181 Commits
Author SHA1 Message Date
ppc e77f401e0c update readme again 2026-01-10 10:22:49 +00:00
ppc e4353fca97 add windows resource embeddings 2026-01-10 10:09:01 +00:00
ppc 27693cda7c update tokio features 2026-01-10 09:46:05 +00:00
ppc fdf8a248e3 reduce logging noise 2026-01-10 09:46:05 +00:00
ppc 041050dba8 update readme 2026-01-10 09:45:52 +00:00
ppc 572f5ed672 update workflow 2026-01-09 21:05:15 +00:00
ppc ded0109486 remove old c# server impl 2026-01-09 20:36:39 +00:00
ppc 0b44d9bf84 update gitignore 2026-01-09 20:35:52 +00:00
ppc 0ee84c168e add test server 2026-01-09 20:31:53 +00:00
ppc 3cae734dbd expose public api, enable rust library output 2026-01-09 20:30:18 +00:00
ppc 59d1a8bd3e update parsing logic and add tests 2026-01-09 20:09:44 +00:00
ppc 6630496280 make access/idm parsing more strict 2026-01-09 20:00:28 +00:00
ppc d078bea395 disable ansi codes in logging outputs 2026-01-07 20:19:56 +00:00
ppc 8c5ba567e4 add missing enablement check 2026-01-07 20:01:56 +00:00
ppc 46b5f1ab16 write tracing output to console 2026-01-07 20:01:42 +00:00
ppc d7a5911e64 cleanup logging messages 2026-01-07 20:01:01 +00:00
ppc c1876cf4bf remove nested if 2026-01-07 20:00:29 +00:00
ppc 3ac5fe4c78 enable space-saving build opts 2026-01-05 21:32:38 +00:00
ppc 857e718dfd update package name 2026-01-05 21:32:38 +00:00
ppc dcc8cd365e add more logging 2026-01-05 21:32:38 +00:00
ppc a9d6d36eea add card expiry checks 2026-01-05 21:32:38 +00:00
ppc 4a7aea406a update file-based read logic 2026-01-05 21:32:38 +00:00
ppc d043f57ad0 finish implementing features, add logging, windows-specific features 2026-01-05 21:32:38 +00:00
ppc 5c1360ecdc create cargo workspace 2026-01-05 21:32:38 +00:00
ppc 2125753e37 complete base rewrite 2026-01-05 21:32:38 +00:00
ppc 96a9e2a498 initial rust commit 2026-01-05 21:32:38 +00:00
ppc 563362eea5 update native app deps 2025-12-30 12:53:30 +00:00
ppc 839dfe6e00 use different animations lib 2025-12-30 12:32:31 +00:00
ppc a03abbde4e fix codemod changes 2025-12-30 12:32:19 +00:00
ppc 9bf1602d5d drop animations library 2025-12-30 12:24:24 +00:00
ppc 1075253573 update shared library deps 2025-12-30 12:20:45 +00:00
ppc 6c692b71fa codemod update components 2025-12-30 12:20:36 +00:00
ppc 8021ee3a7a upgrade tailwind to v4 2025-12-30 12:19:47 +00:00
ppc 622f87242a bump web deps 2025-12-30 12:03:50 +00:00
ppc 8bceb4d422 bump remaining deps 2025-10-04 19:51:49 +01:00
ppc 1ce3cea61e fix building with ios 26 (add compat switch) 2025-10-04 19:51:33 +01:00
ppc 63847543ca bump android version 2025-10-04 10:50:27 +01:00
ppc 81ca4a73f9 bump some packages (non-expo ones) 2025-10-04 10:47:46 +01:00
ppc 5dc7880c58 update nfc alert message 2025-07-29 15:55:12 +01:00
ppc d708c6e2c5 fix ios status bar colours when opening modals 2025-07-29 15:50:20 +01:00
ppc e2cd595e8e remove unused packages 2025-07-29 15:50:20 +01:00
ppc 88cc6833f2 bump android version 2025-07-29 15:50:20 +01:00
ppc e5e158f8d2 make modal header buttons the same font weight 2025-07-29 15:50:20 +01:00
ppc f032fc248a incorrect text variant 2025-07-29 15:50:20 +01:00
ppc 130e4c3789 fix remaining header-related issues 2025-07-29 15:50:20 +01:00
ppc ab766de83a add missing colour tinting 2025-07-29 15:50:20 +01:00
ppc 889e492bb0 resolve depreciation warnings 2025-07-29 15:50:20 +01:00
ppc 97d0a0e437 update core expo version, fix app.json 2025-07-29 15:50:20 +01:00
ppc 591680d521 fix non-bold header on ios 2025-07-29 15:50:20 +01:00
ppc 1d0e40961c upgrade to expo 53 2025-07-29 15:50:20 +01:00
ppc 435dbad3a5 update cswin32 2025-06-17 18:23:55 +01:00
ppc f0125be89d update vulnerable packages 2025-06-17 13:54:03 +01:00
ppc 3511efcc04 add confirmation before deleting server 2025-05-08 20:59:41 +01:00
ppc 0edd7e13fa warning update 2025-05-08 20:59:28 +01:00
ppc 95be807720 bump version numbers 2025-05-04 20:25:39 +01:00
ppc 78668a1b6a improve non-nfc enabled apple device message 2025-05-04 19:10:28 +01:00
ppc d917f8832a move long-press server menu to actual page 2025-05-04 19:08:57 +01:00
ppc e39a117018 try to reduce long-press overlaps 2025-04-08 10:43:34 +01:00
ppc 40fbf475f4 fix schema linkage 2025-04-02 19:27:24 +01:00
ppc 4e69f9a4b1 prevent nested transactions 2025-04-02 08:25:26 +01:00
ppc 672e44593f title case consistency 2025-04-01 20:43:05 +01:00
ppc f0d6bd6c95 update game ids collection 2025-04-01 20:41:14 +01:00
ppc 83de0a558b fix broken nested navigation 2025-04-01 20:34:07 +01:00
ppc 2e831831a2 remove redundant state change 2025-04-01 20:22:38 +01:00
ppc 318e113445 ensure cards are switched correctly and records are swapped without error 2025-04-01 20:21:56 +01:00
ppc c6f9251cf2 fix bad schema relationships 2025-04-01 20:20:06 +01:00
ppc faf688fc0b update native packages 2025-04-01 19:44:50 +01:00
ppc 1b73b60efa move "most recent card used" tracking to separate table 2025-03-29 10:21:09 +00:00
ppc f2550a84e0 update web/shared packages 2025-03-29 10:20:54 +00:00
ppc 71f343e9b5 Merge remote-tracking branch 'origin/master' 2025-03-16 19:01:39 +00:00
ppc 270c65cddb add support for SEGATOOLS_CONFIG_PATH 2025-03-16 19:01:22 +00:00
ppc 21766ae449 allow http requests to any address 2025-03-13 11:12:30 +00:00
ppc 0bb34f04be update js dependencies 2025-02-05 08:43:49 +00:00
ppc 1e5e8b3a4d remove useless warning 2025-02-05 08:43:49 +00:00
ppc 805af19006 special treatment for limited aime cards 2025-02-05 08:43:48 +00:00
ppc 7f51e4a0ee Merge branch 'ic-scanning'
# Conflicts:
#	amnet-native/app.json
2025-01-24 12:41:29 +00:00
ppc 4d43943568 bump expo version (fix android splash screen) 2025-01-24 12:36:38 +00:00
ppc d8d29c3f84 formatting fix 2025-01-21 19:08:32 +00:00
ppc 0a204b249a Merge remote-tracking branch 'tendokyu/ic-scanning' into ic-scanning 2025-01-21 19:05:54 +00:00
ppc ae7483b156 version bump 2025-01-21 19:05:49 +00:00
ppc 5bb477741d remove diagnostic logging, update readme 2025-01-19 20:01:07 +00:00
ppc 3fbea0f925 fix idm value passing 2025-01-19 19:23:59 +00:00
ppc 2bc9c9f673 perform dfc checks before spad decryption 2025-01-15 13:55:47 +00:00
ppc 5b19207a91 Merge remote-tracking branch 'tendokyu/ic-scanning' into ic-scanning 2025-01-15 11:25:39 +00:00
ppc bc48917fe8 fix card scanning using previously selected card 2025-01-15 11:25:30 +00:00
ppc 13cee9a431 update logging output 2025-01-15 11:13:52 +00:00
ppc 874ba06e29 fix logic check 2025-01-15 11:13:21 +00:00
ppc 16c0ff54c6 add message for bootleg ic cards 2025-01-15 00:19:03 +00:00
ppc 73d333cc23 validate cards when manually entering them 2025-01-14 23:57:36 +00:00
ppc 393965d252 vulnerable package fix 2025-01-14 23:30:54 +00:00
ppc 29b303cde8 generate valid access codes on web 2025-01-14 23:30:04 +00:00
ppc e83287c7b7 add amusement ic access code encode/decode 2025-01-14 23:29:52 +00:00
ppc 9324b30e7b check for existing entry to select when scanning a physical card 2025-01-14 22:20:53 +00:00
ppc d0c7616ed1 remove unused type 2025-01-14 22:18:21 +00:00
ppc 99fd999c06 fix server ordering (latest first) 2025-01-14 22:17:48 +00:00
ppc 0a42a0a912 send idm with card in requests 2025-01-14 18:52:08 +00:00
ppc a6ad82ffda documentation fixes/config key simplification 2025-01-14 15:56:53 +00:00
ppc 31c56d2449 update server to implement felica polling 2025-01-14 15:46:35 +00:00
ppc b33670d849 move modal options to container 2025-01-13 21:17:51 +00:00
ppc 9c8c4fd286 improve HTTPS server warning 2025-01-13 21:17:38 +00:00
ppc 0a80da6614 improve ux when scanning already imported physical card 2025-01-13 21:17:26 +00:00
ppc 489b56015c change transport ic icon to rail symbol 2025-01-13 21:17:06 +00:00
ppc 89338c794a additional nfc support checks 2025-01-13 20:16:11 +00:00
ppc aae5039660 entitlement fix 2025-01-12 21:24:00 +00:00
ppc 690a5b591c better NFCReaderUsageDescription 2025-01-12 21:17:29 +00:00
ppc f9b2326853 add physical card scan to server page 2025-01-12 21:14:01 +00:00
ppc b2be622271 version bump 2025-01-12 20:53:47 +00:00
ppc e0ba6c04f2 add idm/access client-side handling 2025-01-12 20:53:35 +00:00
ppc 6c7479225c update readme 2025-01-12 20:42:46 +00:00
ppc 42cb241487 add idm/access code preference for physical cards (server) 2025-01-12 20:19:46 +00:00
ppc 297ad685a7 add comment 2025-01-12 20:07:21 +00:00
ppc d52757f134 make modal navigation headers the same 2025-01-12 20:06:55 +00:00
ppc a4d4e03eb9 new "card add" flow 2025-01-12 19:45:24 +00:00
ppc 3e3755471d refactor aimecard to iccard 2025-01-12 19:44:45 +00:00
ppc adb60dd928 update realm models 2025-01-12 19:44:06 +00:00
ppc c8763dd65e add ICCard scanning support 2025-01-12 12:48:48 +00:00
ppc 2d3b129126 bump android package version 2024-12-13 20:46:08 +00:00
ppc b729b4e31b more package updates 2024-12-13 20:22:34 +00:00
ppc 377fe93645 Merge remote-tracking branch 'origin/master' 2024-12-13 19:52:42 +00:00
ppc cbd2825475 update to expo 52 2024-11-28 14:52:31 +00:00
ppc b9bbcc9228 update workflow to .net 9 2024-11-28 14:00:33 +00:00
ppc 61fb81c75c add android links to readme 2024-11-28 13:52:09 +00:00
ppc d3b96d3017 don't render invisible icons on android 2024-11-22 16:22:51 +00:00
ppc 9b136006a0 add build-properties plugin 2024-11-21 22:52:48 +00:00
ppc 2eb61d74a7 assetlinks.json fix 2024-11-18 22:13:28 +00:00
ppc 89101c5d5f more fun 2024-11-18 22:00:54 +00:00
ppc 2fe9238d02 update dll metadata 2024-11-18 22:00:54 +00:00
ppc d1b626ffac add android asset link validation file 2024-11-18 22:00:53 +00:00
ppc fe7ab1c50f prepare android release bundling 2024-11-18 22:00:53 +00:00
ppc 5a3a7be906 add android intent filters 2024-11-18 22:00:53 +00:00
ppc 703067e6d6 whitespace removal 2024-11-18 22:00:53 +00:00
ppc 67f90397b0 upgrade server to .net9 2024-11-18 22:00:53 +00:00
ppc 8ba439721f android-specific adjustments 2024-11-18 22:00:53 +00:00
ppc 35a8c28c0e more variable naming 2024-11-09 20:21:33 +00:00
ppc a0b44c165b improve variable naming 2024-11-09 20:14:25 +00:00
ppc 489a93adfe update card selection, automatically check for server startup to end 2024-11-09 20:11:00 +00:00
ppc 6172f2bbd7 fix site on ios 12 2024-11-09 16:47:07 +00:00
ppc 414a199552 target es2019 (safari 12+) 2024-11-09 15:53:27 +00:00
ppc a583b72798 update expo sdk version 2024-11-06 16:04:15 +00:00
ppc 33cd321325 improve card selection when changing games 2024-11-06 16:02:59 +00:00
ppc 85b900ea30 fix haptic feedback not happening on iOS (see https://github.com/facebook/react-native/issues/43388) 2024-10-26 18:08:31 +01:00
ppc 4038de583d fix card unselection on reload 2024-10-26 18:07:57 +01:00
ppc b08a7cd125 show oldest cards at the top 2024-10-14 09:41:15 +01:00
ppc 4b6037fe24 fix bad selection logic 2024-09-28 09:22:43 +01:00
ppc 4702052cc4 prevent invalid array access 2024-09-26 12:13:46 +01:00
ppc 969edd3675 add game-default card indicator 2024-09-26 12:09:38 +01:00
ppc 0d200a22fc update vulnerable packages (CVE-2024-47068, CVE-2024-45812 and CVE-2024-45811) 2024-09-24 12:44:26 +01:00
ppc c30abfe482 auto-select card last used with specific game 2024-09-20 11:57:06 +01:00
ppc 6628071f5b fix dark mode inconsistency 2024-09-20 11:55:27 +01:00
ppc b29daecdcd output var fix 2024-09-05 11:19:04 +01:00
ppc 57d9f07a64 Merge branch 'add-server-metrics' 2024-09-05 11:09:00 +01:00
ppc a538812aee disable auto tagging in actions 2024-09-05 11:06:10 +01:00
ppc dff641e6f1 colour fix 2024-09-05 11:05:27 +01:00
ppc 00736e72dd add startup alert to web 2024-09-05 10:55:03 +01:00
ppc f4dd845ee6 add game name to page, make detail icons use same classes 2024-09-05 10:45:50 +01:00
ppc e467329db8 version bump 2024-09-05 10:45:10 +01:00
ppc e7c521cf2e update padding values 2024-09-04 12:12:58 +01:00
ppc a332562928 add server startup warning message 2024-09-04 11:21:10 +01:00
ppc 245a26aecf explicitly serialize null values 2024-09-04 11:20:56 +01:00
ppc 8a1c751448 change param type for readability 2024-09-04 10:21:52 +01:00
ppc 73b7800bf3 merge serializer contents into webserver class 2024-09-04 10:19:51 +01:00
ppc cff5989a8d add instance metrics to server 2024-09-04 10:17:14 +01:00
ppc 20577f76f0 split and rename server response interfaces 2024-09-04 10:15:29 +01:00
ppc 91dc31226d fix manifest name 2024-09-03 18:15:58 +01:00
ppc 7b5c1b9672 move connection error message to own component 2024-09-03 18:00:50 +01:00
ppc 963f2ddb48 add game name display support 2024-09-03 17:59:10 +01:00
ppc 153d3ff7ad remove enum, replace with string-only method 2024-09-03 17:06:19 +01:00
ppc 9fe0dcc35c use empty string over null 2024-09-03 16:03:40 +01:00
ppc 6acddc2f78 add server share option to context menu 2024-09-03 15:02:11 +01:00
ppc 15329d5dcd add card edit support 2024-09-03 14:52:52 +01:00
ppc ef8e03ee9b fix 2024-09-03 14:19:09 +01:00
ppc a7217c8e31 add game name resolution 2024-09-03 14:15:43 +01:00
ppc 1b3865b325 apply updates to amnet-native 2024-09-03 14:12:26 +01:00
ppc 40934f999d update web to use new method signatures 2024-09-03 14:09:36 +01:00
ppc 087f657563 rebuild local lib 2024-09-03 14:07:22 +01:00
ppc fdfbe73bb2 require api version with server connection requests 2024-09-03 14:07:04 +01:00
ppc e0a0002ca1 add server/website build script 2024-09-03 14:03:40 +01:00
ppc ba58e3af1b document keyboard mode setting switch 2024-09-02 16:28:53 +01:00
ppc e4cb903198 add hold-enter-to-login support back 2024-09-02 16:24:00 +01:00
ppc 14efbc4971 return parsed card when setting 2024-09-02 16:23:48 +01:00
ppc ec98003d2c move card storage/web server handlers to separate files 2024-09-02 14:14:02 +01:00
106 changed files with 12399 additions and 12254 deletions
+83
View File
@@ -0,0 +1,83 @@
name: Release
on:
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
strategy:
matrix:
include:
- { target: "x86_64-pc-windows-msvc", filename: "amnet.dll", include_test_server: true }
- { target: "i686-pc-windows-msvc", filename: "amnet_x86.dll", include_test_server: false }
steps:
- uses: actions/checkout@v4
- name: Set target
run: |
rustup target add ${{ matrix.target }}
rustup set default-host ${{ matrix.target }}
- name: Perform build
run: cargo build --release
- name: Upload .dll
run: |
curl -s -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" -H "Content-Type: application/octet-stream" --data-binary "@.\amnet-server\target\release\amnet.dll" "${{ needs.get-upload-url.outputs.upload_url }}?name=${{ matrix.filename }}"
- name: Upload test server
if: "matrix.include_test_server"
run: |
curl -s -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" -H "Content-Type: application/octet-stream" --data-binary "@.\amnet-server\target\release\amnet-server-test.exe" "${{ needs.get-upload-url.outputs.upload_url }}?name=amnet-server.exe"
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"
+15 -120
View File
@@ -1,126 +1,6 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# ReSharper
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# DotCover is a Code Coverage Tool
*.dotCover
# Click-Once directory
publish/
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Local History for Visual Studio Code # Local History for Visual Studio Code
.history/ .history/
# JetBrains Rider
*.sln.iml
# Logs # Logs
logs logs
*.log *.log
@@ -275,3 +155,18 @@ docs/pages/versions/*/react-native/*.diff
# Project-Specific # Project-Specific
/amnet-web/dist /amnet-web/dist
# Generated by Cargo
# will have compiled files and executables
debug
target
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Generated by cargo mutants
# Contains mutation testing data
**/mutants.out*/
-41
View File
@@ -1,41 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<PublishAot>true</PublishAot>
<OutputType>Library</OutputType>
<EventSourceSupport>false</EventSourceSupport>
<TargetFramework>net8.0-windows</TargetFramework>
<OptimizationPreference>Size</OptimizationPreference>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
</PropertyGroup>
<PropertyGroup>
<AssemblyName>amnet</AssemblyName>
<ProductName>AMNet</ProductName>
<CompanyName>ppc</CompanyName>
<Title>AMNet Server</Title>
<Description>Server component for the AMNet IC Card switcher</Description>
<Copyright>Copyright 2024 (c) ppc</Copyright>
</PropertyGroup>
<PropertyGroup>
<DebugType>None</DebugType>
<DebugSymbols>false</DebugSymbols>
<MetricsSupport>false</MetricsSupport>
<DebuggerSupport>false</DebuggerSupport>
<EventSourceSupport>false</EventSourceSupport>
<InvariantGlobalization>true</InvariantGlobalization>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
-206
View File
@@ -1,206 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.Json;
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.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();
static DllMain()
{
PInvoke.AllocConsole();
}
[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 =>
{
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;
}
if (request.MatrixCode.Any(x => !char.IsNumber(x)))
{
ctx.Response.StatusCode = 422;
await ctx.Response.WriteAsync("Invalid card id format.");
return;
}
lock (_cardLock)
{
// ratelimit check
if (_currentCard?.Expires > Environment.TickCount64)
{
ctx.Response.StatusCode = 429;
ctx.Response.Headers.RetryAfter = ((int)TimeSpan.FromMilliseconds(_currentCard.Value.Expires - Environment.TickCount64).TotalSeconds).ToString();
return;
}
// ensure the matrix code is 20-digits long, otherwise pad with zeros
var matrixCode = request.MatrixCode.PadLeft(20, '0');
var bytes = new byte[10];
for (var i = 0; i < 10; i++)
{
var value = matrixCode.Substring(i * 2, 2);
bytes[i] = byte.Parse(value, NumberStyles.HexNumber);
}
// it's called a tick count, but it uses milliseconds despite having a unit of time called a tick???
_currentCard = (bytes, matrixCode, Environment.TickCount64 + 5000);
}
ctx.Response.StatusCode = 202;
});
CancellationTokenRegistration cancellationRegistration = default;
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();
return 0;
}
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_poll")]
public static int NfcPoll(byte unitNo)
{
if (_currentCard.HasValue && _currentCard.Value.Expires < Environment.TickCount64)
{
lock (_cardLock)
{
_currentCard = null;
}
}
// 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)
{
lock (_cardLock)
{
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 0;
}
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_get_felica_id")]
public static unsafe int GetFelicaId(byte unitNo, ulong* idm)
{
// felica not supported
return 1;
}
[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
View File
@@ -1,3 +0,0 @@
GetPrivateProfileStringW
GetPrivateProfileInt
AllocConsole
-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;
-44
View File
@@ -1,44 +0,0 @@
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace AMNet.Server;
internal static class WebServer
{
public static WebApplicationBuilder BuildServer()
{
var builder = WebApplication.CreateSlimBuilder([]);
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(o =>
{
o.SingleLine = true;
o.IncludeScopes = false;
});
builder.Logging.SetMinimumLevel(LogLevel.Information);
builder.Logging.AddFilter("Microsoft.AspNetCore.Http.Result", LogLevel.None);
builder.Logging.AddFilter("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogLevel.Error);
builder.Services.AddCors(cors =>
{
cors.AddDefaultPolicy(policy =>
{
policy.AllowAnyMethod();
policy.AllowAnyOrigin();
policy.AllowAnyHeader();
policy.SetPreflightMaxAge(TimeSpan.FromHours(6));
});
});
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, SerializerContext.Default);
});
return builder;
}
}
-16
View File
@@ -1,16 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMNet.Server", "AMNet.Server\AMNet.Server.csproj", "{C9FA2545-F15C-4517-B3AB-55B8FC23FA04}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C9FA2545-F15C-4517-B3AB-55B8FC23FA04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C9FA2545-F15C-4517-B3AB-55B8FC23FA04}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C9FA2545-F15C-4517-B3AB-55B8FC23FA04}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C9FA2545-F15C-4517-B3AB-55B8FC23FA04}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
Generated
+882
View File
@@ -0,0 +1,882 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "amnet"
version = "2.0.0"
dependencies = [
"axum",
"hostname",
"rust-ini",
"serde",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
"winresource",
]
[[package]]
name = "amnet-server-test"
version = "2.0.0"
dependencies = [
"amnet",
"axum",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bytes"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"pin-utils",
]
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link",
]
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
]
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.179"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "proc-macro2"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "ryu"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.148"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "syn"
version = "2.0.113"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tokio"
version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml"
version = "0.9.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tower"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags",
"bytes",
"http",
"http-body",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
[[package]]
name = "winresource"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b021990998587d4438bb672b5c5f034cbc927f51b45e3807ab7323645ef4899"
dependencies = [
"toml",
"version_check",
]
[[package]]
name = "zmij"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcb2c125bd7365735bebeb420ccb880265ed2d2bddcbcd49f597fdfe6bd5e577"
+8
View File
@@ -0,0 +1,8 @@
[workspace]
members = ["amnet-server", "amnet-server-test"]
resolver = "3"
[workspace.package]
version = "2.0.0"
authors = ["ppc"]
description = "AMNet IC Card Switcher"
+21 -17
View File
@@ -1,14 +1,14 @@
import React, {useEffect} from "react"; import React from "react";
import {StatusBar} from "expo-status-bar"; import {StatusBar} from "expo-status-bar";
import {useColorScheme, View} from "react-native";
import {Server, WalletCards} from "lucide-react-native"; import {Server, WalletCards} from "lucide-react-native";
import {Platform, useColorScheme, View} from "react-native";
import {NavigationContainer} from "@react-navigation/native"; import {NavigationContainer} from "@react-navigation/native";
import {SafeAreaProvider} from "react-native-safe-area-context"; import {SafeAreaProvider} from "react-native-safe-area-context";
import {MD3Colors} from "react-native-paper/lib/typescript/types"; import {MD3Colors} from "react-native-paper/lib/typescript/types";
import {createBottomTabNavigator} from "@react-navigation/bottom-tabs"; import {createBottomTabNavigator} from "@react-navigation/bottom-tabs";
import {createNativeStackNavigator} from "@react-navigation/native-stack"; import {createNativeStackNavigator} from "@react-navigation/native-stack";
import {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native';
import {JetBrainsMono_400Regular, useFonts} from "@expo-google-fonts/jetbrains-mono"; import {JetBrainsMono_400Regular, useFonts} from "@expo-google-fonts/jetbrains-mono";
import {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native';
import { import {
ActivityIndicator, ActivityIndicator,
adaptNavigationTheme, adaptNavigationTheme,
@@ -16,15 +16,18 @@ import {
MD3LightTheme, MD3LightTheme,
MD3Theme, MD3Theme,
PaperProvider, PaperProvider,
Paragraph Text
} from 'react-native-paper'; } from 'react-native-paper';
import RealmContext from "./models/RealmContext";
import linking from "./Linking"; import linking from "./Linking";
import RealmContext from "./models/RealmContext";
import {AMUITabBar} from "./components/AMUITabBar"; import {AMUITabBar} from "./components/AMUITabBar";
import CardAddModal from "./app/CardAddModal";
import CardEditorModal from "./app/card/CardEditorModal";
import CardAddModal from "./app/card/CardAddModal";
import ServerAddModal from "./app/ServerAddModal"; import ServerAddModal from "./app/ServerAddModal";
import CardManagerStack from "./app/CardManagerStack"; import CardManagerStack from "./app/CardManagerStack";
import ServerManagerStack from "./app/ServerManagerStack"; import ServerManagerStack from "./app/ServerManagerStack";
@@ -69,6 +72,7 @@ const {LightTheme, DarkTheme} = adaptNavigationTheme({
}); });
const {RealmProvider} = RealmContext; const {RealmProvider} = RealmContext;
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
@@ -76,17 +80,11 @@ function AppContent() {
return (<Tab.Navigator tabBar={p => <AMUITabBar {...p}/>} screenOptions={{headerShown: false}}> return (<Tab.Navigator tabBar={p => <AMUITabBar {...p}/>} screenOptions={{headerShown: false}}>
<Tab.Screen name="servers" <Tab.Screen name="servers"
component={ServerManagerStack} component={ServerManagerStack}
options={{ options={{tabBarIcon: ({color, size}) => (<Server color={color} size={size}/>)}}/>
title: 'Servers',
tabBarIcon: ({color, size}) => (<Server color={color} size={size}/>)
}}/>
<Tab.Screen name="cards" <Tab.Screen name="cards"
component={CardManagerStack} component={CardManagerStack}
options={{ options={{tabBarIcon: ({color, size}) => (<WalletCards color={color} size={size}/>)}}/>
title: "Cards",
tabBarIcon: ({color, size}) => (<WalletCards color={color} size={size}/>)
}}/>
</Tab.Navigator>) </Tab.Navigator>)
} }
@@ -100,7 +98,7 @@ export default function AppRoot() {
return ( return (
<View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}> <View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}>
<ActivityIndicator animating/> <ActivityIndicator animating/>
<Paragraph>Starting...</Paragraph> <Text variant="bodyMedium">Starting...</Text>
</View> </View>
) )
} }
@@ -116,9 +114,15 @@ export default function AppRoot() {
{/* main app */} {/* main app */}
<Stack.Screen name="app" component={AppContent} options={{headerShown: false}}/> <Stack.Screen name="app" component={AppContent} options={{headerShown: false}}/>
<Stack.Group screenOptions={{presentation: "modal"}}> <Stack.Group screenOptions={{
presentation: "modal",
headerTitleStyle: {
fontWeight: "bold"
}
}}>
<Stack.Screen name="add-server" component={ServerAddModal} options={{title: "Add Server"}}/> <Stack.Screen name="add-server" component={ServerAddModal} options={{title: "Add Server"}}/>
<Stack.Screen name="add-card" component={CardAddModal} options={{title: "Add Card"}}/> <Stack.Screen name="add-card" component={CardAddModal} options={{title: "Add Card", headerShown: Platform.OS !== 'ios'}}/>
<Stack.Screen name="edit-card" component={CardEditorModal} options={{title: "Edit Card"}}/>
</Stack.Group> </Stack.Group>
</Stack.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>
+93 -22
View File
@@ -1,25 +1,96 @@
{ {
"name": "AMNet", "expo": {
"scheme": "amnet", "name": "AMNet",
"version": "1.0.0", "scheme": "amnet",
"slug": "amnet-native", "version": "1.3.2",
"icon": "./assets/icon.png", "slug": "amnet-native",
"userInterfaceStyle": "automatic", "icon": "./assets/icon.png",
"ios": { "userInterfaceStyle": "automatic",
"bundleIdentifier": "moe.ppc.amnet", "newArchEnabled": true,
"supportsTablet": true, "ios": {
"associatedDomains": [ "bundleIdentifier": "moe.ppc.amnet",
"applinks:card.ppc.moe" "supportsTablet": true,
], "associatedDomains": [
"infoPlist": { "applinks:card.ppc.moe"
"UIViewControllerBasedStatusBarAppearance": "NO" ],
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
},
"UIDesignRequiresCompatibility": true,
"UIViewControllerBasedStatusBarAppearance": false,
"NFCReaderUsageDescription": "AMNet uses NFC to scan Physical IC Cards",
"com.apple.developer.nfc.readersession.felica.systemcodes": [
"0003",
"88b4"
]
},
"entitlements": {
"com.apple.developer.nfc.readersession.formats": [
"TAG"
]
},
"config": {
"usesNonExemptEncryption": false
}
}, },
"config": { "android": {
"usesNonExemptEncryption": false "package": "moe.ppc.amnet",
} "versionCode": 8,
}, "adaptiveIcon": {
"android": { "foregroundImage": "./assets/icon-android.png",
"package": "moe.ppc.amnet" "backgroundColor": "#DED8FD"
}, },
"plugins": [] "splash": {
"resizeMode": "cover",
"image": "./assets/icon-android.png",
"backgroundColor": "#DED8FD",
"dark": {
"backgroundColor": "#3B5ACC"
}
},
"intentFilters": [
{
"autoVerify": true,
"action": "VIEW",
"data": [
{
"scheme": "http",
"host": "card.ppc.moe",
"pathPrefix": "/#/servers"
},
{
"scheme": "http",
"host": "card.ppc.moe",
"pathPrefix": "/#/cards"
},
{
"scheme": "https",
"host": "card.ppc.moe",
"pathPrefix": "/#/servers"
},
{
"scheme": "https",
"host": "card.ppc.moe",
"pathPrefix": "/#/cards"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
]
}
]
},
"plugins": [
[
"expo-build-properties",
{
"android": {
"usesCleartextTraffic": true
}
}
]
]
}
} }
-96
View File
@@ -1,96 +0,0 @@
import {generateCardNumber} from "amnet-shared";
import {useModalStyles} from "../utils/ModalStyles";
import {useCallback, useLayoutEffect, useState} from "react";
import {TextInput, TouchableOpacity, View} from "react-native";
import {Button, Card, Divider, Paragraph, useTheme} from "react-native-paper";
import {AMCard} from "../models/AMCard";
import RealmContext from "../models/RealmContext";
const {useRealm} = RealmContext;
export default function CardAddModal({navigation}) {
const realm = useRealm();
const theme = useTheme();
const modalStyles = useModalStyles();
// state values
const [cardName, setCardName] = useState('');
const [cardNumber, setCardNumber] = useState('');
const cardAddCallback = useCallback((cardId: string, cardName: string) => {
const duplicateCard = realm.objects<AMCard>("card").find(x => x.code == cardNumber);
if (duplicateCard) {
realm.write(() => {
duplicateCard.name = cardName;
});
} else {
realm.write(() => {
realm.create("card", AMCard.generate(cardId, cardName));
});
}
navigation.goBack();
}, [realm]);
// header effects
useLayoutEffect(() => {
navigation.setOptions({
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
<Paragraph style={modalStyles.headerText}>Cancel</Paragraph>
</TouchableOpacity>,
});
}, [navigation, modalStyles]);
useLayoutEffect(() => {
const canSave = cardName.length && cardNumber.length === 20;
const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline};
navigation.setOptions({
headerRight: () => <TouchableOpacity disabled={!canSave}
onPress={() => cardAddCallback(cardNumber, cardName)}>
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
</TouchableOpacity>
})
}, [navigation, modalStyles, cardName, cardNumber]);
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
<Card mode="contained">
<Card.Content>
<View style={modalStyles.formItemRow}>
<Paragraph style={modalStyles.formItemName}>
Card Name
</Paragraph>
<TextInput style={modalStyles.formItemInput}
numberOfLines={1} autoFocus
value={cardName}
onChangeText={setCardName}
placeholder="Primary Card"
placeholderTextColor={theme.colors.outline}/>
</View>
<Divider style={{marginVertical: 15}}/>
<View style={modalStyles.formItemRow}>
<Paragraph style={modalStyles.formItemName}>
Matrix Code
</Paragraph>
<TextInput style={modalStyles.formItemInput}
numberOfLines={1}
keyboardType="numeric"
placeholder="0000 0000 0000 0000 0000"
placeholderTextColor={theme.colors.outline}
value={cardNumber.match(/.{1,4}/g)?.join(" ")}
onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}/>
</View>
</Card.Content>
</Card>
<Paragraph style={modalStyles.formFootnote}>
If you have a physical card, you can enter the 20-digit code on the back.
</Paragraph>
<Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Matrix Code</Button>
</View>)
}
+45 -25
View File
@@ -1,60 +1,73 @@
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import {FlatList, View} from "react-native"; import {FlatList, Platform, View} from "react-native";
import React, {useCallback, useMemo} from "react"; import React, {useCallback, useMemo} from "react";
import {Button, useTheme} from "react-native-paper"; import {Button, useTheme} from "react-native-paper";
import {CreditCard, Plus} from "lucide-react-native"; import {CreditCard, Plus} from "lucide-react-native";
import ContextMenu from "react-native-context-menu-view"; import ContextMenu from "react-native-context-menu-view";
import {AMCard} from "../models/AMCard"; import {ICCard} from "../components/ICCard";
import {AimeCard} from "../components/AimeCard";
import RealmContext from "../models/RealmContext";
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder"; import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
import {AMCard} from "../models/AMCard";
import RealmContext from "../models/RealmContext";
const {useQuery, useRealm} = RealmContext; const {useQuery, useRealm} = RealmContext;
function CardEntry(props: { card: AMCard }) { function CardEntry(props: { card: AMCard, navigation: any }) {
const realm = useRealm(); const realm = useRealm();
const callback = useCallback(async (index: number) => { const callback = useCallback(async (indexPath: number[]) => {
if (!props.card.isValid()) { if (!props.card.isValid()) {
return; return;
} }
switch (index) { switch (indexPath.toString()) {
// copy card no // copy card access code
case 0: case "0":
case "0,0":
await Clipboard.setStringAsync(props.card.code); await Clipboard.setStringAsync(props.card.code);
break; return;
case 1: // edit card
realm.write(() => { case "1,0":
realm.delete(props.card); props.navigation.navigate('edit-card', {cardId: props.card._id.toHexString()});
}); return;
break;
// remove card
case "1,1":
AMCard.delete(realm, props.card);
return;
} }
}, [realm, props.card]); }, [realm, props.card, props.navigation]);
return ( return (
<ContextMenu <ContextMenu
onPress={e => callback(e.nativeEvent?.index)} onPress={e => callback(e.nativeEvent?.indexPath)}
actions={[ actions={[
{title: "Copy Card Number", systemIcon: "doc.on.doc"}, {title: "Copy Card Number", systemIcon: "doc.on.doc"},
{title: "Delete Card", destructive: true, systemIcon: "trash"}]}> {
<AimeCard card={props.card} showFullCardNumber/> title: Platform.OS === 'ios' ? "" : "Additional Actions",
inlineChildren: true,
actions: [
{title: "Edit Card", systemIcon: "square.and.pencil"},
{title: "Delete Card", destructive: true, systemIcon: "trash"}]
}]}>
<ICCard card={props.card} showFullCardNumber/>
</ContextMenu> </ContextMenu>
); );
} }
export default function CardManager({navigation}) { export default function CardManager({navigation}) {
const query = useQuery(AMCard);
const cards = useMemo(() => query.sorted("added", true), [query]);
const theme = useTheme(); const theme = useTheme();
const query = useQuery(AMCard);
const cards = useMemo(() => query.sorted("added", false), [query]);
if (!cards.length) { if (!cards.length) {
return ( return (
<View style={{flex: 1, gap: 10, justifyContent: "center", alignItems: "center"}}> <View style={{flex: 1, gap: 10, justifyContent: "center", alignItems: "center"}}>
<EmptyStatePlaceholder message="No cards added yet" icon={<CreditCard color="gray"/>}/> <EmptyStatePlaceholder message="No cards added yet" icon={<CreditCard color="gray"/>}/>
<Button mode="text" onPress={() => navigation.navigate('add-card')} icon={() => <Plus size={18} color={theme.colors.primary}/>}>Add Card</Button> <Button mode="text"
onPress={() => navigation.navigate('add-card')}
icon={() => <Plus size={18} color={theme.colors.primary}/>}>Add Card</Button>
</View> </View>
) )
} }
@@ -64,8 +77,15 @@ export default function CardManager({navigation}) {
style={{padding: 16, paddingTop: 24}} style={{padding: 16, paddingTop: 24}}
data={cards} data={cards}
keyExtractor={i => i._id.toString()} keyExtractor={i => i._id.toString()}
contentContainerStyle={{paddingBottom: 40}} contentContainerStyle={{paddingBottom: 120}}
renderItem={i => <CardEntry card={i.item} navigation={navigation}/>}
ItemSeparatorComponent={() => <View style={{height: 15}}/>} ItemSeparatorComponent={() => <View style={{height: 15}}/>}
renderItem={i => <CardEntry card={i.item}/>}/> ListFooterComponent={() => Platform.OS === "ios" ? null : (
<Button mode="text"
onPress={() => navigation.navigate('add-card')}
style={{marginTop: 15, marginBottom: 25}}
icon={() => <Plus size={18} color={theme.colors.primary}/>}>Add Card</Button>
)}
/>
) )
} }
+9 -3
View File
@@ -1,6 +1,6 @@
import {Plus} from "lucide-react-native"; import {Plus} from "lucide-react-native";
import {TouchableOpacity} from "react-native";
import {useTheme} from "@react-navigation/native"; import {useTheme} from "@react-navigation/native";
import {Platform, TouchableOpacity} from "react-native";
import {createNativeStackNavigator} from "@react-navigation/native-stack"; import {createNativeStackNavigator} from "@react-navigation/native-stack";
import CardManager from "./CardManager"; import CardManager from "./CardManager";
@@ -15,14 +15,20 @@ export default function CardManagerStack({navigation}) {
headerLargeTitle: true, headerLargeTitle: true,
headerBlurEffect: "regular", headerBlurEffect: "regular",
headerStyle: { headerStyle: {
backgroundColor: theme.colors.background, backgroundColor: theme.colors.background
},
headerTitleStyle: {
fontWeight: "bold"
},
headerLargeTitleStyle: {
fontWeight: "bold"
} }
}}> }}>
<Stack.Screen name="card-listing" <Stack.Screen name="card-listing"
component={CardManager} component={CardManager}
options={{ options={{
title: "Cards", title: "Cards",
headerRight: () => <TouchableOpacity onPress={() => navigation.navigate('add-card')}><Plus/></TouchableOpacity> headerRight: () => Platform.OS === 'android' ? null : <TouchableOpacity onPress={() => navigation.navigate('add-card')}><Plus color={theme.colors.primary}/></TouchableOpacity>
}}/> }}/>
</Stack.Navigator> </Stack.Navigator>
); );
+66 -29
View File
@@ -1,8 +1,9 @@
import {useModalStyles} from "../utils/ModalStyles"; import {StatusBar} from "expo-status-bar";
import {Card, Paragraph, useTheme} from "react-native-paper"; import {Card, Text, useTheme} from "react-native-paper";
import {TextInput, TouchableOpacity, View} from "react-native";
import {useCallback, useEffect, useLayoutEffect, useState} from "react"; import {useCallback, useEffect, useLayoutEffect, useState} from "react";
import {Alert, Platform, TextInput, TouchableOpacity, View} from "react-native";
import {useModalStyles} from "../utils/ModalStyles";
import RealmContext from "../models/RealmContext"; import RealmContext from "../models/RealmContext";
import {AMServer} from "../models/AMServer"; import {AMServer} from "../models/AMServer";
@@ -20,53 +21,88 @@ export default function ServerAddModal({route, navigation}) {
handleDeepLink(realm, navigation, route, setServerAddress); handleDeepLink(realm, navigation, route, setServerAddress);
}, []); }, []);
useLayoutEffect(() => { if (Platform.OS === 'ios') {
navigation.setOptions({ useLayoutEffect(() => {
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}> navigation.setOptions({
<Paragraph style={modalStyles.headerText}>Cancel</Paragraph> headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
</TouchableOpacity>, <Text variant="bodyMedium" style={modalStyles.headerText}>Cancel</Text>
}); </TouchableOpacity>,
}, [navigation, modalStyles]); });
}, [navigation, modalStyles]);
}
useLayoutEffect(() => { useLayoutEffect(() => {
let urlValid = false; let urlValid = false;
try { try {
new URL(serverAddress); const protocol = new URL(serverAddress).protocol;
urlValid = true; urlValid = protocol.localeCompare("http:", undefined, {sensitivity: "case"}) === 0 || protocol.localeCompare("https:", undefined, {sensitivity: "case"}) === 0;
} catch { } catch {
// do nothing // do nothing
} }
const themeOptions = urlValid ? modalStyles.headerText : {color: theme.colors.outline}; const themeOptions = urlValid ? modalStyles.headerText : {color: theme.colors.outline};
navigation.setOptions({ navigation.setOptions({
headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer()}> headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer(serverAddress)}>
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph> <Text variant="bodyMedium" style={{...themeOptions, fontWeight: "600"}}>Save</Text>
</TouchableOpacity> </TouchableOpacity>
}) })
}, [navigation, modalStyles, serverAddress]); }, [navigation, modalStyles, serverAddress]);
const saveServer = useCallback(() => { const saveServer = useCallback(async (address: string) => {
let serverObject = realm.objects<AMServer>("server").find(x => x.serverAddress == serverAddress); if (address.startsWith("https:")) {
await new Promise<void>(done => {
Alert.alert(
"HTTPS Warning",
"By default, AMNet doesn't support HTTPS without advanced configuration. It's highly likely you won't be able to connect.",
[
{
text: "Keep HTTPS",
style: "destructive",
onPress: () => done()
},
{
text: "Change to HTTP",
isPreferred: true,
onPress: () => {
address = address.replace(/^https:/i, "http:");
done();
}
},
]);
});
}
let serverObject = realm.objects<AMServer>(AMServer.schema.name).find(x => x.serverAddress == address);
if (!serverObject) { if (!serverObject) {
realm.write(() => { realm.write(() => {
serverObject = realm.create<AMServer>("server", AMServer.generate(serverAddress)); serverObject = realm.create<AMServer>(AMServer.schema.name, AMServer.generate(address));
}); });
} }
navigation.goBack(); navigation.goBack();
setTimeout(() => navigation.navigate('server', { setTimeout(() => {
serverId: serverObject._id.toHexString() navigation.popTo('app', {
}), 500); screen: "servers",
}, [serverAddress, realm]); params: {
screen: "server",
params: {
serverId: serverObject._id.toHexString()
}
}
});
}, 500);
}, [realm]);
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}> return (<>
{Platform.OS == 'ios' && <StatusBar style="light"/>}
<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
<Card mode="contained"> <Card mode="contained">
<Card.Content> <Card.Content>
<View style={{...modalStyles.formItemRow, gap: 10}}> <View style={{...modalStyles.formItemRow, gap: 10}}>
<Paragraph style={{...modalStyles.formItemName, minWidth: 0}}> <Text variant="bodyMedium" style={{...modalStyles.formItemName, minWidth: 0}}>
Address Address
</Paragraph> </Text>
<TextInput style={modalStyles.formItemInput} <TextInput style={modalStyles.formItemInput}
autoFocus autoFocus
numberOfLines={1} numberOfLines={1}
@@ -82,10 +118,11 @@ export default function ServerAddModal({route, navigation}) {
</Card.Content> </Card.Content>
</Card> </Card>
<Paragraph style={modalStyles.formFootnote}> <Text variant="bodyMedium" style={modalStyles.formFootnote}>
If you're using HTTPS, ensure a trusted certificate has been configured via HTTP.SYS AMNet uses HTTP by default. HTTPS will not work unless specifically configured using HTTP.SYS
</Paragraph> </Text>
</View>) </View>
</>)
} }
function handleDeepLink(realm, navigation, route, setAddress: (address: string) => void) { function handleDeepLink(realm, navigation, route, setAddress: (address: string) => void) {
@@ -102,7 +139,7 @@ function handleDeepLink(realm, navigation, route, setAddress: (address: string)
navigation.goBack(); navigation.goBack();
// allow the homepage to render before moving on // allow the homepage to render before moving on
setTimeout(() => navigation.navigate('server', { setTimeout(() => navigation.popTo('server', {
serverId: item[0]._id.toHexString() serverId: item[0]._id.toHexString()
}), 1000); }), 1000);
} else { } else {
+330 -143
View File
@@ -1,127 +1,206 @@
import {BSON} from "realm"; import {BSON} from "realm";
import {fetchServerInfo, performCardIn} from "amnet-shared"; import NfcManager from "react-native-nfc-manager";
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react"; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react";
import {ActivityIndicator, Button, Card, Paragraph, Title, useTheme} from "react-native-paper"; import {ActivityIndicator, Button, IconButton, Text, useTheme} from "react-native-paper";
import {Alert, RefreshControl, ScrollView, Share, StyleSheet, TouchableOpacity, View} from "react-native"; import {Alert, Platform, RefreshControl, ScrollView, Share, StyleSheet, View} from "react-native";
import {CircleCheck, CircleDot, CreditCard, Nfc, Plus, SmartphoneNfcIcon, WifiOff, Share as ShareIcon} from "lucide-react-native"; import {
CircleCheck,
CircleDot,
CircleEllipsis,
CreditCard,
Plus,
SmartphoneNfcIcon,
WifiOff,
TvMinimalPlay,
Star,
SmartphoneNfc,
SquareArrowOutUpRight
} from "lucide-react-native";
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder"; import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
import {AimeCard} from "../components/AimeCard"; import {AMUIStatusAlert} from "../components/AMUIStatusAlert";
import {AMUICardDetail} from "../components/AMUICard";
import {ICCard} from "../components/ICCard";
import RealmContext from "../models/RealmContext"; import RealmContext from "../models/RealmContext";
import {AMServer} from "../models/AMServer"; import {AMServer} from "../models/AMServer";
import {AMCard} from "../models/AMCard"; import {AMCard} from "../models/AMCard";
import {felicaCardReadAsync, icCard0008Number, ICType} from "../lib/iccard";
import {
AMCardInRequest,
AMServerConnectionInfo,
AMServerInstanceMetrics,
fetchServerInfo,
getGameName,
performCardIn
} from "amnet-shared";
import ObjectId = BSON.ObjectId; import ObjectId = BSON.ObjectId;
import {AMCardRecentUse} from "../models/AMCardRecentUse";
import {MenuAction, MenuView, NativeActionEvent} from "@react-native-menu/menu";
const {useRealm, useQuery, useObject} = RealmContext; const {useRealm, useQuery, useObject} = RealmContext;
export default function ServerCardIn({route, navigation}) { export default function ServerCardIn({route, navigation}) {
const realm = useRealm(); const realm = useRealm();
const theme = useTheme(); const theme = useTheme()
// card listing state const {serverId} = route.params;
const query = useQuery(AMCard); const server = useObject(AMServer, ObjectId.createFromHexString(serverId));
const cards = useMemo(() => query.sorted("lastUsed", true), [query]); const cards = useQuery({
type: AMCard,
query: q => q.sorted("lastUsed", true)
});
const [selectedCard, setSelectedCard] = useState<AMCard>(null); // header actions
const [cardInCompleted, setCardInCompleted] = useState<boolean>(null); const menuCallback = useCallback(async (e: NativeActionEvent) => {
switch (e.nativeEvent.event) {
case "share":
Share.share({url: server.shareUrl});
break;
const unselectedCards = useMemo(() => { case "delete":
// protect against selected card being removed Alert.alert("Delete Server", "Are you sure you want to delete this server?", [
switch (selectedCard?.isValid()) { {
case true: text: "Cancel",
return cards.filter(x => !x._id.equals(selectedCard._id)); style: "cancel"
},
{
text: "Delete",
style: "destructive",
onPress: () => {
realm.write(() => {
realm.delete(server);
navigation.goBack();
});
}
}
]);
// clear out invalid break;
case false:
setSelectedCard(null);
return cards;
default:
return cards;
} }
}, [cards, selectedCard]); }, [realm, server, navigation]);
// nfc support
const [nfcSupported, setNfcSupported] = useState<boolean | null>(Platform.select({android: false, default: null}));
useEffect(() => {
if (nfcSupported !== null) {
return;
}
NfcManager.isSupported().then(setNfcSupported);
}, []);
// server state // server state
const [serverRefreshing, setServerRefreshing] = useState<boolean>(false); const [serverRefreshing, setServerRefreshing] = useState<boolean>(false);
const [serverReloadCounter, setServerReloadCounter] = useState<number>(0); const [serverReloadCounter, setServerReloadCounter] = useState<number>(0);
const [connectionTestResult, setConnectionTestResult] = useState<boolean | null>(null); const [serverConnectionSuccess, setServerConnectionSuccess] = useState<boolean | null>(null);
let {serverId} = route.params; const [userSelectedCard, setUserSelectedCard] = useState<AMCard | null>(null);
const server = useObject(AMServer, ObjectId.createFromHexString(serverId)); const [cardInCompleted, setCardInCompleted] = useState<boolean | null>(null);
const disableCardIn = useMemo(() => cardInCompleted !== null || !serverConnectionSuccess, [cardInCompleted, serverConnectionSuccess]);
const serverDefaultCard = useMemo(() => {
if (server?.isValid() !== true || !server.gameId) {
return null;
}
return realm.objects<AMCardRecentUse>(AMCardRecentUse.schema.name).find(x => x.gameId === server.gameId)?.card;
}, [realm, cards, server?.gameId]);
const selectedCard = useMemo(() => [userSelectedCard, serverDefaultCard, cards.at(0)].find(x => x?.isValid() == true) ?? null, [userSelectedCard, serverDefaultCard, cards]);
const unselectedCards = useMemo(() => cards.filter(x => x.isValid() === true && x._id.toHexString() !== selectedCard?._id.toHexString()), [cards, selectedCard]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!server) { if (server?.isValid() !== true) {
return; return;
} }
navigation.setOptions({ navigation.setOptions({
title: server.serverName, title: server.serverName,
headerRight: () => ( headerRight: () => (
<TouchableOpacity onPress={() => Share.share({url: `http://card.ppc.moe/#/servers/add?address=${encodeURIComponent(server.serverAddress)}`})}> <MenuView actions={menuActions} onPressAction={menuCallback}>
<ShareIcon/> <CircleEllipsis color={theme.colors.primary}/>
</TouchableOpacity> </MenuView>
) )
}); });
}, [navigation, server]); }, [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)
useEffect(() => { useEffect(() => {
if (server?.isValid() !== true) { if (server?.isValid() !== true) {
navigation.goBack(); navigation.goBack();
// don't show error on invalid server, just means it was deleted
if (!server) {
Alert.alert("Server not found", "The server you are looking for does not exist.");
}
return; return;
} }
if (!serverConnectionSuccess) {
return;
}
const metrics = server as AMServerInstanceMetrics;
if (metrics.sessionUptime > 0 && metrics.timeSinceLastPoll === null) {
const timeout = setTimeout(() => setServerReloadCounter(serverReloadCounter + 1), 5000);
return () => clearTimeout(timeout);
}
}, [serverConnectionSuccess, server]);
// invalid card/server selection
useEffect(() => {
if (userSelectedCard?.isValid() === false) {
setUserSelectedCard(null);
}
}, [userSelectedCard]);
// server info update
useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
fetchServerInfo(server.serverAddress, abortController.signal).then(r => { fetchServerInfo(server.serverAddress, abortController.signal).then(r => {
if (r) { if (!r) {
setConnectionTestResult(true); setServerConnectionSuccess(false);
realm.write(() => { return;
server.lastUsed = new Date();
Object.keys(r).forEach(x => server[x] = r[x]);
});
} else {
setConnectionTestResult(false);
} }
setServerConnectionSuccess(true);
realm.write(() => {
server.lastUsed = new Date();
Object.keys(r).forEach(x => server[x] = r[x]);
});
}).catch(() => { }).catch(() => {
setConnectionTestResult(false); setServerConnectionSuccess(false);
}).finally(() => { }).finally(() => {
setServerRefreshing(false); setServerRefreshing(false);
}); });
setTimeout(() => abortController.abort(), 5000); const timeout = setTimeout(() => abortController.abort(), 5000);
return () => abortController.abort(); return () => {
clearTimeout(timeout);
abortController.abort();
}
}, [serverId, serverReloadCounter, realm]); }, [serverId, serverReloadCounter, realm]);
// card in callback const cardInCallback = useCallback((card: AMCard) => {
const cardInCallback = useCallback(() => {
setCardInCompleted(false);
const abortController = new AbortController(); const abortController = new AbortController();
performCardIn(server.serverAddress, {cardId: selectedCard.code}, abortController.signal).then(async r => { const request: AMCardInRequest = {
const now = new Date(); cardId: card.code,
physicalCardIDm: card.physicalCardData?.cardIDm ?? undefined
};
setCardInCompleted(r); performCardIn(server, request, abortController.signal).then(r => {
const now = new Date();
realm.write(() => { realm.write(() => {
server.lastUsed = now; server.lastUsed = now;
selectedCard.lastUsed = now; card.lastUsed = now;
if (server.gameId) {
// replace the "most recent card" record for the current game
realm.delete(realm.objects(AMCardRecentUse.schema.name).filtered("gameId = $0", server.gameId));
realm.create(AMCardRecentUse.schema.name, AMCardRecentUse.generate(server.gameId, card));
}
}); });
setSelectedCard(selectedCard); setCardInCompleted(r);
}).catch(() => { }).catch(() => {
setCardInCompleted(false); setCardInCompleted(false);
}).finally(() => { }).finally(() => {
@@ -130,108 +209,216 @@ export default function ServerCardIn({route, navigation}) {
setTimeout(() => abortController.abort(), 5000); setTimeout(() => abortController.abort(), 5000);
return () => abortController.abort(); return () => abortController.abort();
}, [server, selectedCard]); }, [server]);
const disableCardIn = useMemo(() => cardInCompleted !== null || !connectionTestResult, [cardInCompleted, connectionTestResult]); const performPhysicalCardIn = useCallback(async () => {
const card = await felicaCardReadAsync();
if (connectionTestResult === null) { if (!card) {
return;
}
const savedCard = cards.find(x => x.physicalCardData?.cardIDm === card.idm);
if (savedCard) {
setUserSelectedCard(savedCard);
cardInCallback(savedCard);
return;
}
setCardInCompleted(false);
const cardId = card.type !== ICType.Amusement ? icCard0008Number(card.idm) : card.accessCode;
const abortController = new AbortController();
performCardIn(server, {cardId, physicalCardIDm: card.idm}, abortController.signal).then(r => {
setCardInCompleted(r);
}).catch(() => {
setCardInCompleted(false);
}).finally(() => {
setTimeout(() => setCardInCompleted(null), 3000);
});
setTimeout(() => abortController.abort(), 5000);
return () => abortController.abort();
}, [server]);
const performRefresh = () => {
setServerRefreshing(true);
setServerReloadCounter(serverReloadCounter + 1);
};
if (server?.isValid() !== true) {
return null;
}
if (serverConnectionSuccess === null) {
return ( return (
<View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}> <View style={{flex: 1, justifyContent: "center", alignItems: "center", gap: 10}}>
<ActivityIndicator animating/> <ActivityIndicator animating/>
<Paragraph>Connecting to server...</Paragraph> <Text variant="bodyMedium">Connecting to server...</Text>
</View> </View>
) )
} }
return ( return (
<ScrollView contentInsetAdjustmentBehavior="automatic" <ScrollView contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{...styles.container, paddingTop: connectionTestResult ? 0 : 10}} refreshControl={<RefreshControl refreshing={serverRefreshing} onRefresh={performRefresh}/>}>
refreshControl={<RefreshControl refreshing={serverRefreshing} <View style={styles.container}>
tintColor={theme.colors.onSurface} <StatusAlert server={server} connectionEstablished={serverConnectionSuccess}/>
onRefresh={() => {
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>
)}
<View style={styles.section}>
<Title style={{fontWeight: "bold"}}>Selected Card</Title>
{selectedCard ? <>
<AimeCard actionDisabled
card={selectedCard}
actionIcon={<CircleCheck color="green" size={24}/>}/>
<Button textColor="white"
onPress={cardInCallback}
disabled={disableCardIn}
contentStyle={{flexDirection: 'row-reverse'}}
mode={connectionTestResult ? "contained" : "outlined"}
buttonColor={cardInCompleted === false ? "red" : "green"}
icon={() => <Nfc color={disableCardIn ? "gray" : "white"} size={18}/>}>
{cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
</Button>
</>
: <View style={styles.emptyPlaceholderView}>
<EmptyStatePlaceholder message="No card selected" icon={<CreditCard color="gray"/>}/>
<Button mode="text"
icon={() => <Plus color={theme.colors.primary} size={18}/>}
onPress={() => navigation.navigate('add-card')}>
Add card
</Button>
</View>}
</View>
{cards.length > 0 && (
<View style={styles.section}> <View style={styles.section}>
<Title style={{fontWeight: "bold"}}>Additional Cards</Title> <Text variant="titleLarge" style={styles.sectionTitle}>{server.gameId ? `Play ${getGameName(server.gameId)}` : "Selected Card"}</Text>
<Button mode="contained" {selectedCard ? <>
textColor="white" <ICCard actionDisabled
buttonColor={theme.colors.tertiary} card={selectedCard}
icon={() => <Plus color="white" size={18}/>} actionIcon={<CircleCheck color="green" size={24}/>}>
contentStyle={{flexDirection: 'row-reverse'}} {serverDefaultCard?.code == selectedCard.code && <DefaultCardDetail/>}
onPress={() => navigation.navigate('add-card')}> </ICCard>
Add card <View style={{display: "flex", flexDirection: "row", gap: 5}}>
</Button> <Button style={{flex: 1}}
textColor="white"
{unselectedCards.length disabled={disableCardIn}
? unselectedCards.map(card => ( contentStyle={{flexDirection: 'row-reverse'}}
<AimeCard card={card} mode={serverConnectionSuccess ? "contained" : "outlined"}
key={card._id.toHexString()} buttonColor={cardInCompleted === false ? "red" : "green"}
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>} onPress={() => cardInCallback(selectedCard)}
onPress={() => setSelectedCard(card)}/>)) icon={() => <SquareArrowOutUpRight color={disableCardIn ? "gray" : "white"}
size={18}/>}>
{cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
</Button>
{nfcSupported === true && (
<IconButton
mode="outlined"
disabled={disableCardIn}
onPress={performPhysicalCardIn}
style={{borderRadius: 10, margin: 0, height: "100%", flex: 0.2}}
icon={() => <SmartphoneNfc
color={disableCardIn ? "gray" : theme.colors.onBackground} size={18}/>}/>
)}
</View>
</>
: <View style={styles.emptyPlaceholderView}> : <View style={styles.emptyPlaceholderView}>
<EmptyStatePlaceholder message="No additional cards" <EmptyStatePlaceholder message="No card selected" icon={<CreditCard color="gray"/>}/>
icon={<SmartphoneNfcIcon color="gray"/>}/> <Button mode="text"
</View>} icon={() => <Plus color={theme.colors.primary} size={18}/>}
</View>)} onPress={() => navigation.navigate('add-card')}>
Add Card
</Button>
</View>
}
</View>
{cards.length > 0 && (
<View style={styles.section}>
<Text variant="titleLarge" style={styles.sectionTitle}>Additional Cards</Text>
<Button mode="contained"
textColor="white"
buttonColor={theme.colors.tertiary}
contentStyle={{flexDirection: 'row-reverse'}}
icon={() => <Plus color="white" size={18}/>}
onPress={() => navigation.navigate('add-card')}>
Add Card
</Button>
{unselectedCards.length
? unselectedCards.map((card: AMCard) => (
<ICCard card={card}
key={card._id.toHexString()}
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
onPress={() => setUserSelectedCard(card)}>
{serverDefaultCard?.code == card.code && <DefaultCardDetail/>}
</ICCard>))
: <View style={styles.emptyPlaceholderView}>
<EmptyStatePlaceholder message="No additional cards"
icon={<SmartphoneNfcIcon color="gray"/>}/>
</View>}
</View>)}
</View>
</ScrollView> </ScrollView>
) )
} }
function StatusAlert(props: {
server: AMServerConnectionInfo & Partial<AMServerInstanceMetrics>,
connectionEstablished: boolean
}) {
if (!props.connectionEstablished) {
return (
<AMUIStatusAlert color="red" icon={WifiOff} title="Connection Failed">
<Text variant="bodyMedium">
There was an issue connecting to <Text variant="bodyMedium"
style={{fontFamily: "JetBrainsMono_400Regular"}}>{props.server.serverAddress}</Text>.
Check the network connection and try again.
</Text>
</AMUIStatusAlert>
)
}
// don't show if undefined (some older server versions)
if (props.server.timeSinceLastPoll === null) {
return (
<AMUIStatusAlert color="#822eff" icon={TvMinimalPlay} title="Server Startup">
<Text variant="bodyMedium">
Scanning a card below may not have any effect until the service has fully started up.
</Text>
</AMUIStatusAlert>
)
}
return null;
}
function DefaultCardDetail() {
return (
<>
{/* spacer */}
<View></View>
<AMUICardDetail>
<Star size={18} color="goldenrod"/>
<Text style={{color: "goldenrod"}}>Previously used with this game</Text>
</AMUICardDetail>
</>
)
}
const menuActions: MenuAction[] = [
{
id: "share",
title: "Share",
image: Platform.select({
ios: "square.and.arrow.up",
android: "share"
})
},
{
id: "delete",
title: "Delete Server",
attributes: {
destructive: true,
},
image: Platform.select({
ios: "trash",
android: "delete"
})
}
];
const styles = StyleSheet.create({ const styles = StyleSheet.create({
section: { section: {
gap: 10 gap: 10
}, },
sectionTitle: {
fontWeight: "bold"
},
emptyPlaceholderView: { emptyPlaceholderView: {
marginVertical: 30, marginVertical: 30,
gap: 15 gap: 15
}, },
container: { container: {
paddingTop: Platform.select({android: 120, default: 0}),
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 25, paddingBottom: 80,
gap: 20 gap: 20
} }
}) });
+39 -43
View File
@@ -1,59 +1,47 @@
import {Button, Paragraph, useTheme} from "react-native-paper"; import {getGameName} from "amnet-shared";
import {Globe, History, Plus, ServerOff} from "lucide-react-native"; import {FlatList, Platform, View} from "react-native";
import {FlatList, NativeSyntheticEvent, Text, View} from "react-native"; import {Button, Text, useTheme} from "react-native-paper";
import React, {useCallback, useLayoutEffect, useMemo, useState} from "react"; import React, {useCallback, useLayoutEffect, useMemo, useState} from "react";
import ContextMenu, {ContextMenuOnPressNativeEvent} from "react-native-context-menu-view"; import {Globe, History, Joystick, Plus, ServerOff} from "lucide-react-native";
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
import {AMUICard, AMUICardDetail} from "../components/AMUICard";
import TimeAgo from "../components/TimeAgo"; import TimeAgo from "../components/TimeAgo";
import {AMUICard, AMUICardDetail} from "../components/AMUICard";
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
import RealmContext from "../models/RealmContext";
import {AMServer} from "../models/AMServer"; import {AMServer} from "../models/AMServer";
import RealmContext from "../models/RealmContext";
const {useRealm, useQuery} = RealmContext; const {useQuery} = RealmContext;
function ServerCard(props: { server: AMServer, navigation: any }) { function ServerCard(props: { server: AMServer, navigation: any }) {
const realm = useRealm(); const gameName = useMemo(() => getGameName(props.server.gameId ?? ''), [props.server.gameId]);
const pageCallback = useCallback(() => { const pageCallback = useCallback(() => {
props.navigation.navigate('server', { props.navigation.navigate('server', {
serverId: props.server._id.toHexString() serverId: props.server._id.toHexString()
}); });
}, [props.navigation, props.server]); }, [props.navigation, props.server]);
const pageContextMenuCallback = useCallback((action: NativeSyntheticEvent<ContextMenuOnPressNativeEvent>) => {
switch (action.nativeEvent.index) {
case 0:
pageCallback();
break;
case 1:
realm.write(() => {
realm.delete(props.server);
});
break;
}
}, [realm, props.server]);
return ( return (
<ContextMenu onPress={pageContextMenuCallback} actions={[ <AMUICard title={props.server.serverName} onPress={pageCallback}>
{title: `Select ${props.server.serverName}`, systemIcon: "chevron.right"}, <AMUICardDetail>
{title: "Delete Server", systemIcon: "trash", destructive: true} <Globe size={18} color="gray"/>
]}> <Text variant="bodyMedium" style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>
<AMUICard title={props.server.serverName} onPress={pageCallback}> {props.server.serverAddress}
</Text>
</AMUICardDetail>
{gameName && (
<AMUICardDetail> <AMUICardDetail>
<Globe size={18} color="gray"/> <Joystick size={18} color="gray"/>
<Text style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}> <Text variant="bodyMedium" style={{color: "gray"}}>{gameName}</Text>
{props.server.serverAddress}
</Text>
</AMUICardDetail> </AMUICardDetail>
<AMUICardDetail> )}
<History size={18} color="gray"/> <AMUICardDetail>
{props.server.lastUsed <History size={18} color="gray"/>
? (<TimeAgo style={{color: "gray"}} dateTo={props.server.lastUsed}/>) {props.server.lastUsed
: (<Paragraph style={{color: "gray"}}>never</Paragraph>)} ? (<TimeAgo style={{color: "gray"}} dateTo={props.server.lastUsed}/>)
</AMUICardDetail> : (<Text variant="bodyMedium" style={{color: "gray"}}>never</Text>)}
</AMUICard> </AMUICardDetail>
</ContextMenu> </AMUICard>
) )
} }
@@ -69,7 +57,7 @@ export default function ServerManager({navigation}) {
q = q.filtered("serverName CONTAINS[c] $0 OR serverAddress CONTAINS[c] $0", searchQuery); q = q.filtered("serverName CONTAINS[c] $0 OR serverAddress CONTAINS[c] $0", searchQuery);
} }
return q.sorted("lastUsed"); return q.sorted("lastUsed", true);
}, [query, searchQuery]); }, [query, searchQuery]);
// enable searchbar // enable searchbar
@@ -80,6 +68,8 @@ export default function ServerManager({navigation}) {
value: searchQuery, value: searchQuery,
autoCorrect: "none", autoCorrect: "none",
autoCapitalize: "none", autoCapitalize: "none",
textColor: theme.colors.onSurface,
headerIconColor: theme.colors.primary,
onChangeText: e => setSearchQuery(e.nativeEvent.text) onChangeText: e => setSearchQuery(e.nativeEvent.text)
}, },
}) })
@@ -102,8 +92,14 @@ export default function ServerManager({navigation}) {
data={servers} data={servers}
keyExtractor={i => i._id.toString()} keyExtractor={i => i._id.toString()}
style={{padding: 16, paddingTop: 24}} style={{padding: 16, paddingTop: 24}}
contentContainerStyle={{paddingBottom: 40}} contentContainerStyle={{paddingBottom: 80}}
renderItem={i => <ServerCard navigation={navigation} server={i.item}/>}
ItemSeparatorComponent={() => <View style={{height: 15}}/>} ItemSeparatorComponent={() => <View style={{height: 15}}/>}
renderItem={i => <ServerCard navigation={navigation} server={i.item}/>}/> ListFooterComponent={() => Platform.OS === "ios" ? null : (
<Button mode="text"
onPress={() => navigation.navigate('add-server')}
style={{marginTop: 15, marginBottom: 25}}
icon={() => <Plus size={18} color={theme.colors.primary}/>}>Add Server</Button>
)}/>
) )
} }
+8 -5
View File
@@ -1,6 +1,6 @@
import {Plus} from "lucide-react-native"; import {Plus} from "lucide-react-native";
import {TouchableOpacity} from "react-native";
import {useTheme} from "@react-navigation/native"; import {useTheme} from "@react-navigation/native";
import {Platform, TouchableOpacity} from "react-native";
import {createNativeStackNavigator} from "@react-navigation/native-stack"; import {createNativeStackNavigator} from "@react-navigation/native-stack";
import ServerCardIn from "./ServerCardIn"; import ServerCardIn from "./ServerCardIn";
@@ -18,16 +18,19 @@ export default function ServerManagerStack({navigation}) {
headerBlurEffect: "regular", headerBlurEffect: "regular",
headerStyle: { headerStyle: {
backgroundColor: theme.colors.background backgroundColor: theme.colors.background
},
headerTitleStyle: {
fontWeight: "bold"
},
headerLargeTitleStyle: {
fontWeight: "bold"
} }
}}> }}>
<Stack.Screen name="server-listing" <Stack.Screen name="server-listing"
component={ServerManager} component={ServerManager}
options={{ options={{
title: "Servers", title: "Servers",
headerRight: () => <TouchableOpacity headerRight: () => Platform.OS === 'android' ? null : <TouchableOpacity onPress={() => navigation.navigate('add-server')}><Plus color={theme.colors.primary}/></TouchableOpacity>
onPress={() => navigation.navigate('add-server')}>
<Plus/>
</TouchableOpacity>
}}/> }}/>
<Stack.Screen name="server" options={{title: "Server", headerTransparent: true}} component={ServerCardIn}/> <Stack.Screen name="server" options={{title: "Server", headerTransparent: true}} component={ServerCardIn}/>
+113
View File
@@ -0,0 +1,113 @@
import {StatusBar} from "expo-status-bar";
import NfcManager from "react-native-nfc-manager";
import React, {useEffect, useState} from "react";
import {Platform, TouchableOpacity, View} from "react-native";
import {createNativeStackNavigator} from "@react-navigation/native-stack";
import {Card, Text, useTheme} from "react-native-paper";
import {ShieldBan, SmartphoneNfc, SquareAsterisk, SquarePlus} from "lucide-react-native";
import ManualCardEntry from "./ManualCardEntry";
import PhysicalCardScan from "./PhyscialCardScan";
const Stack = createNativeStackNavigator();
function CardAddModal() {
return (
<Stack.Navigator initialRouteName="Select" screenOptions={{headerTitleStyle: {fontWeight: "bold"}}}>
<Stack.Screen name="Select" component={CardAddMethodSelector} options={{headerShown: false}}/>
<Stack.Screen name="Scan" component={PhysicalCardScan} options={{title: "Add Physical Card"}}/>
<Stack.Screen name="Manual" component={ManualCardEntry} options={{title: "Add Card Manually"}}/>
</Stack.Navigator>
)
}
function CardAddMethodSelector({navigation}) {
const theme = useTheme();
const [nfcSupported, setNfcSupported] = useState<boolean | null>(null);
useEffect(() => {
NfcManager.isSupported().then(setNfcSupported);
}, []);
return (
<>
{Platform.OS === 'ios' && <StatusBar style="light"/>}
<View style={{paddingHorizontal: 15, paddingTop: 90, gap: 8}}>
<View style={{paddingBottom: 25, gap: 5}}>
<Text variant="titleLarge" style={{textAlign: "center", fontSize: 32, lineHeight: 32, fontWeight: "bold"}}>
Add new Card
</Text>
<Text variant="bodyMedium" style={{textAlign: "center"}}>
AMNet supports scanning physical cards, entering details manually or generating new ones.
</Text>
</View>
{nfcSupported !== null && (
<>
<MethodCard title="Scan Physical Card"
disabled={!nfcSupported}
color={nfcSupported ? "#03a9f4" : "gray"}
icon={nfcSupported ? <SmartphoneNfc/> : <ShieldBan/>}
description={nfcSupported ? "Amusement IC or Transport cards supported" : "NFC is not supported on this device"}
onPress={() => navigation.navigate('Scan')}/>
<View></View>
</>
)}
<MethodCard color={theme.colors.secondary}
icon={<SquareAsterisk/>}
title="Enter Details Manually"
onPress={() => navigation.navigate('Manual')}/>
<MethodCard color="#4caf50"
icon={<SquarePlus/>}
title="Generate New Card"
onPress={() => navigation.navigate('Manual', {autoGenerate: true})}/>
</View>
</>)
}
function MethodCard(props: {
title: string,
description?: string,
color: string,
disabled?: boolean,
icon: React.ReactElement,
onPress: () => void
}) {
const theme = useTheme();
const large = props.description?.length > 0;
return (
<TouchableOpacity onPress={() => !props.disabled && props.onPress()} disabled={props.disabled}>
<Card mode={theme.dark ? "elevated" : "contained"} disabled={props.disabled}>
<Card.Content style={{display: "flex", flexDirection: "row", alignItems: "center", gap: 15}}>
<View style={{
borderRadius: large ? 10 : 5,
height: large ? 50 : 30,
width: large ? 50 : 30,
backgroundColor: props.color,
justifyContent: "center",
alignItems: "center"
}}>
{React.cloneElement(props.icon, {size: large ? 24 : 16, color: theme.colors.surfaceVariant})}
</View>
<View style={{flexShrink: 1}}>
<Text style={{fontSize: large ? 16 : 14}}>{props.title}</Text>
{props.description && (<Text variant="bodyMedium" style={{fontSize: 12}}>{props.description}</Text>)}
</View>
</Card.Content>
</Card>
</TouchableOpacity>
)
}
// the nfc library doesn't offer the same support for felica cards on android compared to ios...
export default Platform.select({
ios: CardAddModal,
default: ManualCardEntry
});
+99
View File
@@ -0,0 +1,99 @@
import {BSON} from "realm";
import {StatusBar} from "expo-status-bar";
import {Card, Divider, Text, useTheme} from "react-native-paper";
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
import {Platform, TextInput, TouchableOpacity, View} from "react-native";
import {useModalStyles} from "../../utils/ModalStyles";
import {AMCard} from "../../models/AMCard";
import RealmContext from "../../models/RealmContext";
const {useObject, useRealm} = RealmContext;
export default function CardEditorModal({navigation, route}) {
if (!route.params || !route.params.cardId) {
navigation.goBack();
return;
}
const theme = useTheme();
const modalStyles = useModalStyles();
const realm = useRealm();
const card = useObject(AMCard, BSON.ObjectId.createFromHexString(route.params.cardId));
const [cardName, setCardName] = useState<string>('');
const updateCardCallback = useCallback((newName: string) => {
realm.write(() => {
card.name = newName;
});
navigation.goBack();
}, [realm, navigation]);
useEffect(() => {
if (card?.isValid() !== true) {
navigation.goBack();
return;
}
if (!cardName.length) {
setCardName(card.name);
}
}, [card]);
// header effects
if (Platform.OS === 'ios') {
useLayoutEffect(() => {
navigation.setOptions({
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
<Text variant="bodyMedium" style={modalStyles.headerText}>Cancel</Text>
</TouchableOpacity>,
});
}, [navigation, modalStyles]);
}
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => <TouchableOpacity onPress={() => updateCardCallback(cardName)} disabled={!cardName.length}>
<Text variant="bodyMedium" style={modalStyles.headerText}>Save</Text>
</TouchableOpacity>
});
}, [cardName]);
return (<>
{Platform.OS === 'ios' && <StatusBar style="light"/>}
<View style={modalStyles.rootLayout}>
<Card mode="contained">
<Card.Content>
<View style={modalStyles.formItemRow}>
<Text variant="bodyMedium" style={modalStyles.formItemName}>
Card Name
</Text>
<TextInput style={modalStyles.formItemInput}
autoFocus
numberOfLines={1}
value={cardName}
onChangeText={setCardName}
placeholder="Primary Card"
placeholderTextColor={theme.colors.outline}/>
</View>
<Divider style={{marginVertical: 15}}/>
<View style={modalStyles.formItemRow}>
<Text variant="bodyMedium" style={modalStyles.formItemName}>
Access Code
</Text>
<TextInput readOnly
numberOfLines={1}
value={card?.code.match(/.{1,4}/g)?.join(" ")}
style={{...modalStyles.formItemInput, color: theme.colors.onSurfaceDisabled}}/>
</View>
</Card.Content>
</Card>
</View>
</>)
}
+129
View File
@@ -0,0 +1,129 @@
import {StatusBar} from "expo-status-bar";
import {generateCardNumber, DecodeAMAccessCode} from "amnet-shared";
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
import {Button, Card, Divider, Text, useTheme} from "react-native-paper";
import {Alert, Platform, TextInput, TouchableOpacity, View} from "react-native";
import {AMCard} from "../../models/AMCard";
import RealmContext from "../../models/RealmContext";
import {useModalStyles} from "../../utils/ModalStyles";
const {useRealm} = RealmContext;
export default function CardAddModal({navigation, route}) {
const realm = useRealm();
const theme = useTheme();
const modalStyles = useModalStyles();
// state values
const [cardName, setCardName] = useState('');
const [cardNumber, setCardNumber] = useState('');
const cardAddCallback = useCallback(async (cardId: string, cardName: string) => {
try {
// validate AM card numbers
cardId.startsWith('5') && DecodeAMAccessCode(cardId);
} catch {
const shouldContinue = await new Promise<boolean>(done => {
Alert.alert(
"Invalid Access Code",
"It looks like the access code isn't from an Amusement IC card. Please double-check the code, and if it's correct, note that some networks might still reject it.",
[
{
style: "destructive",
text: "Continue Anyway",
onPress: () => done(true)
},
{
style: "cancel",
text: "Cancel",
onPress: () => done(false)
}
], {
cancelable: true,
onDismiss: () => done(false)
});
})
if (!shouldContinue) {
return;
}
}
const duplicateCard = realm.objects<AMCard>(AMCard.schema.name).find(x => x.code === cardId);
if (duplicateCard) {
realm.write(() => {
duplicateCard.name = cardName;
});
} else {
realm.write(() => {
realm.create<AMCard>(AMCard.schema.name, AMCard.generate(cardId, cardName));
});
}
if (Platform.OS === 'ios') {
navigation.getParent()?.goBack();
} else {
navigation.goBack();
}
}, [realm]);
useLayoutEffect(() => {
const canSave = cardName.length && cardNumber.length === 20;
const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline};
navigation.setOptions({
headerRight: () => <TouchableOpacity disabled={!canSave}
onPress={() => cardAddCallback(cardNumber, cardName)}>
<Text variant="bodyMedium" style={{...themeOptions}}>Save</Text>
</TouchableOpacity>
})
}, [navigation, modalStyles, cardName, cardNumber]);
// check params for autoGenerate request
useEffect(() => {
if (route.params?.autoGenerate) {
setCardNumber(generateCardNumber());
}
}, []);
return (<>
{Platform.OS == 'ios' && <StatusBar style="light"/>}
<View style={modalStyles.rootLayout}>
<Card mode="contained">
<Card.Content>
<View style={modalStyles.formItemRow}>
<Text variant="bodyMedium" style={modalStyles.formItemName}>
Card Name
</Text>
<TextInput style={modalStyles.formItemInput}
autoFocus
numberOfLines={1}
value={cardName}
onChangeText={setCardName}
placeholder="Primary Card"
placeholderTextColor={theme.colors.outline}/>
</View>
<Divider style={{marginVertical: 15}}/>
<View style={modalStyles.formItemRow}>
<Text variant="bodyMedium" style={modalStyles.formItemName}>
Access Code
</Text>
<TextInput numberOfLines={1}
keyboardType="numeric"
placeholder="0000 0000 0000 0000 0000"
placeholderTextColor={theme.colors.outline}
value={cardNumber.match(/.{1,4}/g)?.join(" ")}
onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}
style={{...modalStyles.formItemInput, color: theme.colors.onSurface}}/>
</View>
</Card.Content>
</Card>
<Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Access Code</Button>
</View>
</>)
}
@@ -0,0 +1,87 @@
import {StatusBar} from "expo-status-bar";
import {useNavigation} from "@react-navigation/native";
import {Card, Text, useTheme} from "react-native-paper";
import {useCallback, useLayoutEffect, useState} from "react";
import {Platform, TextInput, TouchableOpacity, View} from "react-native";
import RealmContext from "../../models/RealmContext";
import {useModalStyles} from "../../utils/ModalStyles";
import {AMCard} from "../../models/AMCard";
import {AMPhysicalCardData} from "../../models/AMPhysicalCardData";
import {felicaCardReadAsync, icCard0008Number, ICType} from "../../lib/iccard";
const {useRealm} = RealmContext;
export default function PhysicalCardScan() {
const realm = useRealm();
const theme = useTheme();
const navigation = useNavigation();
const modalStyles = useModalStyles();
const [cardName, setCardName] = useState<string>('');
const handleScan = useCallback(async () => {
const cardInfo = await felicaCardReadAsync();
if (!cardInfo) {
return;
}
realm.write(() => {
// check if the card has already been registered (convert a manually entered card to a physically scanned card)
const accessCode = cardInfo.type === ICType.Amusement ? cardInfo.accessCode : icCard0008Number(cardInfo.idm);
const cardEntity = realm.objects<AMCard>(AMCard.schema.name).find(x => x.code === accessCode) ?? realm.create<AMCard>(AMCard.schema.name, AMCard.generate(accessCode, cardName));
// set physical card props
cardEntity.name = cardName;
cardEntity.added = new Date();
cardEntity.physicalCardData ??= realm.objectForPrimaryKey<AMPhysicalCardData>(AMPhysicalCardData.schema.name, cardInfo.idm) ?? realm.create<AMPhysicalCardData>(AMPhysicalCardData.schema.name, {
cardIDm: cardInfo.idm,
cardType: cardInfo.type,
cardVendor: cardInfo.type === ICType.Amusement ? cardInfo.vendor : null
});
});
// dismiss the parent screen (this screen can only be accessed from modal -> stack)
navigation.getParent()?.goBack();
}, [realm, navigation, cardName]);
useLayoutEffect(() => {
const canSave = cardName.length;
const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline};
navigation.setOptions({
headerRight: () => <TouchableOpacity disabled={!canSave} onPress={() => handleScan()}>
<Text variant="bodyMedium" style={{...themeOptions}}>Scan</Text>
</TouchableOpacity>
})
}, [navigation, modalStyles, cardName]);
return (<>
{Platform.OS == 'ios' && <StatusBar style="light"/>}
<View style={modalStyles.rootLayout}>
<Card mode="contained">
<Card.Content>
<View style={modalStyles.formItemRow}>
<Text variant="bodyMedium" style={modalStyles.formItemName}>
Card Name
</Text>
<TextInput style={modalStyles.formItemInput}
autoFocus
numberOfLines={1}
value={cardName}
onChangeText={setCardName}
placeholder="Primary Card"
placeholderTextColor={theme.colors.outline}/>
</View>
</Card.Content>
</Card>
<Text variant="bodyMedium" style={modalStyles.formFootnote}>
Ensure you have either an Amusement IC or Transport card ready to scan.
After the scan has completed, the card will be saved to your device.
</Text>
</View>
</>)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+3 -3
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import {TouchableOpacity, View} from "react-native"; import {TouchableOpacity, View} from "react-native";
import {Card, Title, useTheme} from "react-native-paper"; import {Card, Text, useTheme} from "react-native-paper";
export interface AMUICardActionProps { export interface AMUICardActionProps {
actionIcon?: Readonly<React.ReactNode>, actionIcon?: Readonly<React.ReactNode>,
@@ -21,7 +21,7 @@ export function AMUICard(props: AMUICardActionProps & AMUICardProps) {
} }
return ( return (
<TouchableOpacity disabled={props.actionDisabled} onPress={props.onPress} onLongPress={props.onLongPress}> <TouchableOpacity disabled={props.actionDisabled} onPress={props.onPress} onLongPress={props.onLongPress} delayLongPress={150}>
<CardComponent {...props}/> <CardComponent {...props}/>
</TouchableOpacity> </TouchableOpacity>
); );
@@ -48,7 +48,7 @@ function CardComponent(props: AMUICardProps & AMUICardActionProps) {
gap: 5 gap: 5
}}> }}>
<View style={{flexGrow: 1}}> <View style={{flexGrow: 1}}>
<Title style={{fontWeight: "bold"}}>{props.title}</Title> <Text variant="titleLarge" style={{fontWeight: "bold"}}>{props.title}</Text>
<View style={{display: "flex", gap: 8, flexWrap: "wrap", marginTop: 10}}> <View style={{display: "flex", gap: 8, flexWrap: "wrap", marginTop: 10}}>
{props.children} {props.children}
</View> </View>
@@ -0,0 +1,22 @@
import {View} from "react-native";
import React, {ReactNode} from "react";
import {Card, Text} 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})}
<Text variant="titleLarge" style={{color: props.color}}>{props.title}</Text>
</View>
{props.children}
</Card.Content>
</Card>
);
}
function renderIconWithProps(icon: LucideIcon, additionalProps: Partial<LucideProps>) {
return React.cloneElement(React.createElement(icon), additionalProps);
}
-34
View File
@@ -1,34 +0,0 @@
import {useMemo} from "react";
import {Text} from "react-native";
import {CreditCard, History} from "lucide-react-native";
import TimeAgo from "./TimeAgo";
import {AMCardData} from "../models/AMCard";
import {AMUICard, AMUICardActionProps, AMUICardDetail} from "./AMUICard";
export interface AimeCardProps extends AMUICardActionProps {
card: AMCardData,
showFullCardNumber?: boolean
}
export function AimeCard(props: AimeCardProps) {
const displayedCardNumber = useMemo(() => props.showFullCardNumber
? props.card.code.match(/.{1,4}/g)?.join(" ")
: `•••• ${props.card.code.substring(props.card.code.length - 4)}`,
[props.showFullCardNumber, props.card.code]);
return (
<AMUICard title={props.card.name} {...props}>
<AMUICardDetail>
<CreditCard size={18} color="gray"/>
<Text style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>{displayedCardNumber}</Text>
</AMUICardDetail>
{props.card.lastUsed && (
<AMUICardDetail>
<History size={18} color="gray"/>
<TimeAgo style={{color: "gray"}} dateTo={props.card.lastUsed}/>
</AMUICardDetail>
)}
</AMUICard>
);
}
@@ -1,13 +1,13 @@
import {View} from "react-native"; import {View} from "react-native";
import {Paragraph} from "react-native-paper"; import {Text} from "react-native-paper";
export function EmptyStatePlaceholder(props: { message: string, icon: React.ReactNode }) { export function EmptyStatePlaceholder(props: { message: string, icon: React.ReactNode }) {
return ( return (
<View style={{display: "flex", gap: 5, alignItems: "center"}}> <View style={{display: "flex", gap: 5, alignItems: "center"}}>
{props.icon} {props.icon}
<Paragraph style={{color: "gray", fontSize: 14}}> <Text variant="bodyMedium" style={{color: "gray", fontSize: 14}}>
{props.message} {props.message}
</Paragraph> </Text>
</View> </View>
) )
} }
+86
View File
@@ -0,0 +1,86 @@
import {Text} from "react-native";
import React, {ReactNode, useMemo} from "react";
import {CreditCard, Gem, History, Joystick, LucideIcon, RailSymbol} from "lucide-react-native";
import TimeAgo from "./TimeAgo";
import {AMUICard, AMUICardActionProps, AMUICardDetail} from "./AMUICard";
import {AmusementICVendor, ICType} from "../lib/iccard";
import {AMCardData} from "../models/AMCardData";
export interface ICCardProps extends AMUICardActionProps {
card: AMCardData,
children?: ReactNode,
showFullCardNumber?: boolean
}
export function ICCard(props: ICCardProps) {
const displayedCardNumber = useMemo(() => props.showFullCardNumber
? props.card.code.match(/.{1,4}/g)?.join(" ")
: `•••• ${props.card.code.substring(props.card.code.length - 4)}`,
[props.showFullCardNumber, props.card.code]);
return (
<AMUICard title={props.card.name} {...props}>
<PhysicalICInfo card={props.card}/>
<AMUICardDetail>
<CreditCard size={18} color="gray"/>
<Text style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>{displayedCardNumber}</Text>
</AMUICardDetail>
{props.card.lastUsed && (
<AMUICardDetail>
<History size={18} color="gray"/>
<TimeAgo style={{color: "gray"}} dateTo={props.card.lastUsed}/>
</AMUICardDetail>
)}
{props.children}
</AMUICard>
);
}
function PhysicalICInfo(props: { card: AMCardData | null }) {
if (props.card.physicalCardData?.cardType === ICType.Transport) {
return (
<AMUICardDetail>
<RailSymbol size={18} color="gray"/>
<Text style={{color: "gray"}}>Transport IC Card</Text>
</AMUICardDetail>
)
}
if (props.card.physicalCardData?.cardVendor) {
const [icon, color]: [LucideIcon, string] = props.card.isLimitedAimeCard ? [Gem, "crimson"] : [Joystick, "gray"];
return (
<AMUICardDetail>
{React.createElement(icon, {size: 18, color})}
<Text style={{color}}>
{props.card.physicalCardData.isBootlegAmusementCard ? "Bootleg " : null}{ICVendorName(props.card.physicalCardData.cardVendor)}
</Text>
</AMUICardDetail>
);
}
return null;
}
function ICVendorName(vendor: AmusementICVendor): string {
switch (vendor) {
case AmusementICVendor.Sega:
return "Aime Card";
case AmusementICVendor.Konami:
return "e-Amusement Pass";
case AmusementICVendor.BandaiNamcoGames:
case AmusementICVendor.BandaiNamcoEntertainment:
return "Banapass";
case AmusementICVendor.Nesica:
return "NESiCA Card";
default:
return "Amusement IC Card";
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
import {Text} from "react-native-paper";
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import {StyleProp, TextStyle} from 'react-native'; import {StyleProp, TextStyle} from 'react-native';
import {formatDistance} from 'date-fns/formatDistance'; import {formatDistance} from 'date-fns/formatDistance';
import {Paragraph} from "react-native-paper";
interface Props { interface Props {
dateTo: Date; dateTo: Date;
@@ -28,11 +28,11 @@ const TimeAgo: React.FC<Props> = ({
}, [dateFrom, updateInterval]); }, [dateFrom, updateInterval]);
return ( return (
<Paragraph {...{style}}> <Text variant="bodyMedium" {...{style}}>
{formatDistance(dateTo, dateFrom || currentDate, { {formatDistance(dateTo, dateFrom || currentDate, {
addSuffix: !hideAgo, addSuffix: !hideAgo,
})} })}
</Paragraph> </Text>
); );
}; };
+49
View File
@@ -0,0 +1,49 @@
import {Buffer} from "buffer";
const s_box_inv = [
"243cba36e385a4d0934373b9706ec9f1100e9b2c97e70b636c2920fe86f3e1f5f69fb616047b8fabb1392a1beb5ca8ac3811125f893e7dcaec53db6d1ed281789646fff9541c287a4fd3c0dcc16af2bccb57fd4ae4f0b2c79540625241e2ad49a6b51f02c8da92e5b0c564764821de0f45584cdfa784cfd5154e27806b7ffc44714722dd300ca13be037a03523903274bf8dc2ead650bbd9c483b43168558b5df47218b7eff7982d010361cc0a8e1300e814af095175a52f1d0d658acd66073d05d84be99d997c91d1b819c32b426988c679173a4d5ba25afb253fbdf8edcee687a326a92ebe94087e67608c9c5e6fb39e06fa82aeee5977d71a9a34d456aa33",
"f9817c00b93037d590516ef0b206fbcd39145ff81bc74a82708c921deac34ec812e6b610d32f95842542a572e58f55ef86a253aeed262047de786828e445cc35bc3ebb8ec0be34afa60964017b44f5f3b13fa4b33280c94f6f40bf214c74e1115eeb3d712e9d6275d41648771367ad6b5d0787f7a89a59768b9b1ed8eeaae9992dc597f1a783fccadcbab84be88917050ba06523d1ce3603d7e0f291df0e9c2c0d66d673585cb40a4dc23bd2b7e2ac33db6027bd56432404028d7d7ff6b5f4fddd5bec79c4221fa18854e71998947e31a329e35acb6ab0cf6d936c7a08a93c1cd06350c68538c14149ab61522a9fd9181a57462b69dafaff0f15963a8afe9e0c",
"11a29ac394a051015bf7d130ee1f06d57767d3c16ee76a1aa48e9f6566d60c2dc6e469b7565a5760fc791ee11652f007d0cdca78c98bb38827f6e0c084cf2fa304008040a7fa759d4b89462228373413ff20b4da08266c8ff25d7b735c4c9071418a9ebb12e8556d2a254aafbd9519a531baadd9c5a66b83c4b6a9ccf9a1b17adcc7db2e7e7f8d4e620f0339fdb8d418c2ec6102531c3b7dbc1d59f58c98f16f9385f82c74d85e4d97ab87820a21425f0d5833443adde9fb5047c8d7819be51b1754862beff429320e7c2315ebe63c35e296683f0b43cede9cbe4f457276b510e3df92d2a84891b9acbf49b2996470eaf33e6314aeedaa24fe3dcbb009383605",
"c4f02849554af5fd75af2069c843866bc9a8c6544cdd025be89b597734d7c0515ff37ed4f19081ce198a7833cd978cd66a4b8fa480dc1a1714c5078766ad9d85b2f88d98df3e1f3f645840ac6c5c085d53baffd2a92c25abe432389af9cbc29167d0e322292d9248b40e9905a36f0a15efd5104fd8f600e6462b31578394ae03eb8e1324a11e5ed126d9a7ca366e7137eeb1fa7c7606b823be21bc9ec1da7a3b62277bb0e56318827f0feda02a9cbf11bde0880d3a795256f7b6d309161b70b342602f1ca29f72124547bbe10b018b1ddeec0c4ee9cfcc9574f4a593c7db4dfb3565fee739b5ea96c304414484fc6d30e2f268b7897d733db95a50a63c61aa2e",
"1a599cadc8e41154ed370f3ae65f3c4bb81589b1e8da697791568bdb0624cf18f8b087df8c35cb86539da4664a7a710a4838ffdc8320ce9832f6d7af705e738a14721ec129790708e94346d02fde2a4f3d2c50b375fc0b64ae317b61a530a093d2bec255ba6cf36268fdac3b95491f6bb485f7a603ec6e9a81090c6aee9e4ef0ab2d7ea1e1bbc9bc41f2fa2e1bdd2734d36004b501d640f9d50247d9d8e08f2bfbb7c5eb5716e5788df400cd8239c696bdbfe336450e261d63e7843eb69b22a3f58e236dc3997d9097102580d44ce274ccb25c33c7ea05123f51a8fef11c42cad176286f92a767a919a2445baaef587f21c08865b95d4d0d5ac413527c9f9417",
"c72b82615bd09684d34a70a19b59339fc02014532917c50bc97b97020d3a1e7c3f6b52e8753df6e40c8a4fc35f2665733123284874aaa73609b1e291045122fc08a605a4f1121c19eb4037c2a0411dd4dc07438f47afd12e98ab01baf06668acf9e769b6cb8d78871503d5dfa31a9d6aea2f944e9e42d7b83892d8bbdedd9abcb04c79f4583ee98381ffe355fd5db2ef9c6d549960da3becfa11d6c42aed4bae13bfb9068be01f7f5aad90390ff3bd466c2cf2f8fed9e6720e89be5ec6a8cff5577e8cb7e1887ac81b18db6f35d216326463a58ef7b47695867de5a95c8500c1934dfb30a2b5253410773c67712d4445560a50b3cd6280ce214924caee276ecc",
"a5b06db251551fbf3e8ddd1992ea1bbd326529a48967b1906800da0da65954f210142f45e03b23fad750ac93e643010a5c78709846a97bf395072ad374b33fa36082394e3448c01cf6c291644d3cf89d359a94e37af0f96eb912b8042d02281385728087db2bf14f26a2e1497e9ccca7b6a0d09b3677ad8b6b4a031d058a064bafe531b5d4c60c66ba83fd090fae71b76fdc41e8178e407f6230ffa88425fb16ce3744ab991eeb183a47f75f81cfed582c6cfe9e57539720ca791a5a88f5699fe7d90ebe42df56e44c22aa730b15c5eefcc7d6cbcd8ce27621e9d1ecc87dd88f617c2ebcdeb475d2c4633da15e5d6a0824c927bbef33865bd5385211f4c396c1",
"345adb2c5957122b30c2a092bfedbc45de279b96d3e6c5ebd8244ba421cca8d6ce3cbab109e0d732664a831d19ca89670f420771e9bb445e8517c4ae9c3f6b78e76d02a7fe33e3d90a7deaf118877b6203795223003e25b9dd16683b1f90a62ac629918a9def1e8476ee4f39fb111b0b93ad49b705e14dcbe838d055f2f70d8b65cdfad49ec9814e14c826b6f9aaf580f86a98ab58f3b88eb5974372a2da64c1405c13b0e5bd089f1c7e8f06be6e501a2f37a1fc102e705304206348e2fd6f7a7561b3353acf5b888251d247a5a9746c31947c9ac0ec150e28012dc3f0b4e48ddcac3d5f86c79522f4b2a35446d1778c0cffaf565d36697f994cd56073df41f6",
"04da79631ebdeae30b652501cda992ed187557308903096adcc798a450911fb10c778566ca121c67c2e01783599dd5228256a89fc66048b311fca328fb06dd5dba297a4fe8fee910cbf3937b6c6954e744a2841d8dcefffa1a879074a1f814aabcc0cf31b4d9bfd85e262dd0e43f19d68c2fab3958726edf3be63cb66288de40b28f9b7c9543d19eac9cd423e10e4b5333462013341b97f7f1c3614a6f5a217f702e554105c5d776e52715ec425c4d78358befd2eeb5beae023ad35fc424f0f9514eeb000ffdaf3ec19a528681807ef62bccb97d68f2ad99a7072c7338b06bb78e71a0f43da60d37db0a473616966d322a5be24594f5a54cc88a4964bb08c9b8"
];
function rotate_right(data: Uint8Array, nBytes: number, nBits: number) {
let prior = data[nBytes - 1];
for (let i = 0; i < nBytes; i++) {
let current = data[i];
data[i] = (current >> nBits) | ((prior & ((1 << nBits) - 1)) << (8 - nBits));
prior = current;
}
}
const S_BOX_INV = s_box_inv.map(x => new Uint8Array(Buffer.from(x, 'hex')));
const N_TABLES = S_BOX_INV.length - 1;
const ITER_ADD = 5;
export function performDecrypt(data: Uint8Array) {
const spad = data.map(x => S_BOX_INV[N_TABLES][x]);
let count = (spad[15] >> 4) + 7;
let table = spad[15] + ITER_ADD * count;
for (let z = 0; z < count; z++) {
table -= ITER_ADD;
rotate_right(spad, 15, 5);
for (let i = 0; i < 15; i++) {
spad[i] = S_BOX_INV[table % N_TABLES][spad[i]];
}
}
return {
version: spad[0],
accessCodeBytes: spad.slice(6) // access code is a binary-coded decimal
};
}
+132
View File
@@ -0,0 +1,132 @@
import {Buffer} from "buffer";
import NfcManager, {NfcTech} from "react-native-nfc-manager";
import {performDecrypt} from "./iccard-crypto";
export enum ICType {
Amusement = 1,
Transport = 2
}
export enum AmusementICVendor {
Sega = 0x0078,
Konami = 0x0068,
BandaiNamcoEntertainment = 0x002A,
BandaiNamcoGames = 0x003A,
Nesica = 0x0079,
Unknown = 0x0000
}
type AmusementICCard = {
type: ICType.Amusement,
idm: string,
accessCode: string,
vendor: AmusementICVendor
}
type TransportICCard = {
type: ICType.Transport,
idm: string
}
export type ICCard = AmusementICCard | TransportICCard;
/**
* Converts an IC card IDm to the 20-digit "0008" number used by some networks
* @param idm The IDm to convert
*/
export function icCard0008Number(idm: string): string {
// 0008 is the numerical representation of the idm padded with zeros until it is 20 digits
return BigInt(`0x${idm}`).toString().padStart(20, '0');
}
/**
* Reads a FeliCa card, returning access code information.
*/
export async function felicaCardReadAsync(): Promise<ICCard | null> {
try {
await NfcManager.requestTechnology(NfcTech.FelicaIOS, {
isReaderModeEnabled: true,
invalidateAfterFirstRead: false,
alertMessage: "Hold an Amusement IC or Transport card to the top of your phone to scan it."
});
const tag = await NfcManager.getTag();
const idm = tag['idm'] as string;
switch (tag['systemCode'] as string) {
case "0003":
return {
type: ICType.Transport,
idm
};
case "88B4":
return await readAmusementICCardContents(idm);
default:
return null;
}
} catch (ex) {
return null;
} finally {
await NfcManager.cancelTechnologyRequest();
}
}
async function readAmusementICCardContents(idm: string): Promise<AmusementICCard | null> {
const idmBytes = Uint8Array.from(Buffer.from(idm, 'hex'));
const readCommand = [
0x06, // read
...idmBytes,
0x01, // service count (fixed)
0x0B, 0x00, // service code (000b, le)
0x02, // block count
0x80, 0x00, // S_PAD0 block
0x80, 0x82 // ID block
];
let response = await NfcManager.sendFelicaCommandIOS(readCommand);
const offset = response.indexOf(0x07); // look for 0x07 as for some reason the response may not start at response[0]
// response must have 44 bytes
if (offset === -1 || response.length - offset !== 44) {
return null;
} else if (offset !== 0) {
response = response.slice(offset);
}
// response format:
// 0 - read response (0x07)
// 1-8 - IDm
// 9 - status flag 1
// 10 - status flag 2
// 11 - number of blocks (0x02)
// 12-27 - S_PAD0 block
// 28-43 - ID block (DFC is 35-36)
if (response[9] !== 0x00 || response[11] !== 0x02) {
return null;
}
const dfcValue = response[37] | (response[36] << 8);
if (dfcValue in AmusementICVendor && dfcValue !== AmusementICVendor.Unknown) {
const accessCode = performDecrypt(new Uint8Array(response.slice(12, 28)));
return {
type: ICType.Amusement,
accessCode: [...accessCode.accessCodeBytes].map(x => x.toString(16).padStart(2, '0')).join(''),
vendor: dfcValue,
idm
};
}
return {
type: ICType.Amusement,
accessCode: icCard0008Number(idm),
vendor: AmusementICVendor.Unknown,
idm
};
}
+2 -1
View File
@@ -6,7 +6,8 @@ module.exports = (async () => {
const config = await getDefaultConfig(__dirname); const config = await getDefaultConfig(__dirname);
config.resolver.extraNodeModules = { config.resolver.extraNodeModules = {
'amnet-shared': resolve(__dirname, '../amnet-shared') 'amnet-shared': resolve(__dirname, '../amnet-shared'),
'@babel/runtime': resolve(__dirname, 'node_modules/@babel/runtime')
}; };
config.watchFolders = [ config.watchFolders = [
+33 -12
View File
@@ -1,12 +1,10 @@
import {Realm} from "realm"; import {Realm} from "realm";
export interface AMCardData { import {ICType} from "../lib/iccard";
code: string;
name: string;
added: Date; import {AMCardData} from "./AMCardData";
lastUsed?: Date | null; import {AMCardRecentUse} from "./AMCardRecentUse";
} import {AMPhysicalCardData} from "./AMPhysicalCardData";
export class AMCard extends Realm.Object<AMCard> implements AMCardData { export class AMCard extends Realm.Object<AMCard> implements AMCardData {
_id!: Realm.BSON.ObjectId; _id!: Realm.BSON.ObjectId;
@@ -15,17 +13,37 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
name: string; name: string;
added: Date; added: Date;
lastUsed?: Date | null; lastUsed: Date;
recentUses: Realm.Results<AMCardRecentUse>;
physicalCardData?: AMPhysicalCardData | null = null;
get isLimitedAimeCard() {
// limited cards use the company code 500
return this.physicalCardData?.cardType === ICType.Amusement && this.code.startsWith('500');
}
static generate(code: string, name: string) { static generate(code: string, name: string) {
return { return {
_id: new Realm.BSON.ObjectId(), _id: new Realm.BSON.ObjectId(),
added: new Date(),
code: code, code: code,
name: name, name: name
added: new Date()
}; };
} }
static delete(realm: Realm, card: AMCard) {
realm.write(() => {
if (card.physicalCardData) {
realm.delete(card.physicalCardData);
}
// remove any usage history tied to the card
realm.delete(card.recentUses);
realm.delete(card);
});
}
static schema: Realm.ObjectSchema = { static schema: Realm.ObjectSchema = {
name: "card", name: "card",
primaryKey: "_id", primaryKey: "_id",
@@ -33,9 +51,12 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
properties: { properties: {
_id: "objectId", _id: "objectId",
code: "string", code: "string",
name: {type: "string", default: "New Card"}, name: {type: "string", default: "New Card", indexed: true},
added: {type: "date", default: new Date()}, added: {type: "date", default: new Date(), indexed: true},
lastUsed: {type: "date", optional: true} lastUsed: {type: "date", optional: true, indexed: true},
recentUses: {type: "linkingObjects", objectType: AMCardRecentUse.schema.name, property: "card"},
physicalCardData: {type: "object", objectType: AMPhysicalCardData.schema.name, optional: true}
} }
}; };
} }
+11
View File
@@ -0,0 +1,11 @@
import {AMPhysicalCardData} from "./AMPhysicalCardData";
export interface AMCardData {
code: string;
name: string;
added: Date;
lastUsed?: Date | null;
isLimitedAimeCard: boolean;
physicalCardData?: AMPhysicalCardData | null;
}
+37
View File
@@ -0,0 +1,37 @@
import {Realm} from "realm";
import {AMCard} from "./AMCard";
/**
* Represents a usage of a card for determining which card was last used on a specific game.
*/
export class AMCardRecentUse extends Realm.Object<AMCardRecentUse> {
/**
* The game id that is being tracked
*/
gameId: string;
/**
* The card that was last used on the game.
* If the card is removed, this record will also be removed.
*/
card: AMCard;
static generate(gameId: string, card: AMCard) {
return {
gameId: gameId,
card: card
};
}
static schema: Realm.ObjectSchema = {
name: "cardUsageHistory",
primaryKey: "gameId",
properties: {
gameId: "string",
card: {type: "object", objectType: "card"}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import {Realm} from "realm";
import {AmusementICVendor, ICType} from "../lib/iccard";
/**
* Holds physical card information when a card is imported via NFC/FeliCa
*/
export class AMPhysicalCardData extends Realm.Object<AMPhysicalCardData> {
cardIDm: string;
cardType: ICType;
cardVendor: AmusementICVendor | null;
get isBootlegAmusementCard() {
// real cards have an IDm manufacturer code of 012E (first two bytes of the IDm or in this case, the first 4 characters)
return this.cardType === ICType.Amusement && !this.cardIDm.startsWith("012E");
}
static schema: Realm.ObjectSchema = {
name: "physicalCard",
primaryKey: "cardIDm",
properties: {
cardIDm: "string",
cardType: "int",
cardVendor: {type: "int", optional: true}
}
};
}
+17 -7
View File
@@ -1,25 +1,34 @@
import {Realm} from 'realm'; import {Realm} from 'realm';
import {AMServerInfoResponse} from "amnet-shared"; import {AMServerConnectionInfo, AMServerMetadata} from "amnet-shared";
export class AMServer extends Realm.Object<AMServer> implements AMServerInfoResponse { export class AMServer extends Realm.Object<AMServer> implements AMServerConnectionInfo, AMServerMetadata {
_id!: Realm.BSON.ObjectId; _id!: Realm.BSON.ObjectId;
apiVersion: number;
gameId?: string;
serverName: string; serverName: string;
serverAddress: string; serverAddress: string;
lastUsed: Date; lastUsed: Date;
createdAt: Date; createdAt: Date;
apiVersion: number;
preferredCardIdFormat?: "idm" | "access" | null;
get shareUrl() {
return `http://card.ppc.moe/#/servers/add?address=${encodeURIComponent(this.serverAddress)}`;
}
static generate(serverAddress: string) { static generate(serverAddress: string) {
return { return {
_id: new Realm.BSON.ObjectId(), _id: new Realm.BSON.ObjectId(),
apiVersion: 0, apiVersion: 0,
serverAddress,
serverName: "New Server", serverName: "New Server",
serverAddress: serverAddress,
createdAt: new Date(), createdAt: new Date(),
lastUsed: null lastUsed: null
} }
} }
@@ -31,10 +40,11 @@ export class AMServer extends Realm.Object<AMServer> implements AMServerInfoResp
properties: { properties: {
_id: "objectId", _id: "objectId",
apiVersion: {type: "int", default: 0}, apiVersion: {type: "int", default: 0},
gameId: {type: "string", optional: true},
serverAddress: "string", serverAddress: "string",
serverName: {type: "string", default: "New Server"}, serverName: {type: "string", default: "New Server", indexed: "full-text"},
createdAt: {type: "date", default: new Date()}, createdAt: {type: "date", default: new Date()},
lastUsed: {type: "date", optional: true} lastUsed: {type: "date", optional: true, indexed: true}
} }
}; };
} }
+35 -3
View File
@@ -1,10 +1,42 @@
import {Realm} from "realm";
import {createRealmContext} from "@realm/react"; import {createRealmContext} from "@realm/react";
import {AMCard} from "./AMCard"; import {AMCard} from "./AMCard";
import {AMServer} from "./AMServer"; import {AMServer} from "./AMServer";
import {AMCardRecentUse} from "./AMCardRecentUse";
import {AMPhysicalCardData} from "./AMPhysicalCardData";
const migrationFunction = (oldRealm: Realm, newRealm: Realm) => {
if (oldRealm.schemaVersion < 5) {
const oldCards = oldRealm.objects("card");
const gameIdMap: Map<string, { card: any, lastUsed: Date }> = new Map();
// get last used card for each game
oldCards.forEach((card: any) => {
if (card.lastUsedGameId && card.lastUsed) {
const existing = gameIdMap.get(card.lastUsedGameId);
if (!existing || card.lastUsed > existing.lastUsed) {
gameIdMap.set(card.lastUsedGameId, {card, lastUsed: card.lastUsed});
}
}
});
// write new history records
const newCards = newRealm.objects("card");
gameIdMap.forEach((value, gameId) => {
const newCard = newCards.filtered(`_id == $0`, value.card._id)[0];
newRealm.create("cardUsageHistory", {
gameId: gameId,
card: newCard
});
});
}
};
// @ts-ignore // @ts-ignore
export default createRealmContext({ export default createRealmContext({
schemaVersion: 1, schemaVersion: 5,
schema: [AMCard, AMServer], schema: [AMPhysicalCardData, AMCardRecentUse, AMCard, AMServer],
deleteRealmIfMigrationNeeded: false onMigration: migrationFunction,
deleteRealmIfMigrationNeeded: false,
}); });
+4128 -7105
View File
File diff suppressed because it is too large Load Diff
+37 -32
View File
@@ -1,44 +1,49 @@
{ {
"name": "amnet-native", "name": "amnet-native",
"version": "1.0.0", "version": "1.3.2",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"ios-prebuild": "expo prebuild --platform ios", "ios-prebuild": "expo prebuild --platform ios",
"postinstall": "patch-package" "android-prebuild": "expo prebuild --platform android"
}, },
"dependencies": { "dependencies": {
"@expo-google-fonts/jetbrains-mono": "^0.2.3", "@babel/runtime": "^7.28.4",
"@react-navigation/bottom-tabs": "^6.6.1", "@expo-google-fonts/jetbrains-mono": "^0.4.1",
"@react-navigation/native": "^6.1.18", "@react-native-menu/menu": "^2.0.0",
"@react-navigation/native-stack": "^6.11.0", "@react-navigation/bottom-tabs": "^7.9.0",
"@realm/react": "^0.9.0", "@react-navigation/native": "^7.1.26",
"@types/react": "~18.2.79", "@react-navigation/native-stack": "^7.9.0",
"@realm/react": "^0.20.0",
"amnet-shared": "file:../amnet-shared", "amnet-shared": "file:../amnet-shared",
"date-fns": "^3.6.0", "babel-preset-expo": "^54.0.9",
"expo": "^51.0.0", "date-fns": "^4.1.0",
"expo-blur": "~13.0.2", "expo": "^54.0.30",
"expo-clipboard": "~6.0.3", "expo-build-properties": "~1.0.10",
"expo-constants": "~16.0.2", "expo-clipboard": "~8.0.8",
"expo-dev-client": "~4.0.10", "expo-dev-client": "~6.0.20",
"expo-linking": "~6.3.1", "expo-linking": "~8.0.11",
"expo-status-bar": "~1.12.1", "expo-splash-screen": "~31.0.13",
"expo-system-ui": "~3.0.7", "expo-status-bar": "~3.0.9",
"lucide-react-native": "^0.427.0", "lucide-react-native": "^0.562.0",
"patch-package": "^8.0.0", "pusher-js": "^8.4.0",
"react": "18.2.0", "react": "19.1.0",
"react-native": "0.74.1", "react-native": "0.81.5",
"react-native-context-menu-view": "^1.16.0", "react-native-context-menu-view": "^1.21.0",
"react-native-gesture-handler": "^2.18.1", "react-native-gesture-handler": "^2.30.0",
"react-native-get-random-values": "^1.11.0", "react-native-get-random-values": "^2.0.0",
"react-native-paper": "^5.12.5", "react-native-nfc-manager": "^3.17.2",
"react-native-reanimated": "^3.15.0", "react-native-paper": "^5.14.5",
"react-native-safe-area-context": "4.10.5", "react-native-reanimated": "~4.2.1",
"react-native-screens": "3.31.1", "react-native-safe-area-context": "5.6.2",
"react-native-svg": "^15.5.0", "react-native-screens": "~4.19.0",
"react-native-url-polyfill": "^2.0.0", "react-native-svg": "15.15.1",
"realm": "^12.12.1", "react-native-url-polyfill": "^3.0.0",
"typescript": "~5.3.3" "realm": "^20.1.0"
},
"devDependencies": {
"@types/react": "~19.1.10",
"typescript": "~5.9.3"
} }
} }
@@ -1,64 +0,0 @@
diff --git a/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift b/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
index 8ba09a7..8d7d2b0 100644
--- a/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
+++ b/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
@@ -101,59 +101,6 @@ open class ExpoAppDelegate: UIResponder, UIApplicationDelegate {
}
}
- // MARK: - Handling Remote Notification Registration
-
- open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
- subscribers.forEach { $0.application?(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) }
- }
-
- open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
- subscribers.forEach { $0.application?(application, didFailToRegisterForRemoteNotificationsWithError: error) }
- }
-
- open func application(
- _ application: UIApplication,
- didReceiveRemoteNotification userInfo: [AnyHashable: Any],
- fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
- ) {
- let selector = #selector(application(_:didReceiveRemoteNotification:fetchCompletionHandler:))
- let subs = subscribers.filter { $0.responds(to: selector) }
- var subscribersLeft = subs.count
- let dispatchQueue = DispatchQueue(label: "expo.application.remoteNotification", qos: .userInteractive)
- var failedCount = 0
- var newDataCount = 0
-
- let handler = { (result: UIBackgroundFetchResult) in
- dispatchQueue.sync {
- if result == .failed {
- failedCount += 1
- } else if result == .newData {
- newDataCount += 1
- }
-
- subscribersLeft -= 1
-
- if subscribersLeft == 0 {
- if newDataCount > 0 {
- completionHandler(.newData)
- } else if failedCount > 0 {
- completionHandler(.failed)
- } else {
- completionHandler(.noData)
- }
- }
- }
- }
-
- if subs.isEmpty {
- completionHandler(.noData)
- } else {
- subs.forEach { subscriber in
- subscriber.application?(application, didReceiveRemoteNotification: userInfo, fetchCompletionHandler: handler)
- }
- }
- }
-
// MARK: - Continuing User Activity and Handling Quick Actions
open func application(_ application: UIApplication, willContinueUserActivityWithType userActivityType: String) -> Bool {
+4 -1
View File
@@ -1,4 +1,7 @@
{ {
"compilerOptions": {}, "compilerOptions": {
"strictNullChecks": false,
"moduleResolution": "bundler"
},
"extends": "expo/tsconfig.base" "extends": "expo/tsconfig.base"
} }
+8 -1
View File
@@ -13,7 +13,14 @@ const modalStyles = StyleSheet.create({
}, },
formItemDivider: { formItemDivider: {
marginVertical: 15 marginVertical: 15
} },
rootLayout: {
display: "flex",
gap: 10,
justifyContent: "center",
paddingHorizontal: 15,
paddingTop: 25
},
}); });
export function useModalStyles() { export function useModalStyles() {
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "amnet-server-test"
edition = "2024"
version.workspace = true
authors.workspace = true
description = "AMNet Server Test Application"
[[bin]]
name = "amnet-server-test"
path = "src/main.rs"
[dependencies]
amnet = { path = "../amnet-server" }
axum = "0.8.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
tracing = "0.1"
tracing-subscriber = "0.3"
+65
View File
@@ -0,0 +1,65 @@
use std::time::Duration;
use amnet::{build_server, CardSlot, ServerConfig, Metrics, format_hex_bytes};
use tokio::net::TcpListener;
use tracing::{info, warn};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.init();
let config = ServerConfig::instance();
info!("AMNet Server (Test Application)");
info!("Configuration:");
info!(" Game ID: {}", config.game_id.as_deref().unwrap_or("(not set)"));
info!(" Server Name: {}", config.server_name);
info!(" Listen Address: {}", config.server_listen_address);
info!(" Physical Card Use IDm: {}", config.physical_card_use_idm);
info!("");
// Start the webserver in the background
let app = build_server();
let listen_addr = config.server_listen_address.as_str();
let listener = match TcpListener::bind(listen_addr).await {
Ok(l) => l,
Err(e) => {
warn!("Failed to bind to {}: {}", listen_addr, e);
return;
}
};
Metrics::instance().set_start_time();
info!("Server started on {}", listen_addr);
info!("Submit a card via the web API and it will be detected here.");
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
warn!("Server error: {}", e);
}
});
loop {
if let Err(_) = Metrics::instance().set_last_poll() {
warn!("Failed to update poll metrics");
}
if let Some(card) = CardSlot::instance().request_card(None) {
info!("=== CARD DETECTED ===");
info!(" Access Code: {}", format_hex_bytes(&card.access_code));
if let Some(idm) = card.card_idm {
info!(" Card IDm: {}", format_hex_bytes(&idm));
} else {
info!(" Card IDm: (not provided)");
}
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "amnet"
edition = "2024"
build = "build.rs"
version.workspace = true
authors.workspace = true
description.workspace = true
[lib]
name = "amnet"
crate-type = ["cdylib", "rlib"]
[profile.release]
lto = true
strip = true
[dependencies]
axum = "0.8.8"
hostname = "0.4"
tracing = "0.1"
rust-ini = "0.21"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "net"] }
tower = "0.5.2"
tower-http = { version = "0.6.8", features = ["cors", "trace"] }
windows-sys = { version = "0.61.2", features = ["Win32_System_Console", "Win32_UI_Input_KeyboardAndMouse"] }
tracing-subscriber = "0.3.22"
[build-dependencies]
winresource = "0.1"
+32
View File
@@ -0,0 +1,32 @@
fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" {
set_windows_resource_version();
}
}
fn set_windows_resource_version() {
let version_str = env!("CARGO_PKG_VERSION");
let version_parts: Vec<&str> = version_str.split('.').collect();
let mut normalized_parts = vec!["0", "0", "0", "0"];
for (i, part) in version_parts.iter().enumerate().take(4) {
normalized_parts[i] = part;
}
let version_display = normalized_parts.join(".");
let mut res = winresource::WindowsResource::new();
res.set("FileVersion", &version_display);
res.set("ProductVersion", "2.0.0.0");
res.set("ProductName", "AMNet");
res.set("FileDescription", "AMNet Server");
res.set("CompanyName", "ppc");
res.set("InternalName", "amnet-server");
res.set("OriginalFilename", "amnet.dll");
res.set("LegalCopyright", "Copyright 2026 (c) ppc");
if let Err(e) = res.compile() {
println!("cargo:warning=Failed to set Windows resource information: {}", e);
}
}
+271
View File
@@ -0,0 +1,271 @@
use std::{sync::{Mutex, OnceLock}, time::{Duration, Instant}};
use tracing::debug;
#[derive(Debug, Clone)]
pub struct StoredCard {
pub access_code: [u8; 10],
pub card_idm: Option<[u8; 8]>,
pub expiry_time: Instant
}
impl StoredCard {
pub fn is_expired(&self) -> bool {
Instant::now() > self.expiry_time
}
}
pub enum CardPresentResult {
Accepted,
SlotUnavailable(Instant /* release time */),
}
static PRESENTER: OnceLock<CardPresenter> = OnceLock::new();
pub struct CardPresenter {
current_card: Mutex<Option<StoredCard>>,
}
impl Default for CardPresenter {
fn default() -> Self {
Self {
current_card: Mutex::new(None),
}
}
}
impl CardPresenter {
pub fn instance() -> &'static CardPresenter {
PRESENTER.get_or_init(|| CardPresenter::default())
}
pub fn request_card(&self, require_idm: Option<bool>) -> Option<StoredCard> {
let mut guard = self.current_card.lock().unwrap();
let card = guard.as_ref()?;
if let Some(require) = require_idm && card.card_idm.is_some() != require {
return None;
}
let card = guard.take();
if let Some(ref stored_card) = card && stored_card.is_expired() {
debug!("Stored card has expired and will not be provided");
return None;
};
card
}
pub fn present_card(&self, access_code: [u8; 10], idm_hex: Option<[u8; 8]>, validity: Duration) -> CardPresentResult {
let mut guard = self.current_card.lock().unwrap();
if let Some(card) = guard.as_ref() && !card.is_expired() {
return CardPresentResult::SlotUnavailable(card.expiry_time);
}
let stored_card = StoredCard {
access_code,
card_idm: idm_hex,
expiry_time: Instant::now() + validity,
};
*guard = Some(stored_card);
CardPresentResult::Accepted
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::{parse_access_code, parse_idm_hex};
mod stored_card_tests {
use super::*;
#[test]
fn is_expired_returns_false_for_future_expiry() {
let card = StoredCard {
access_code: [0; 10],
card_idm: None,
expiry_time: Instant::now() + Duration::from_secs(60),
};
assert!(!card.is_expired());
}
#[test]
fn is_expired_returns_true_for_past_expiry() {
let card = StoredCard {
access_code: [0; 10],
card_idm: None,
expiry_time: Instant::now() - Duration::from_millis(1),
};
assert!(card.is_expired());
}
}
mod card_presenter_tests {
use super::*;
fn create_presenter() -> CardPresenter {
CardPresenter::default()
}
#[test]
fn request_card_returns_none_when_empty() {
let presenter = create_presenter();
assert!(presenter.request_card(None).is_none());
}
#[test]
fn present_card_accepts_valid_access_code() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
match presenter.present_card(access_code, None, Duration::from_secs(5)) {
CardPresentResult::Accepted => {}
_ => panic!("Expected Accepted"),
}
}
#[test]
fn present_card_rejects_invalid_access_code() {
let access_code = parse_access_code(&"INVALID".to_string());
assert!(access_code.is_none(), "Expected parsing to fail for invalid access code");
}
#[test]
fn present_card_accepts_valid_idm() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap();
match presenter.present_card(access_code, Some(idm), Duration::from_secs(5)) {
CardPresentResult::Accepted => {}
_ => panic!("Expected Accepted"),
}
}
#[test]
fn present_card_rejects_invalid_idm() {
let idm = parse_idm_hex(&"INVALID_IDM".to_string());
assert!(idm.is_none(), "Expected parsing to fail for invalid IDm");
}
#[test]
fn present_card_returns_slot_unavailable_when_card_present() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
// Present first card
presenter.present_card(access_code, None, Duration::from_secs(5));
// Try to present second card
match presenter.present_card(access_code, None, Duration::from_secs(5)) {
CardPresentResult::SlotUnavailable(_) => {}
_ => panic!("Expected SlotUnavailable"),
}
}
#[test]
fn request_card_returns_card_after_present() {
let presenter = create_presenter();
let access_code = parse_access_code(&"00101234567890123456".to_string()).unwrap();
presenter.present_card(access_code, None, Duration::from_secs(5));
let card = presenter.request_card(None);
assert!(card.is_some());
let card = card.unwrap();
assert_eq!(card.access_code, [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]);
}
#[test]
fn request_card_consumes_the_card() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
presenter.present_card(access_code, None, Duration::from_secs(5));
// First request should succeed
assert!(presenter.request_card(None).is_some());
// Second request should return None
assert!(presenter.request_card(None).is_none());
}
#[test]
fn request_card_filters_by_idm_requirement_true() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
// Present card without IDm
presenter.present_card(access_code, None, Duration::from_secs(5));
// Request card that requires IDm should return None
assert!(presenter.request_card(Some(true)).is_none());
}
#[test]
fn request_card_filters_by_idm_requirement_false() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap();
// Present card with IDm
presenter.present_card(access_code, Some(idm), Duration::from_secs(5));
// Request card that requires no IDm should return None
assert!(presenter.request_card(Some(false)).is_none());
}
#[test]
fn request_card_returns_card_with_idm_when_required() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
let idm = parse_idm_hex(&"0102030405060708".to_string()).unwrap();
presenter.present_card(access_code, Some(idm), Duration::from_secs(5));
let card = presenter.request_card(Some(true));
assert!(card.is_some());
assert!(card.unwrap().card_idm.is_some());
}
#[test]
fn expired_card_cannot_be_requested() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
// Present card with very short validity
presenter.present_card(access_code, None, Duration::from_millis(1));
// Wait for expiry
std::thread::sleep(Duration::from_millis(10));
// Card should not be available
assert!(presenter.request_card(None).is_none());
}
#[test]
fn can_present_card_after_previous_expires() {
let presenter = create_presenter();
let access_code = parse_access_code(&"12345678901234567890".to_string()).unwrap();
// Present first card with very short validity
presenter.present_card(access_code, None, Duration::from_millis(1));
// Wait for expiry
std::thread::sleep(Duration::from_millis(10));
// Should be able to present another card
match presenter.present_card(access_code, None, Duration::from_secs(5)) {
CardPresentResult::Accepted => {}
_ => panic!("Expected Accepted after previous card expired"),
}
}
}
}
+135
View File
@@ -0,0 +1,135 @@
use std::env;
use std::path::PathBuf;
use std::sync::OnceLock;
use ini::Ini;
use tracing::{error, warn};
mod defaults {
pub const CONFIG_FILE: &str = "./segatools.ini";
pub const CONFIG_FILE_ENV: &str = "SEGATOOLS_CONFIG_PATH";
pub const SERVER_NAME: &str = "AMNET-SERVER";
pub const SERVER_ADDRESS: &str = "0.0.0.0:6070";
pub const AIME_FILE: &str = r"DEVICE\aime.txt";
pub const AIME_KEYBOARD_ENABLED: bool = true;
pub const INCLUDE_IDM_FOR_PHYSICAL_CARDS: bool = false;
}
mod limits {
pub const GAME_ID_LEN: usize = 4;
pub const SERVER_NAME_MAX_LEN: usize = 16;
}
static CONFIG: OnceLock<Config> = OnceLock::new();
pub struct Config {
/// Game ID (4 characters or none)
pub game_id: Option<String>,
/// Server name shown on devices (max 16 chars). Defaults to the machine hostname.
pub server_name: String,
/// Whether physically scanned cards should have their IDm forwarded to the game
/// instead of the access code.
pub physical_card_use_idm: bool,
/// Whether holding the enter key should trigger a card scan using the code
/// from the aime.txt file
pub enable_aime_txt: bool,
/// Path to the aime.txt file
pub aime_txt_path: PathBuf,
/// Server address that should be listened on
pub server_listen_address: String,
}
impl Config {
pub fn instance() -> &'static Config {
CONFIG.get_or_init(|| {
Config::load().unwrap_or_else(|e| {
warn!("Failed to load config: {}. Using defaults.", e);
Config::default()
})
})
}
fn load() -> Result<Self, Box<dyn std::error::Error>> {
let config_path = env::var(defaults::CONFIG_FILE_ENV).unwrap_or_else(|_| defaults::CONFIG_FILE.to_string());
let ini = Ini::load_from_file(&config_path)?;
let io_section = ini.section(Some("aimeio")).ok_or("Missing [aimeio] section")?;
let aime_section = ini.section(Some("aime"));
// gameId must be exactly 4 ascii letters or empty
let game_id = io_section.get("gameId").unwrap_or("");
let game_id = if game_id.is_empty() {
None
} else if game_id.chars().count() == limits::GAME_ID_LEN && game_id.chars().all(|c| c.is_ascii_alphabetic()) {
Some(game_id.to_ascii_uppercase().to_string())
} else {
error!("Invalid gameId '{game_id}': must be exactly {} characters or empty", limits::GAME_ID_LEN);
None
};
let default_hostname = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.unwrap_or_else(|| defaults::SERVER_NAME.to_string());
let server_name = io_section.get("serverName")
.unwrap_or(&default_hostname)
.chars()
.take(limits::SERVER_NAME_MAX_LEN)
.collect();
// Read boolean flags
let physical_card_use_idm = io_section.get("useAimeDBForPhysicalCards")
.and_then(|v| v.parse::<i32>().ok())
.map(|v| v > 0)
.unwrap_or(defaults::INCLUDE_IDM_FOR_PHYSICAL_CARDS);
let enable_aime_txt = io_section.get("enableKeyboardMode")
.and_then(|v| v.parse::<i32>().ok())
.map(|v| v > 0)
.unwrap_or(defaults::AIME_KEYBOARD_ENABLED);
let aime_txt_path = aime_section
.and_then(|s| s.get("aimePath"))
.unwrap_or(defaults::AIME_FILE)
.into();
let server_listen_address = io_section.get("listenAddress")
.unwrap_or(defaults::SERVER_ADDRESS)
.to_string();
Ok(Config {
game_id,
server_name,
physical_card_use_idm,
enable_aime_txt,
aime_txt_path,
server_listen_address,
})
}
}
impl Default for Config {
fn default() -> Self {
let default_hostname = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.unwrap_or_else(|| defaults::SERVER_NAME.to_string());
Self {
game_id: None,
server_name: default_hostname.chars().take(limits::SERVER_NAME_MAX_LEN).collect(),
physical_card_use_idm: defaults::INCLUDE_IDM_FOR_PHYSICAL_CARDS,
enable_aime_txt: defaults::AIME_KEYBOARD_ENABLED,
aime_txt_path: PathBuf::from(defaults::AIME_FILE),
server_listen_address: defaults::SERVER_ADDRESS.to_string(),
}
}
}
+197
View File
@@ -0,0 +1,197 @@
use std::sync::OnceLock;
use tokio::net::TcpListener;
use tokio::runtime::Runtime;
use tracing::{info, warn};
mod config;
mod card;
mod webapi;
mod metrics;
mod utils;
mod webserver;
use card::CardPresenter;
use config::Config;
use metrics::SystemMetrics;
// Re-export public API for library users
pub use config::Config as ServerConfig;
pub use card::{CardPresenter as CardSlot, StoredCard, CardPresentResult};
pub use metrics::SystemMetrics as Metrics;
pub use webserver::build_server;
pub use utils::{format_hex_bytes, parse_access_code, parse_idm_hex};
static WEBSERVER_INIT: OnceLock<()> = OnceLock::<()>::new();
#[unsafe(no_mangle)]
pub extern "C" fn aime_io_get_api_version() -> u16 {
0x0100
}
#[unsafe(no_mangle)]
pub extern "C" fn aime_io_init() -> i32 {
WEBSERVER_INIT.get_or_init(|| {
std::thread::spawn(|| {
let rt = Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(start_server_async());
});
});
return 0;
}
#[unsafe(no_mangle)]
pub extern "C" fn aime_io_nfc_poll(unit_no: u8) -> i32 {
if unit_no != 0 {
return 1;
}
if let Err(_) = SystemMetrics::instance().set_last_poll() {
warn!("Failed to update last poll time");
return 1;
}
#[cfg(target_os = "windows")]
{
use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
let config = Config::instance();
if !config.enable_aime_txt {
return 1;
}
let enter_pressed = unsafe { (GetAsyncKeyState(0x0D) & (0x8000u16 as i16)) != 0 };
if !enter_pressed {
return 1;
}
let aime_code = match std::fs::read_to_string(&config.aime_txt_path) {
Ok(content) => content.trim().to_string(),
Err(e) => {
warn!("Failed to read text file at {}: {}", config.aime_txt_path.display(), e);
return 1;
}
};
if aime_code.is_empty() {
warn!("Access code file is empty");
return 1;
}
let Some(access_code) = utils::parse_access_code(&aime_code) else {
warn!("Invalid access code read from file");
return 1;
};
match CardPresenter::instance().present_card(access_code, None, std::time::Duration::from_secs(5)) {
card::CardPresentResult::Accepted => {
return 0;
},
card::CardPresentResult::SlotUnavailable(release_time) => {
warn!("Access code was not accepted as there is already a card in the active slot until {:?}", release_time);
}
}
}
return 1;
}
#[unsafe(no_mangle)]
pub extern "C" fn aime_io_nfc_get_aime_id(unit_no: u8, luid: *mut u8, luid_size: usize) -> i32 {
if unit_no != 0 {
return 1;
}
let require_idm = if Config::instance().physical_card_use_idm {
Some(false)
} else {
None
};
let Some(card) = CardPresenter::instance().request_card(require_idm) else {
return 1;
};
unsafe {
let copy_size = std::cmp::min(card.access_code.len(), luid_size);
std::ptr::copy_nonoverlapping(card.access_code.as_ptr(), luid, copy_size);
}
info!("Provided access code: {}", utils::format_hex_bytes(&card.access_code));
return 0;
}
#[unsafe(no_mangle)]
pub extern "C" fn aime_io_nfc_get_felica_id(unit_no: u8, idm: *mut u64) -> i32 {
if unit_no != 0 || Config::instance().physical_card_use_idm == false {
return 1;
}
let Some(card) = CardPresenter::instance().request_card(Some(true)) else {
return 1;
};
let Some(card_idm) = card.card_idm else {
return 1;
};
let mut idm_value: u64 = 0;
for byte in card_idm {
idm_value = (idm_value << 8) | (byte as u64);
}
unsafe {
*idm = idm_value;
}
info!("Provided IDm: {}", utils::format_hex_bytes(&card_idm));
return 0;
}
#[unsafe(no_mangle)]
pub extern "C" fn aime_io_led_set_color(_unit_no: u8, _r: u8, _g: u8, _b: u8) {
// do nothing
return;
}
#[unsafe(no_mangle)]
#[allow(non_snake_case)]
#[cfg(target_os = "windows")]
pub extern "system" fn DllMain(_hinst_dll: *mut std::ffi::c_void, fdw_reason: u32, _lpv_reserved: *mut std::ffi::c_void) -> i32 {
if fdw_reason != 1 /* DLL_PROCESS_ATTACH */ {
return 1;
}
unsafe {
windows_sys::Win32::System::Console::AllocConsole();
}
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_ansi(false) // some targets that are injected into don't play nicely with ansi codes
.init();
return 1;
}
async fn start_server_async() {
let app = webserver::build_server();
let config = Config::instance();
let listen_addr = config.server_listen_address.as_str();
let listener = TcpListener::bind(listen_addr)
.await
.unwrap_or_else(|e| panic!("Failed to bind to {}: {}", listen_addr, e));
SystemMetrics::instance().set_start_time();
let game_id = config.game_id.clone().unwrap_or_else(|| "SXXX".to_string());
info!("AMNet Server - Version {} ({}) loaded", env!("CARGO_PKG_VERSION"), game_id);
info!("Listening on {} - visit http://card.ppc.moe from a mobile device or use the AMNet App to get started.", listen_addr);
axum::serve(listener, app)
.await
.expect("Failed to start server");
}
+59
View File
@@ -0,0 +1,59 @@
use std::{sync::{Mutex, OnceLock}, time::{Duration, Instant}};
static METRICS: OnceLock<SystemMetrics> = OnceLock::new();
#[derive(Debug)]
pub struct SystemMetrics {
start_time: OnceLock<Instant>,
last_poll_time: Mutex<Option<Instant>>,
}
impl Default for SystemMetrics {
fn default() -> Self {
Self {
start_time: OnceLock::new(),
last_poll_time: Mutex::new(None),
}
}
}
impl SystemMetrics {
pub fn instance() -> &'static SystemMetrics {
METRICS.get_or_init(|| SystemMetrics::default())
}
/// get the start_time of the server.
pub fn get_server_uptime(&self) -> Option<Duration> {
let Some(time) = self.start_time.get() else {
return None;
};
Some(time.elapsed())
}
/// sets the start time of the server.
/// this does nothing if the time was already set.
pub fn set_start_time(&self) {
self.start_time.get_or_init(|| Instant::now());
}
/// get the last time the card was polled by the game.
/// if the data is not available, an empty err will be returned.
pub fn get_last_poll(&self) -> Option<Duration> {
let Ok(time_guard) = self.last_poll_time.lock() else {
return None;
};
Some(time_guard.as_ref()?.elapsed())
}
/// Set the last poll time to now, returning the previously stored value
pub fn set_last_poll(&self) -> Result<(), ()> {
let Ok(mut time_guard) = self.last_poll_time.lock() else {
return Err(());
};
time_guard.replace(Instant::now());
return Ok(());
}
}
+157
View File
@@ -0,0 +1,157 @@
pub fn format_hex_bytes(bytes: &[u8]) -> String {
bytes.chunks(2)
.map(|chunk| chunk.iter().map(|b| format!("{:02X}", b)).collect::<String>())
.collect::<Vec<_>>()
.join(" ")
}
pub fn parse_access_code(code: &String) -> Option<[u8; 10]> {
let matrix_code = code.replace(" ", "");
if code.chars().any(|c| !c.is_ascii_hexdigit() && !c.is_whitespace()) && matrix_code.len() != 20 {
return None;
}
let mut bytes: [u8; 10] = [0; 10];
for i in 0..bytes.len() {
let slice_str = &matrix_code[i*2..i*2+2];
let slice_val = u8::from_str_radix(slice_str, 16).ok()?;
bytes[i] = slice_val;
}
Some(bytes)
}
pub fn parse_idm_hex(idm_hex: &String) -> Option<[u8; 8]> {
if idm_hex.len() != 16 || idm_hex.chars().any(|c| !c.is_ascii_hexdigit()) {
return None;
}
let mut idm_bytes: [u8; 8] = [0; 8];
for i in 0..idm_bytes.len() {
let slice_str = &idm_hex[i*2..i*2+2];
let slice_val = u8::from_str_radix(slice_str, 16).ok()?;
idm_bytes[i] = slice_val;
}
Some(idm_bytes)
}
#[cfg(test)]
mod tests {
use super::*;
mod format_hex_bytes_tests {
use super::*;
#[test]
fn formats_empty_bytes() {
assert_eq!(format_hex_bytes(&[]), "");
}
#[test]
fn formats_access_code_bytes() {
let bytes = [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56];
assert_eq!(format_hex_bytes(&bytes), "0010 1234 5678 9012 3456");
}
}
mod parse_access_code_tests {
use super::*;
#[test]
fn parses_full_20_digit_code() {
let code = "00101234567890123456".to_string();
let result = parse_access_code(&code);
assert!(result.is_some());
assert_eq!(result.unwrap(), [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]);
}
#[test]
fn parses_code_with_spaces() {
let code = "0010 1234 5678 9012 3456".to_string();
let result = parse_access_code(&code);
assert!(result.is_some());
assert_eq!(result.unwrap(), [0x00, 0x10, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56]);
}
#[test]
fn parses_code_with_leading_zeros() {
let code = "00000000000000001234".to_string(); // Valid 20-digit code with leading zeros
let result = parse_access_code(&code);
assert!(result.is_some());
assert_eq!(result.unwrap(), [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34]);
}
#[test]
fn returns_none_for_invalid_hex() {
let code = "GHIJKLMNOPQRSTUVWXYZ".to_string();
let result = parse_access_code(&code);
assert!(result.is_none());
}
#[test]
fn parses_code_with_all_zeros() {
let code = "00000000000000000000".to_string();
let result = parse_access_code(&code);
assert!(result.is_some());
assert_eq!(result.unwrap(), [0x00; 10]);
}
}
mod parse_idm_hex_tests {
use super::*;
#[test]
fn parses_full_16_char_idm() {
let idm = "0102030405060708".to_string();
let result = parse_idm_hex(&idm);
assert!(result.is_some());
assert_eq!(result.unwrap(), [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
}
#[test]
fn parses_short_idm_returns_none() {
let idm = "1234".to_string();
let result = parse_idm_hex(&idm);
// IDm is too short - should fail
assert!(result.is_none());
}
#[test]
fn returns_none_for_too_long_idm() {
let idm = "01020304050607080910".to_string(); // 20 chars
let result = parse_idm_hex(&idm);
assert!(result.is_none());
}
#[test]
fn returns_none_for_non_hex_characters() {
let idm = "0102GHIJ".to_string();
let result = parse_idm_hex(&idm);
assert!(result.is_none());
}
#[test]
fn parses_empty_idm_returns_none() {
let idm = "".to_string();
let result = parse_idm_hex(&idm);
// Empty IDm should fail
assert!(result.is_none());
}
}
}
+35
View File
@@ -0,0 +1,35 @@
use serde::{Deserialize, Serialize};
pub const API_VERSION: u8 = 1;
#[derive(Serialize)]
pub struct ServerError {
pub error: String,
}
#[derive(Serialize)]
pub struct ServerState {
#[serde(rename = "apiVersion")]
pub api_version: u8,
#[serde(rename = "gameId")]
pub game_id: Option<String>,
#[serde(rename = "serverName")]
pub server_name: String,
#[serde(rename = "sessionUptime")]
pub session_uptime: u64,
#[serde(rename = "timeSinceLastPoll")]
pub time_since_last_poll: Option<u64>,
}
#[derive(Deserialize)]
pub struct CardSubmissionRequest {
#[serde(rename = "cardId")]
pub access_code: String,
#[serde(rename = "physicalCardIDm")]
pub card_idm_hex: Option<String>,
}
+99
View File
@@ -0,0 +1,99 @@
use std::time::{Duration, Instant};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use tower::ServiceBuilder;
use tower_http::{cors::{Any, CorsLayer}, trace::TraceLayer};
use crate::card::{CardPresentResult, CardPresenter};
use crate::config::Config;
use crate::metrics::SystemMetrics;
use crate::utils;
use crate::webapi::API_VERSION;
pub fn build_server() -> Router {
let services = ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::new().allow_origin(Any).allow_methods(Any));
Router::<()>::new()
.route("/amnet/info", get(server_state_handler))
.route("/amnet/signin", post(card_submission_handler))
.layer(services)
}
fn build_error_response(status: StatusCode, message: &str) -> Response {
let error = crate::webapi::ServerError {
error: message.to_string(),
};
(status, Json(error)).into_response()
}
async fn server_state_handler() -> Json<crate::webapi::ServerState> {
let config = Config::instance();
let metrics = SystemMetrics::instance();
let session_uptime = metrics.get_server_uptime().map(|d| d.as_secs()).unwrap_or(0);
let time_since_last_poll = metrics.get_last_poll().map(|d| d.as_secs());
let state = crate::webapi::ServerState {
api_version: API_VERSION,
game_id: config.game_id.clone(),
server_name: config.server_name.clone(),
session_uptime,
time_since_last_poll,
};
Json(state)
}
async fn card_submission_handler(
Json(payload): Json<crate::webapi::CardSubmissionRequest>,
) -> impl IntoResponse {
if payload.access_code.len() > 20 || payload.access_code.len() == 0 {
return build_error_response(StatusCode::BAD_REQUEST, "Access code is required");
}
let Some(access_code) = utils::parse_access_code(&payload.access_code) else {
return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid access code provided");
};
let card_idm_bytes = if let Some(idm_hex) = payload.card_idm_hex.as_ref() {
if idm_hex.len() != 16 {
return build_error_response(StatusCode::BAD_REQUEST, "IDm must be a 16-char hex string");
}
let Some(idm_bytes) = utils::parse_idm_hex(idm_hex) else {
return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid IDm provided");
};
Some(idm_bytes)
} else {
None
};
match CardPresenter::instance().present_card(access_code, card_idm_bytes, Duration::from_secs(5)) {
CardPresentResult::Accepted => StatusCode::ACCEPTED.into_response(),
CardPresentResult::SlotUnavailable(release_time) => {
let now = Instant::now();
let wait_secs = if release_time > now {
(release_time - now).as_secs()
} else {
0
};
let mut response = build_error_response(
StatusCode::TOO_MANY_REQUESTS,
"A card has already been submitted. Please wait before submitting another.",
);
let headers = response.headers_mut();
headers.insert(header::RETRY_AFTER, wait_secs.to_string().parse().unwrap());
response
}
}
}
+47 -27
View File
@@ -2,30 +2,55 @@
* The latest version of the AMNet API this library supports * The latest version of the AMNet API this library supports
*/ */
declare const AMNET_API_VERSION = 1; declare const AMNET_API_VERSION = 1;
/**
* The minimum version of the AMNet API this library supports
*/
declare const MIN_AMNET_API_VERSION = 1;
interface AMCardInRequest { interface AMCardInRequest {
cardId: string; cardId: string;
} physicalCardIDm?: string;
interface AMServerInfoResponse {
serverAddress: string;
serverName: string;
apiVersion: number;
gameId: string;
} }
/**
* Server connection info (api version, address, etc.)
*/
interface AMServerConnectionInfo {
apiVersion: number;
serverAddress: string;
}
/**
* User-definable server metadata
*/
interface AMServerMetadata {
serverName: string;
gameId?: string | null | undefined;
}
/**
* Metrics about the current game session
*/
interface AMServerInstanceMetrics {
timeSinceLastPoll?: number;
sessionUptime?: number;
}
/**
* The response from an AMNet server's info endpoint
*/
type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
/** /**
* Performs the card-in request to the specified server * Performs the card-in request to the specified server
* @param serverAddress The origin address of the server to perform the request against * @param server The server to perform the request against
* @param request The card-in request to send * @param request The card-in request to send
* @param signal The abort signal to use for the request * @param signal The abort signal to use for the request
*/ */
declare function performCardIn(serverAddress: string, request: AMCardInRequest, signal: AbortSignal): Promise<boolean>; declare function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal): Promise<boolean>;
/** /**
* Attempts to connect to the specified server and fetch its info * Attempts to connect to the specified server and fetch its info
* @param serverAddress The origin address of the server to connect to * @param serverAddress The origin address of the server to connect to
* @param abort The abort signal to use for the request (for cancellation) * @param abort The abort signal to use for the request (for cancellation)
*/ */
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfoResponse | null>; declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
declare const CARD_ID_REGEX: RegExp; declare const CARD_ID_REGEX: RegExp;
declare const AMNET_COMPANY_ID = 597;
declare function generateCardNumber(): string; declare function generateCardNumber(): string;
/** /**
* Validates the provided card id by known parameters * Validates the provided card id by known parameters
@@ -33,22 +58,17 @@ declare function generateCardNumber(): string;
*/ */
declare function validateCardId(id: string): boolean; declare function validateCardId(id: string): boolean;
declare enum GameIds { /**
/** * Get the game name from the game identifier (4-letter code)
* Initial D The Arcade * @param id
*/ */
IDAC = "SDGT", declare function getGameName(id: string): string | null;
/**
* Chunithm (Paradise Lost)
*/
Chuni = "SDBT",
/**
* Chunithm NEW onwards
*/
Chusan = "SDHD",
ONGEKI = "SDDT",
maimaiDX = "SDEZ",
DivaFT = "SBZV"
}
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerInfoResponse, CARD_ID_REGEX, GameIds, fetchServerInfo, generateCardNumber, performCardIn, validateCardId }; type DecodedAccessCode = {
revision: number;
serial: number;
};
declare function decodeAccessCode(code: string): DecodedAccessCode | null;
declare function encodeAccessCode(company: number, serial: number, rev?: number): string;
export { type AMCardInRequest, AMNET_API_VERSION, AMNET_COMPANY_ID, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
+47 -27
View File
@@ -2,30 +2,55 @@
* The latest version of the AMNet API this library supports * The latest version of the AMNet API this library supports
*/ */
declare const AMNET_API_VERSION = 1; declare const AMNET_API_VERSION = 1;
/**
* The minimum version of the AMNet API this library supports
*/
declare const MIN_AMNET_API_VERSION = 1;
interface AMCardInRequest { interface AMCardInRequest {
cardId: string; cardId: string;
} physicalCardIDm?: string;
interface AMServerInfoResponse {
serverAddress: string;
serverName: string;
apiVersion: number;
gameId: string;
} }
/**
* Server connection info (api version, address, etc.)
*/
interface AMServerConnectionInfo {
apiVersion: number;
serverAddress: string;
}
/**
* User-definable server metadata
*/
interface AMServerMetadata {
serverName: string;
gameId?: string | null | undefined;
}
/**
* Metrics about the current game session
*/
interface AMServerInstanceMetrics {
timeSinceLastPoll?: number;
sessionUptime?: number;
}
/**
* The response from an AMNet server's info endpoint
*/
type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
/** /**
* Performs the card-in request to the specified server * Performs the card-in request to the specified server
* @param serverAddress The origin address of the server to perform the request against * @param server The server to perform the request against
* @param request The card-in request to send * @param request The card-in request to send
* @param signal The abort signal to use for the request * @param signal The abort signal to use for the request
*/ */
declare function performCardIn(serverAddress: string, request: AMCardInRequest, signal: AbortSignal): Promise<boolean>; declare function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal): Promise<boolean>;
/** /**
* Attempts to connect to the specified server and fetch its info * Attempts to connect to the specified server and fetch its info
* @param serverAddress The origin address of the server to connect to * @param serverAddress The origin address of the server to connect to
* @param abort The abort signal to use for the request (for cancellation) * @param abort The abort signal to use for the request (for cancellation)
*/ */
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfoResponse | null>; declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
declare const CARD_ID_REGEX: RegExp; declare const CARD_ID_REGEX: RegExp;
declare const AMNET_COMPANY_ID = 597;
declare function generateCardNumber(): string; declare function generateCardNumber(): string;
/** /**
* Validates the provided card id by known parameters * Validates the provided card id by known parameters
@@ -33,22 +58,17 @@ declare function generateCardNumber(): string;
*/ */
declare function validateCardId(id: string): boolean; declare function validateCardId(id: string): boolean;
declare enum GameIds { /**
/** * Get the game name from the game identifier (4-letter code)
* Initial D The Arcade * @param id
*/ */
IDAC = "SDGT", declare function getGameName(id: string): string | null;
/**
* Chunithm (Paradise Lost)
*/
Chuni = "SDBT",
/**
* Chunithm NEW onwards
*/
Chusan = "SDHD",
ONGEKI = "SDDT",
maimaiDX = "SDEZ",
DivaFT = "SBZV"
}
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerInfoResponse, CARD_ID_REGEX, GameIds, fetchServerInfo, generateCardNumber, performCardIn, validateCardId }; type DecodedAccessCode = {
revision: number;
serial: number;
};
declare function decodeAccessCode(code: string): DecodedAccessCode | null;
declare function encodeAccessCode(company: number, serial: number, rev?: number): string;
export { type AMCardInRequest, AMNET_API_VERSION, AMNET_COMPANY_ID, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
+165 -73
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+147 -69
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+458 -745
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -16,7 +16,12 @@
".": "./dist/index.mjs" ".": "./dist/index.mjs"
}, },
"devDependencies": { "devDependencies": {
"tsup": "^8.2.4", "@types/lodash": "^4.17.21",
"typescript": "^5.5.4" "tsup": "^8.5.1",
"typescript": "^5.9.3"
},
"dependencies": {
"big-integer": "^1.6.52",
"lodash": "^4.17.21"
} }
} }
+48 -12
View File
@@ -3,26 +3,58 @@
*/ */
export const AMNET_API_VERSION = 1; export const AMNET_API_VERSION = 1;
/**
* The minimum version of the AMNet API this library supports
*/
export const MIN_AMNET_API_VERSION = 1;
export interface AMCardInRequest { export interface AMCardInRequest {
cardId: string; cardId: string;
} physicalCardIDm?: string;
export interface AMServerInfoResponse {
serverAddress: string;
serverName: string;
apiVersion: number;
gameId: string;
} }
/**
* Server connection info (api version, address, etc.)
*/
export interface AMServerConnectionInfo {
apiVersion: number;
serverAddress: string;
}
/**
* User-definable server metadata
*/
export interface AMServerMetadata {
serverName: string;
gameId?: string | null | undefined;
}
/**
* Metrics about the current game session
*/
export interface AMServerInstanceMetrics {
timeSinceLastPoll?: number;
sessionUptime?: number;
}
/**
* The response from an AMNet server's info endpoint
*/
export type AMServerInfo = AMServerMetadata & AMServerConnectionInfo & AMServerInstanceMetrics;
/** /**
* Performs the card-in request to the specified server * Performs the card-in request to the specified server
* @param serverAddress The origin address of the server to perform the request against * @param server The server to perform the request against
* @param request The card-in request to send * @param request The card-in request to send
* @param signal The abort signal to use for the request * @param signal The abort signal to use for the request
*/ */
export async function performCardIn(serverAddress: string, request: AMCardInRequest, signal: AbortSignal) { export async function performCardIn(server: AMServerConnectionInfo, request: AMCardInRequest, signal: AbortSignal): Promise<boolean> {
if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {
throw new Error("Unsupported server API version");
}
try { try {
const response = await fetch(`${new URL(serverAddress).origin}/amnet/signin`, { const response = await fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
@@ -42,16 +74,20 @@ export async function performCardIn(serverAddress: string, request: AMCardInRequ
* @param serverAddress The origin address of the server to connect to * @param serverAddress The origin address of the server to connect to
* @param abort The abort signal to use for the request (for cancellation) * @param abort The abort signal to use for the request (for cancellation)
*/ */
export async function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfoResponse | null> { export async function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null> {
try { try {
const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, { const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, {
signal: abort signal: abort
}); });
if (!response.ok) {
return null;
}
return { return {
...await response.json(), ...await response.json(),
serverAddress: serverAddress serverAddress: serverAddress
} satisfies AMServerInfoResponse; } satisfies AMServerMetadata;
} catch { } catch {
return null; return null;
} }
File diff suppressed because one or more lines are too long
+6 -17
View File
@@ -1,23 +1,12 @@
import {encodeAccessCode} from "./card-encoding";
export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i; export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;
export const AMNET_COMPANY_ID = 597;
export function generateCardNumber(): string { export function generateCardNumber(): string {
let cardId = ''; // serial between 5000 and 99999999999
const serial = Math.floor(Math.random() * 99999994999) + 5000;
while (true) { return encodeAccessCode(AMNET_COMPANY_ID, serial, 0);
// don't start with a 3
const firstDigit = Math.floor(Math.random() * 10);
if (firstDigit !== 3) {
cardId = firstDigit.toString();
break;
}
}
for (let i = 0; i < 19; i++) {
cardId += Math.floor(Math.random() * 10).toString();
}
return cardId;
} }
/** /**
+38 -16
View File
@@ -1,20 +1,42 @@
export enum GameIds { /**
/** * Get the game name from the game identifier (4-letter code)
* Initial D The Arcade * @param id
*/ */
IDAC = "SDGT", export function getGameName(id: string): string | null {
switch (id?.toUpperCase()) {
case "SDGT": // IDAC JPN
case "SDJJ": // IDAC CHN
return "Initial D The Arcade";
/** case "SDGS": // CHUNITHM INTL
* Chunithm (Paradise Lost) case "SDHD": // CHUNITHM NEW
*/ case "SDHJ": // CHUNITHM CHN
Chuni = "SDBT", case "SDBT": // CHUNITHM
return "CHUNITHM";
/** case "SDEZ": // maimai DX
* Chunithm NEW onwards case "SDGA": // maimai DX INTL
*/ case "SDGB": // maimai DX CHN
Chusan = "SDHD", return "maimai DX";
ONGEKI = "SDDT", case "SDDT":
maimaiDX = "SDEZ", return "O.N.G.E.K.I.";
DivaFT = "SBZV"
case "SDED":
return "Card Maker";
case "SBZV":
return "Project DIVA Future Tone";
case "SDDS":
return "SEGA World Driver Championship";
case "SDFE": // Wacca JPN
case "SDHN": // Wacca Ver.2/2R CHN
case "SDJE": // Wacca Ver.3 CHN
return "Wacca";
default:
return null;
}
} }
+2
View File
@@ -1,3 +1,5 @@
export * from './api'; export * from './api';
export * from './card'; export * from './card';
export * from './gameIds'; export * from './gameIds';
export {decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode} from './card-encoding';
+7 -6
View File
@@ -1,14 +1,15 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2016", "baseUrl": ".",
"target": "ES2017",
"module": "CommonJS", "module": "CommonJS",
"noEmit": true, "noEmit": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"baseUrl": "."
}, },
"include": ["src/**/*.ts"] "include": [
"src/**/*.ts"
]
} }
+2434 -2252
View File
File diff suppressed because it is too large Load Diff
+39 -38
View File
@@ -10,48 +10,49 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.4",
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.2.8",
"amnet-shared": "file:../amnet-shared", "amnet-shared": "file:../amnet-shared",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"detectincognitojs": "^1.3.5", "core-js": "^3.47.0",
"dexie": "^4.0.8", "detectincognitojs": "^1.6.2",
"dexie-react-hooks": "^1.1.7", "dexie": "^4.2.1",
"lucide-react": "^0.427.0", "dexie-react-hooks": "^4.2.0",
"react": "^18.3.1", "lucide-react": "^0.562.0",
"react-dom": "^18.3.1", "react": "^19.2.3",
"react-hook-form": "^7.52.2", "react-dom": "^19.2.3",
"react-router-dom": "^6.26.0", "react-hook-form": "^7.69.0",
"react-timeago": "^7.2.0", "react-router-dom": "^7.11.0",
"tailwind-merge": "^2.4.0", "react-timeago": "^8.3.0",
"tailwindcss-animate": "^1.0.7", "tailwind-merge": "^3.4.0",
"vaul": "^0.9.1", "tw-animate-css": "^1.4.0",
"zod": "^3.23.8" "vaul": "^1.1.2",
"zod": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.8.0", "@eslint/js": "^9.23.0",
"@types/node": "^22.2.0", "@tailwindcss/postcss": "^4.1.18",
"@types/react": "^18.3.3", "@types/node": "^24.0.0",
"@types/react-dom": "^18.3.0", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-timeago": "^4.1.7", "@types/react-timeago": "^4.1.7",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^4.2.2",
"autoprefixer": "^10.4.20", "eslint": "^9.23.0",
"eslint": "^9.8.0", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react-refresh": "^0.4.9", "globals": "^16.5.0",
"globals": "^15.9.0", "postcss": "^8.5.6",
"postcss": "^8.4.41", "tailwindcss": "^4.1.18",
"tailwindcss": "^3.4.9", "typescript": "^5.9.3",
"typescript": "^5.5.3", "typescript-eslint": "^8.51.0",
"typescript-eslint": "^8.0.0", "vite": "^7.3.0"
"vite": "^5.4.0"
} }
} }
+1 -2
View File
@@ -1,6 +1,5 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {},
autoprefixer: {},
}, },
} }
@@ -0,0 +1,10 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "moe.ppc.amnet",
"sha256_cert_fingerprints": ["9E:4C:42:6F:9E:27:85:62:E5:DB:75:23:60:4A:B9:8D:B5:39:C8:1D:AD:FE:8E:D3:DC:BA:6E:55:A9:C6:59:05"]
}
}
]
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"id": "?pwa-home=1", "id": "?pwa-home=1",
"name": "AimeNet", "name": "AMNet",
"description": "Aime Card Switcher", "description": "IC Card Switcher",
"start_url": "/", "start_url": "/",
"categories": [ "categories": [
"utilities", "utilities",
+8 -9
View File
@@ -8,20 +8,19 @@ import {AMCard, AMCardActionProps, AMCardDetail} from "@/components/am-card.tsx"
export function AimeCardEntry(props: AMCardActionProps & { card: AimeCard, children?: Readonly<React.ReactNode> }) { export function AimeCardEntry(props: AMCardActionProps & { card: AimeCard, children?: Readonly<React.ReactNode> }) {
const isDesktop = useMediaQuery("(min-width: 512px)"); const isDesktop = useMediaQuery("(min-width: 512px)");
return ( return (
<AMCard title={props.card.name} actionIcon={props.actionIcon} actionClicked={props.actionClicked} actionDisabled={props.actionDisabled}> <AMCard title={props.card.name} actionIcon={props.actionIcon} actionClicked={props.actionClicked}
actionDisabled={props.actionDisabled}>
{props.card.lastUsed > 0 && ( {props.card.lastUsed > 0 && (
<AMCardDetail> <AMCardDetail icon={History}>
<History className="h-5 w-5"/>
<TimeAgo date={props.card.lastUsed}/> <TimeAgo date={props.card.lastUsed}/>
</AMCardDetail> </AMCardDetail>
)} )}
<AMCardDetail> <AMCardDetail icon={CreditCard} iconClicked={() => navigator.clipboard.writeText(props.card.id)}>
<CreditCard className="h-5 w-5 cursor-pointer" onClick={() => navigator.clipboard.writeText(props.card.id)}/> <code>
<code>{isDesktop {isDesktop
? props.card.id.match(/.{1,4}/g)?.join(" ") ? props.card.id.match(/.{1,4}/g)?.join(" ")
: `•••• ${props.card.id.substring(props.card.id.length - 4)}`} : `•••• ${props.card.id.substring(props.card.id.length - 4)}`}
</code> </code>
</AMCardDetail> </AMCardDetail>
</AMCard> </AMCard>
+6 -4
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import {ChevronRight} from "lucide-react"; import {ChevronRight, LucideIcon} from "lucide-react";
import {Card} from "@/components/ui/card.tsx"; import {Card} from "@/components/ui/card.tsx";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
@@ -18,12 +19,12 @@ export function AMCard(props: AMCardActionProps & { title: string, titlePerforms
onClick={() => props.titlePerformsAction && props.actionClicked ? props.actionClicked() : null}> onClick={() => props.titlePerformsAction && props.actionClicked ? props.actionClicked() : null}>
{props.title} {props.title}
</h4> </h4>
<div className="text-sm lg:text-md flex flex-auto flex-wrap gap-3"> <div className="text-sm lg:text-md flex flex-auto flex-wrap gap-4">
{props.children} {props.children}
</div> </div>
</div> </div>
{props.actionClicked && ( {props.actionClicked && (
<Button className="flex-shrink-0" variant="ghost" size="icon" disabled={props.actionDisabled} onClick={props.actionClicked}> <Button className="shrink-0" variant="ghost" size="icon" disabled={props.actionDisabled} onClick={props.actionClicked}>
{props.actionIcon ?? <ChevronRight className="h-4 w-4"/>} {props.actionIcon ?? <ChevronRight className="h-4 w-4"/>}
</Button> </Button>
)} )}
@@ -32,9 +33,10 @@ export function AMCard(props: AMCardActionProps & { title: string, titlePerforms
) )
} }
export function AMCardDetail(props: { children?: Readonly<React.ReactNode> }) { export function AMCardDetail(props: { icon: LucideIcon, iconClicked?: () => void | Promise<any>, children?: Readonly<React.ReactNode> }) {
return ( return (
<div className="inline-flex items-center max-w-full gap-2 text-gray-500 select-none md:select-text"> <div className="inline-flex items-center max-w-full gap-2 text-gray-500 select-none md:select-text">
{React.createElement(props.icon, {className: "h-5 w-5", onClick: props.iconClicked})}
{props.children} {props.children}
</div> </div>
) )
+1 -1
View File
@@ -5,7 +5,7 @@ export function AMHeader(props: { title: string, children?: Readonly<React.React
return ( return (
<> <>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<h2 className="text-4xl font-bold select-none flex-grow">{props.title}</h2> <h2 className="text-4xl font-bold select-none grow">{props.title}</h2>
{props.children} {props.children}
</div> </div>
<Separator/> <Separator/>
+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>)
}
@@ -98,10 +98,9 @@ function AimeCardCreationForm(props: { callback?: () => void }) {
<FormControl> <FormControl>
<Input placeholder="0000 0000 0000 0000 0000" {...field} /> <Input placeholder="0000 0000 0000 0000 0000" {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription className="select-none">
If you&apos;ve got a card, enter the matrix code here. If not, you can If you&apos;ve got a card, enter the matrix code here. If not, you can
<span className="font-bold hover:cursor-pointer ml-1" <span className="font-bold hover:cursor-pointer ml-1" onClick={() => form.setValue("id", generateCardNumber())}>
onClick={() => form.setValue("id", generateCardNumber())}>
generate a new one. generate a new one.
</span> </span>
</FormDescription> </FormDescription>
@@ -2,7 +2,7 @@ import {Loader} from "lucide-react";
export function LoadingIndicator() { export function LoadingIndicator() {
return ( return (
<div className="flex flex-grow gap-2 justify-center items-center text-gray-500 py-5"> <div className="flex grow gap-2 justify-center items-center text-gray-500 py-5">
<Loader className="h-8 w-8"/> <Loader className="h-8 w-8"/>
<span className="text-center">Loading...</span> <span className="text-center">Loading...</span>
</div> </div>
+76 -70
View File
@@ -10,37 +10,39 @@ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef< function AlertDialogOverlay({
React.ElementRef<typeof AlertDialogPrimitive.Overlay>, className,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
<AlertDialogPrimitive.Overlay return (
className={cn( <AlertDialogPrimitive.Overlay
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> )
)) }
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({ const AlertDialogHeader = ({
@@ -71,57 +73,61 @@ const AlertDialogFooter = ({
) )
AlertDialogFooter.displayName = "AlertDialogFooter" AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef< function AlertDialogTitle({
React.ElementRef<typeof AlertDialogPrimitive.Title>, className,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
<AlertDialogPrimitive.Title return (
ref={ref} <AlertDialogPrimitive.Title
className={cn("text-lg font-semibold", className)} className={cn("text-lg font-semibold", className)}
{...props} {...props}
/> />
)) )
}
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef< function AlertDialogDescription({
React.ElementRef<typeof AlertDialogPrimitive.Description>, className,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
<AlertDialogPrimitive.Description return (
ref={ref} <AlertDialogPrimitive.Description
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) )
}
AlertDialogDescription.displayName = AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef< function AlertDialogAction({
React.ElementRef<typeof AlertDialogPrimitive.Action>, className,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
<AlertDialogPrimitive.Action return (
ref={ref} <AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)} className={cn(buttonVariants(), className)}
{...props} {...props}
/> />
)) )
}
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef< function AlertDialogCancel({
React.ElementRef<typeof AlertDialogPrimitive.Cancel>, className,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
<AlertDialogPrimitive.Cancel return (
ref={ref} <AlertDialogPrimitive.Cancel
className={cn( className={cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0", "mt-2 sm:mt-0",
className className
)} )}
{...props} {...props}
/> />
)) )
}
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export { export {
+35 -31
View File
@@ -19,41 +19,45 @@ const alertVariants = cva(
} }
) )
const Alert = React.forwardRef< function Alert({
HTMLDivElement, className,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> variant,
>(({ className, variant, ...props }, ref) => ( ...props
<div }: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>) {
ref={ref} return (
role="alert" <div
className={cn(alertVariants({ variant }), className)} role="alert"
{...props} className={cn(alertVariants({ variant }), className)}
/> {...props}
)) />
)
}
Alert.displayName = "Alert" Alert.displayName = "Alert"
const AlertTitle = React.forwardRef< function AlertTitle({
HTMLParagraphElement, className,
React.HTMLAttributes<HTMLHeadingElement> ...props
>(({ className, ...props }, ref) => ( }: React.HTMLAttributes<HTMLHeadingElement>) {
<h5 return (
ref={ref} <h5
className={cn("mb-1 font-medium leading-none tracking-tight", className)} className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props} {...props}
/> />
)) )
}
AlertTitle.displayName = "AlertTitle" AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef< function AlertDescription({
HTMLParagraphElement, className,
React.HTMLAttributes<HTMLParagraphElement> ...props
>(({ className, ...props }, ref) => ( }: React.HTMLAttributes<HTMLParagraphElement>) {
<div return (
ref={ref} <div
className={cn("text-sm [&_p]:leading-relaxed", className)} className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} {...props}
/> />
)) )
}
AlertDescription.displayName = "AlertDescription" AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription }
+16 -13
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
@@ -39,18 +39,21 @@ export interface ButtonProps
asChild?: boolean asChild?: boolean
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( function Button({
({ className, variant, size, asChild = false, ...props }, ref) => { className,
const Comp = asChild ? Slot : "button" variant,
return ( size,
<Comp asChild = false,
className={cn(buttonVariants({ variant, size, className }))} ...props
ref={ref} }: ButtonProps) {
{...props} const Comp = asChild ? Slot : "button"
/> return (
) <Comp
} className={cn(buttonVariants({ variant, size, className }))}
) {...props}
/>
)
}
Button.displayName = "Button" Button.displayName = "Button"
export { Button, buttonVariants } export { Button, buttonVariants }
+69 -62
View File
@@ -2,78 +2,85 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const Card = React.forwardRef< function Card({
HTMLDivElement, className,
React.HTMLAttributes<HTMLDivElement> ...props
>(({ className, ...props }, ref) => ( }: React.HTMLAttributes<HTMLDivElement>) {
<div return (
ref={ref} <div
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "rounded-lg border bg-card text-card-foreground shadow-xs",
className className
)} )}
{...props} {...props}
/> />
)) )
}
Card.displayName = "Card" Card.displayName = "Card"
const CardHeader = React.forwardRef< function CardHeader({
HTMLDivElement, className,
React.HTMLAttributes<HTMLDivElement> ...props
>(({ className, ...props }, ref) => ( }: React.HTMLAttributes<HTMLDivElement>) {
<div return (
ref={ref} <div
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} {...props}
/> />
)) )
}
CardHeader.displayName = "CardHeader" CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef< function CardTitle({
HTMLParagraphElement, className,
React.HTMLAttributes<HTMLHeadingElement> ...props
>(({ className, ...props }, ref) => ( }: React.HTMLAttributes<HTMLHeadingElement>) {
<h3 return (
ref={ref} <h3
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", "text-2xl font-semibold leading-none tracking-tight",
className className
)} )}
{...props} {...props}
/> />
)) )
}
CardTitle.displayName = "CardTitle" CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef< function CardDescription({
HTMLParagraphElement, className,
React.HTMLAttributes<HTMLParagraphElement> ...props
>(({ className, ...props }, ref) => ( }: React.HTMLAttributes<HTMLParagraphElement>) {
<p return (
ref={ref} <p
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) )
}
CardDescription.displayName = "CardDescription" CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef< function CardContent({
HTMLDivElement, className,
React.HTMLAttributes<HTMLDivElement> ...props
>(({ className, ...props }, ref) => ( }: React.HTMLAttributes<HTMLDivElement>) {
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> return (
)) <div className={cn("p-6 pt-0", className)} {...props} />
)
}
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef< function CardFooter({
HTMLDivElement, className,
React.HTMLAttributes<HTMLDivElement> ...props
>(({ className, ...props }, ref) => ( }: React.HTMLAttributes<HTMLDivElement>) {
<div return (
ref={ref} <div
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0", className)}
{...props} {...props}
/> />
)) )
}
CardFooter.displayName = "CardFooter" CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+142 -127
View File
@@ -16,151 +16,166 @@ const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef< function ContextMenuSubTrigger({
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, className,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { inset,
inset?: boolean children,
} ...props
>(({ className, inset, children, ...props }, ref) => ( }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
<ContextMenuPrimitive.SubTrigger inset?: boolean
ref={ref} }) {
className={cn( return (
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", <ContextMenuPrimitive.SubTrigger
inset && "pl-8", className={cn(
className "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
)} inset && "pl-8",
{...props} className
> )}
{children} {...props}
<ChevronRight className="ml-auto h-4 w-4" /> >
</ContextMenuPrimitive.SubTrigger> {children}
)) <ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
)
}
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef< function ContextMenuSubContent({
React.ElementRef<typeof ContextMenuPrimitive.SubContent>, className,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
<ContextMenuPrimitive.SubContent return (
ref={ref} <ContextMenuPrimitive.SubContent
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className className
)} )}
{...props} {...props}
/> />
</ContextMenuPrimitive.Portal> )
)) }
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef< function ContextMenuItem({
React.ElementRef<typeof ContextMenuPrimitive.Item>, className,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { inset,
inset?: boolean ...props
} }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
>(({ className, inset, ...props }, ref) => ( inset?: boolean
<ContextMenuPrimitive.Item }) {
ref={ref} return (
className={cn( <ContextMenuPrimitive.Item
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className={cn(
inset && "pl-8", "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className inset && "pl-8",
)} className
{...props} )}
/> {...props}
)) />
)
}
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef< function ContextMenuCheckboxItem({
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, className,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> children,
>(({ className, children, checked, ...props }, ref) => ( checked,
<ContextMenuPrimitive.CheckboxItem ...props
ref={ref} }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
className={cn( return (
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", <ContextMenuPrimitive.CheckboxItem
className className={cn(
)} "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
checked={checked} className
{...props} )}
> checked={checked}
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> {...props}
<ContextMenuPrimitive.ItemIndicator> >
<Check className="h-4 w-4" /> <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
</ContextMenuPrimitive.ItemIndicator> <ContextMenuPrimitive.ItemIndicator>
</span> <Check className="h-4 w-4" />
{children} </ContextMenuPrimitive.ItemIndicator>
</ContextMenuPrimitive.CheckboxItem> </span>
)) {children}
</ContextMenuPrimitive.CheckboxItem>
)
}
ContextMenuCheckboxItem.displayName = ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef< function ContextMenuRadioItem({
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, className,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> children,
>(({ className, children, ...props }, ref) => ( ...props
<ContextMenuPrimitive.RadioItem }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
ref={ref} return (
className={cn( <ContextMenuPrimitive.RadioItem
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className={cn(
className "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
)} className
{...props} )}
> {...props}
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> >
<ContextMenuPrimitive.ItemIndicator> <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Circle className="h-2 w-2 fill-current" /> <ContextMenuPrimitive.ItemIndicator>
</ContextMenuPrimitive.ItemIndicator> <Circle className="h-2 w-2 fill-current" />
</span> </ContextMenuPrimitive.ItemIndicator>
{children} </span>
</ContextMenuPrimitive.RadioItem> {children}
)) </ContextMenuPrimitive.RadioItem>
)
}
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef< function ContextMenuLabel({
React.ElementRef<typeof ContextMenuPrimitive.Label>, className,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { inset,
inset?: boolean ...props
} }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
>(({ className, inset, ...props }, ref) => ( inset?: boolean
<ContextMenuPrimitive.Label }) {
ref={ref} return (
className={cn( <ContextMenuPrimitive.Label
"px-2 py-1.5 text-sm font-semibold text-foreground", className={cn(
inset && "pl-8", "px-2 py-1.5 text-sm font-semibold text-foreground",
className inset && "pl-8",
)} className
{...props} )}
/> {...props}
)) />
)
}
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef< function ContextMenuSeparator({
React.ElementRef<typeof ContextMenuPrimitive.Separator>, className,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
<ContextMenuPrimitive.Separator return (
ref={ref} <ContextMenuPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-border", className)} className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} {...props}
/> />
)) )
}
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({ const ContextMenuShortcut = ({
+61 -56
View File
@@ -12,43 +12,46 @@ const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef< function DialogOverlay({
React.ElementRef<typeof DialogPrimitive.Overlay>, className,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
<DialogPrimitive.Overlay return (
ref={ref} <DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
> />
{children} )
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> }
<X className="h-4 w-4" /> DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
<span className="sr-only">Close</span>
</DialogPrimitive.Close> function DialogContent({
</DialogPrimitive.Content> className,
</DialogPortal> children,
)) ...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ const DialogHeader = ({
@@ -79,31 +82,33 @@ const DialogFooter = ({
) )
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef< function DialogTitle({
React.ElementRef<typeof DialogPrimitive.Title>, className,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof DialogPrimitive.Title>) {
<DialogPrimitive.Title return (
ref={ref} <DialogPrimitive.Title
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-semibold leading-none tracking-tight",
className className
)} )}
{...props} {...props}
/> />
)) )
}
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef< function DialogDescription({
React.ElementRef<typeof DialogPrimitive.Description>, className,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof DialogPrimitive.Description>) {
<DialogPrimitive.Description return (
ref={ref} <DialogPrimitive.Description
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) )
}
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName
export { export {
+57 -52
View File
@@ -20,37 +20,40 @@ const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef< function DrawerOverlay({
React.ElementRef<typeof DrawerPrimitive.Overlay>, className,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
<DrawerPrimitive.Overlay return (
ref={ref} <DrawerPrimitive.Overlay
className={cn("fixed inset-0 z-50 bg-black/80", className)} className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props} {...props}
/> />
)) )
}
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef< function DrawerContent({
React.ElementRef<typeof DrawerPrimitive.Content>, className,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> children,
>(({ className, children, ...props }, ref) => ( ...props
<DrawerPortal> }: React.ComponentProps<typeof DrawerPrimitive.Content>) {
<DrawerOverlay /> return (
<DrawerPrimitive.Content <DrawerPortal>
ref={ref} <DrawerOverlay />
className={cn( <DrawerPrimitive.Content
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", className={cn(
className "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
)} className
{...props} )}
> {...props}
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> >
{children} <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
</DrawerPrimitive.Content> {children}
</DrawerPortal> </DrawerPrimitive.Content>
)) </DrawerPortal>
)
}
DrawerContent.displayName = "DrawerContent" DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({ const DrawerHeader = ({
@@ -75,31 +78,33 @@ const DrawerFooter = ({
) )
DrawerFooter.displayName = "DrawerFooter" DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef< function DrawerTitle({
React.ElementRef<typeof DrawerPrimitive.Title>, className,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
<DrawerPrimitive.Title return (
ref={ref} <DrawerPrimitive.Title
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-semibold leading-none tracking-tight",
className className
)} )}
{...props} {...props}
/> />
)) )
}
DrawerTitle.displayName = DrawerPrimitive.Title.displayName DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef< function DrawerDescription({
React.ElementRef<typeof DrawerPrimitive.Description>, className,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof DrawerPrimitive.Description>) {
<DrawerPrimitive.Description return (
ref={ref} <DrawerPrimitive.Description
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) )
}
DrawerDescription.displayName = DrawerPrimitive.Description.displayName DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export { export {
+26 -30
View File
@@ -70,46 +70,43 @@ const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue {} as FormItemContextValue
) )
const FormItem = React.forwardRef< function FormItem({
HTMLDivElement, className,
React.HTMLAttributes<HTMLDivElement> ...props
>(({ className, ...props }, ref) => { }: React.HTMLAttributes<HTMLDivElement>) {
const id = React.useId() const id = React.useId()
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} /> <div className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider> </FormItemContext.Provider>
) )
}) }
FormItem.displayName = "FormItem" FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef< function FormLabel({
React.ElementRef<typeof LabelPrimitive.Root>, className,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> ...props
>(({ className, ...props }, ref) => { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField() const { error, formItemId } = useFormField()
return ( return (
<Label <Label
ref={ref}
className={cn(error && "text-destructive", className)} className={cn(error && "text-destructive", className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) )
}) }
FormLabel.displayName = "FormLabel" FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef< function FormControl({
React.ElementRef<typeof Slot>, ...props
React.ComponentPropsWithoutRef<typeof Slot> }: React.ComponentProps<typeof Slot>) {
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return ( return (
<Slot <Slot
ref={ref}
id={formItemId} id={formItemId}
aria-describedby={ aria-describedby={
!error !error
@@ -120,30 +117,30 @@ const FormControl = React.forwardRef<
{...props} {...props}
/> />
) )
}) }
FormControl.displayName = "FormControl" FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef< function FormDescription({
HTMLParagraphElement, className,
React.HTMLAttributes<HTMLParagraphElement> ...props
>(({ className, ...props }, ref) => { }: React.HTMLAttributes<HTMLParagraphElement>) {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField()
return ( return (
<p <p
ref={ref}
id={formDescriptionId} id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) )
}) }
FormDescription.displayName = "FormDescription" FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef< function FormMessage({
HTMLParagraphElement, className,
React.HTMLAttributes<HTMLParagraphElement> children,
>(({ className, children, ...props }, ref) => { ...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children const body = error ? String(error?.message) : children
@@ -153,7 +150,6 @@ const FormMessage = React.forwardRef<
return ( return (
<p <p
ref={ref}
id={formMessageId} id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)} className={cn("text-sm font-medium text-destructive", className)}
{...props} {...props}
@@ -161,7 +157,7 @@ const FormMessage = React.forwardRef<
{body} {body}
</p> </p>
) )
}) }
FormMessage.displayName = "FormMessage" FormMessage.displayName = "FormMessage"
export { export {
+16 -15
View File
@@ -5,21 +5,22 @@ import { cn } from "@/lib/utils"
export interface InputProps export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {} extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>( function Input({
({ className, type, ...props }, ref) => { className,
return ( type,
<input ...props
type={type} }: InputProps) {
className={cn( return (
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", <input
className type={type}
)} className={cn(
ref={ref} "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
{...props} className
/> )}
) {...props}
} />
) )
}
Input.displayName = "Input" Input.displayName = "Input"
export { Input } export { Input }
+11 -11
View File
@@ -8,17 +8,17 @@ const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
) )
const Label = React.forwardRef< function Label({
React.ElementRef<typeof LabelPrimitive.Root>, className,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & ...props
VariantProps<typeof labelVariants> }: React.ComponentProps<typeof LabelPrimitive.Root>) {
>(({ className, ...props }, ref) => ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
ref={ref} className={cn(labelVariants(), className)}
className={cn(labelVariants(), className)} {...props}
{...props} />
/> )
)) }
Label.displayName = LabelPrimitive.Root.displayName Label.displayName = LabelPrimitive.Root.displayName
export { Label } export { Label }
+9 -11
View File
@@ -3,27 +3,25 @@ import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const Separator = React.forwardRef< function Separator({
React.ElementRef<typeof SeparatorPrimitive.Root>, className,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> orientation = "horizontal",
>( decorative = true,
( ...props
{ className, orientation = "horizontal", decorative = true, ...props }, }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
ref return (
) => (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
ref={ref}
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border", "shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className className
)} )}
{...props} {...props}
/> />
) )
) }
Separator.displayName = SeparatorPrimitive.Root.displayName Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator } export { Separator }
+74 -70
View File
@@ -7,23 +7,24 @@ import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef< function ToastViewport({
React.ElementRef<typeof ToastPrimitives.Viewport>, className,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof ToastPrimitives.Viewport>) {
<ToastPrimitives.Viewport return (
ref={ref} <ToastPrimitives.Viewport
className={cn( className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", "fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className className
)} )}
{...props} {...props}
/> />
)) )
}
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva( const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{ {
variants: { variants: {
variant: { variant: {
@@ -38,76 +39,79 @@ const toastVariants = cva(
} }
) )
const Toast = React.forwardRef< function Toast({
React.ElementRef<typeof ToastPrimitives.Root>, className,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & variant,
VariantProps<typeof toastVariants> ...props
>(({ className, variant, ...props }, ref) => { }: React.ComponentProps<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>) {
return ( return (
<ToastPrimitives.Root <ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
) )
}) }
Toast.displayName = ToastPrimitives.Root.displayName Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef< function ToastAction({
React.ElementRef<typeof ToastPrimitives.Action>, className,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof ToastPrimitives.Action>) {
<ToastPrimitives.Action return (
ref={ref} <ToastPrimitives.Action
className={cn( className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground focus:group-[.destructive]:ring-destructive",
className className
)} )}
{...props} {...props}
/> />
)) )
}
ToastAction.displayName = ToastPrimitives.Action.displayName ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef< function ToastClose({
React.ElementRef<typeof ToastPrimitives.Close>, className,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof ToastPrimitives.Close>) {
<ToastPrimitives.Close return (
ref={ref} <ToastPrimitives.Close
className={cn( className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-hidden focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-50 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600",
className className
)} )}
toast-close="" toast-close=""
{...props} {...props}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)) )
}
ToastClose.displayName = ToastPrimitives.Close.displayName ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef< function ToastTitle({
React.ElementRef<typeof ToastPrimitives.Title>, className,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof ToastPrimitives.Title>) {
<ToastPrimitives.Title return (
ref={ref} <ToastPrimitives.Title
className={cn("text-sm font-semibold", className)} className={cn("text-sm font-semibold", className)}
{...props} {...props}
/> />
)) )
}
ToastTitle.displayName = ToastPrimitives.Title.displayName ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef< function ToastDescription({
React.ElementRef<typeof ToastPrimitives.Description>, className,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof ToastPrimitives.Description>) {
<ToastPrimitives.Description return (
ref={ref} <ToastPrimitives.Description
className={cn("text-sm opacity-90", className)} className={cn("text-sm opacity-90", className)}
{...props} {...props}
/> />
)) )
}
ToastDescription.displayName = ToastPrimitives.Description.displayName ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
+16 -14
View File
@@ -9,20 +9,22 @@ const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef< function TooltipContent({
React.ElementRef<typeof TooltipPrimitive.Content>, className,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> sideOffset = 4,
>(({ className, sideOffset = 4, ...props }, ref) => ( ...props
<TooltipPrimitive.Content }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
ref={ref} return (
sideOffset={sideOffset} <TooltipPrimitive.Content
className={cn( sideOffset={sideOffset}
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className={cn(
className "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
)} className
{...props} )}
/> {...props}
)) />
)
}
TooltipContent.displayName = TooltipPrimitive.Content.displayName TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+21 -9
View File
@@ -1,19 +1,31 @@
import * as React from "react" import * as React from "react";
export function useMediaQuery(query: string) { export function useMediaQuery(query: string) {
const [value, setValue] = React.useState(false) const [value, setValue] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
function onChange(event: MediaQueryListEvent) { function onChange(event: MediaQueryListEvent) {
setValue(event.matches) setValue(event.matches);
} }
const result = matchMedia(query) const result = matchMedia(query);
result.addEventListener("change", onChange)
setValue(result.matches)
return () => result.removeEventListener("change", onChange) // addEventListener isn't available on older versions of safari
}, [query]) if (result.addEventListener) {
result.addEventListener("change", onChange);
} else {
result.addListener(onChange);
}
setValue(result.matches);
return value return () => {
if (result.removeEventListener) {
result.removeEventListener("change", onChange);
} else {
result.removeListener(onChange);
}
};
}, [query]);
return value;
} }
+136 -55
View File
@@ -1,62 +1,143 @@
@tailwind base; @import 'tailwindcss';
@tailwind components; @import 'tw-animate-css';
@tailwind utilities;
@custom-variant dark (&:is(.dark *));
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1400px) {
max-width: 1400px;
}
}
@theme inline {
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base { @layer base {
:root { *,
--background: 0 0% 100%; ::after,
--foreground: 222.2 84% 4.9%; ::before,
--card: 0 0% 100%; ::backdrop,
--card-foreground: 222.2 84% 4.9%; ::file-selector-button {
--popover: 0 0% 100%; border-color: var(--color-gray-200, currentcolor);
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
} }
}
.dark { :root {
--background: 222.2 84% 4.9%; --background: hsl(0 0% 100%);
--foreground: 210 40% 98%; --foreground: hsl(222.2 84% 4.9%);
--card: 222.2 84% 4.9%; --card: hsl(0 0% 100%);
--card-foreground: 210 40% 98%; --card-foreground: hsl(222.2 84% 4.9%);
--popover: 222.2 84% 4.9%; --popover: hsl(0 0% 100%);
--popover-foreground: 210 40% 98%; --popover-foreground: hsl(222.2 84% 4.9%);
--primary: 210 40% 98%; --primary: hsl(222.2 47.4% 11.2%);
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: hsl(210 40% 98%);
--secondary: 217.2 32.6% 17.5%; --secondary: hsl(210 40% 96.1%);
--secondary-foreground: 210 40% 98%; --secondary-foreground: hsl(222.2 47.4% 11.2%);
--muted: 217.2 32.6% 17.5%; --muted: hsl(210 40% 96.1%);
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: hsl(215.4 16.3% 46.9%);
--accent: 217.2 32.6% 17.5%; --accent: hsl(210 40% 96.1%);
--accent-foreground: 210 40% 98%; --accent-foreground: hsl(222.2 47.4% 11.2%);
--destructive: 0 62.8% 30.6%; --destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: 210 40% 98%; --destructive-foreground: hsl(210 40% 98%);
--border: 217.2 32.6% 17.5%; --border: hsl(214.3 31.8% 91.4%);
--input: 217.2 32.6% 17.5%; --input: hsl(214.3 31.8% 91.4%);
--ring: 212.7 26.8% 83.9%; --ring: hsl(222.2 84% 4.9%);
--chart-1: 220 70% 50%; --radius: 0.5rem;
--chart-2: 160 60% 45%; --chart-1: hsl(12 76% 61%);
--chart-3: 30 80% 55%; --chart-2: hsl(173 58% 39%);
--chart-4: 280 65% 60%; --chart-3: hsl(197 37% 24%);
--chart-5: 340 75% 55%; --chart-4: hsl(43 74% 66%);
} --chart-5: hsl(27 87% 67%);
}
.dark {
--background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
--card: hsl(222.2 84% 4.9%);
--card-foreground: hsl(210 40% 98%);
--popover: hsl(222.2 84% 4.9%);
--popover-foreground: hsl(210 40% 98%);
--primary: hsl(210 40% 98%);
--primary-foreground: hsl(222.2 47.4% 11.2%);
--secondary: hsl(217.2 32.6% 17.5%);
--secondary-foreground: hsl(210 40% 98%);
--muted: hsl(217.2 32.6% 17.5%);
--muted-foreground: hsl(215 20.2% 65.1%);
--accent: hsl(217.2 32.6% 17.5%);
--accent-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(217.2 32.6% 17.5%);
--input: hsl(217.2 32.6% 17.5%);
--ring: hsl(212.7 26.8% 83.9%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
} }
@layer base { @layer base {
+3 -3
View File
@@ -1,9 +1,9 @@
import Dexie, { type EntityTable } from 'dexie'; import Dexie, { type EntityTable } from 'dexie';
import {AMServerInfoResponse} from "amnet-shared"; import {AMServerConnectionInfo, AMServerMetadata} from "amnet-shared";
export const CARD_LIMIT = 15; export const CARD_LIMIT = 15;
interface AimeServer extends AMServerInfoResponse { interface AimeServer extends AMServerMetadata, AMServerConnectionInfo {
id: number; id: number;
lastConnected: number; lastConnected: number;
} }
@@ -19,7 +19,7 @@ const db = new Dexie('amnet_db') as Dexie & {
cards: EntityTable<AimeCard, 'id'>; cards: EntityTable<AimeCard, 'id'>;
}; };
db.version(1).stores({ db.version(2).stores({
servers: '++id, serverAddress', servers: '++id, serverAddress',
cards: 'id' cards: 'id'
}); });
+19 -3
View File
@@ -1,5 +1,10 @@
import './index.css' import './index.css'
// import polyfills from core-js for older browsers
import 'core-js/actual/promise';
import 'core-js/actual/symbol';
import 'core-js/actual/object/assign';
import {StrictMode} from "react"; import {StrictMode} from "react";
import {createRoot} from "react-dom/client"; import {createRoot} from "react-dom/client";
import {createHashRouter, Navigate, RouterProvider} from "react-router-dom"; import {createHashRouter, Navigate, RouterProvider} from "react-router-dom";
@@ -10,6 +15,7 @@ import {ThemeProvider} from "@/components/theme-provider.tsx";
import CardManager from "@/pages/card-manager.tsx"; import CardManager from "@/pages/card-manager.tsx";
import ServerCardIn from "@/pages/card-in-screen.tsx"; import ServerCardIn from "@/pages/card-in-screen.tsx";
import ServerListing from "@/pages/server-listing.tsx"; import ServerListing from "@/pages/server-listing.tsx";
import Privacy from "@/pages/privacy.tsx";
// iOS PWAs show the navigation bar when changing routes // iOS PWAs show the navigation bar when changing routes
// HashRouter doesn't have this issue as the URL never changes // HashRouter doesn't have this issue as the URL never changes
@@ -33,14 +39,24 @@ const router = createHashRouter([
path: x, path: x,
element: <CardManager/> element: <CardManager/>
}; };
}) }),
{
path: "/privacy",
element: <Privacy/>
}
]) ])
createRoot(document.getElementById('root')!).render( function App() {
<StrictMode> return (
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
<RouterProvider router={router}/> <RouterProvider router={router}/>
<Toaster/> <Toaster/>
</ThemeProvider> </ThemeProvider>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App/>
</StrictMode>, </StrictMode>,
) )
+45 -27
View File
@@ -1,10 +1,8 @@
import {useLiveQuery} from "dexie-react-hooks"; import {useLiveQuery} from "dexie-react-hooks";
import {useEffect, useMemo, useState} from "react"; import {useEffect, useMemo, useState} from "react";
import {useNavigate, useParams} from "react-router-dom"; import {useNavigate, useParams} from "react-router-dom";
import {CircleDot, CircleCheckBig, Home, Nfc, Wallet, WifiOff} from "lucide-react"; import {performCardIn, fetchServerInfo, getGameName, AMServerInfo} from "amnet-shared";
import {CircleDot, CircleCheckBig, Home, Nfc, Wallet, WifiOff, TvMinimalPlay} from "lucide-react";
import {AimeCard, AimeServer, db} from "@/lib/database";
import {performCardIn, fetchServerInfo} from "amnet-shared";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import {useToast} from "@/components/ui/use-toast"; import {useToast} from "@/components/ui/use-toast";
@@ -21,6 +19,8 @@ import {
AlertDialogTrigger AlertDialogTrigger
} from "@/components/ui/alert-dialog.tsx"; } from "@/components/ui/alert-dialog.tsx";
import {AimeCard, AimeServer, db} from "@/lib/database";
import {AMHeader} from "@/components/am-header"; import {AMHeader} from "@/components/am-header";
import {AMFooter} from "@/components/am-footer"; import {AMFooter} from "@/components/am-footer";
import {Separator} from "@/components/ui/separator"; import {Separator} from "@/components/ui/separator";
@@ -38,7 +38,7 @@ async function performCardInAction(card: AimeCard, server: AimeServer, setCardIn
const abort = new AbortController(); const abort = new AbortController();
setTimeout(() => abort.abort("timeout"), 10000); setTimeout(() => abort.abort("timeout"), 10000);
await performCardIn(server.serverAddress, {cardId: card.id}, abort.signal); await performCardIn(server, {cardId: card.id}, abort.signal);
await db.cards.update(card.id, {lastUsed: Date.now()}); await db.cards.update(card.id, {lastUsed: Date.now()});
toaster({ toaster({
@@ -60,15 +60,13 @@ async function performCardInAction(card: AimeCard, server: AimeServer, setCardIn
} }
} }
/* eslint-enable @typescript-eslint/no-explicit-any */
function CardList(props: { cards: ReadonlyArray<AimeCard> | null, cardSelected: (card: AimeCard) => void }) { function CardList(props: { cards: ReadonlyArray<AimeCard> | null, cardSelected: (card: AimeCard) => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
return (<> return (<>
<h4 className="text-2xl font-bold">{props.cards?.length ? "Cards" : "Additional Cards"}</h4> <h4 className="text-2xl font-bold">{props.cards?.length ? "Cards" : "Additional Cards"}</h4>
<div className="flex gap-2"> <div className="flex gap-2">
<CardCreationDialog className="flex-grow"/> <CardCreationDialog className="grow"/>
<Button onClick={() => navigate('/cards')} variant="outline" size="icon"> <Button onClick={() => navigate('/cards')} variant="outline" size="icon">
<Wallet className="h-4 w-4"/> <Wallet className="h-4 w-4"/>
</Button> </Button>
@@ -82,6 +80,39 @@ function CardList(props: { cards: ReadonlyArray<AimeCard> | null, cardSelected:
</>); </>);
} }
function AlertPanel(props: { server: AMServerInfo | null, connectionError: boolean, onReloadRequested: () => void}) {
if (!props.server) {
return <LoadingIndicator/>
}
if (props.connectionError) {
return <Alert variant="destructive">
<WifiOff className="h-4 w-4"/>
<AlertTitle>Connection Issue</AlertTitle>
<AlertDescription>
There was an issue connecting to <code
className="max-w-full text-wrap">{props.server.serverAddress}</code>.
Please check the game is running and <span className="underline cursor-pointer"
onClick={props.onReloadRequested}>try again</span>.
</AlertDescription>
</Alert>
}
if (props.server.timeSinceLastPoll === null) {
return <Alert variant="blank" className="text-violet-600 border-violet-600">
<TvMinimalPlay className="h-4 w-4"/>
<AlertTitle>Server Startup</AlertTitle>
<AlertDescription>
Scanning a card below may not have any effect until the service has fully started up. <span
className="underline cursor-pointer"
onClick={props.onReloadRequested}>reload this page</span> to perform another check.
</AlertDescription>
</Alert>
}
return <IncognitoStorageWarning/>
}
function ServerCardInPage() { function ServerCardInPage() {
const {toast} = useToast(); const {toast} = useToast();
const {serverId} = useParams<{ serverId: string }>(); const {serverId} = useParams<{ serverId: string }>();
@@ -98,6 +129,7 @@ function ServerCardInPage() {
const [connectionError, setConnectionError] = useState(false); const [connectionError, setConnectionError] = useState(false);
const shareUrl = useMemo(() => `${location.origin}/#/servers/add?address=${encodeURIComponent(server?.serverAddress ?? "")}`, [server]); const shareUrl = useMemo(() => `${location.origin}/#/servers/add?address=${encodeURIComponent(server?.serverAddress ?? "")}`, [server]);
const gameName = useMemo(() => server?.gameId ? getGameName(server.gameId) : null, [server]);
// select newest card // select newest card
useEffect(() => setSelectedCard(cards?.sort((a, b) => b.lastUsed - a.lastUsed)[0] ?? null), [cards]); useEffect(() => setSelectedCard(cards?.sort((a, b) => b.lastUsed - a.lastUsed)[0] ?? null), [cards]);
@@ -152,25 +184,12 @@ function ServerCardInPage() {
</Button> </Button>
</AMHeader> </AMHeader>
{!server && (<LoadingIndicator/>)} <AlertPanel server={server} connectionError={connectionError} onReloadRequested={() => setServerReloadCounter(serverReloadCounter + 1)}/>
{(server && !connectionError) && <IncognitoStorageWarning/>}
{(server && connectionError) && (
<Alert variant="destructive">
<WifiOff className="h-4 w-4"/>
<AlertTitle>Connection Issue</AlertTitle>
<AlertDescription>
There was an issue connecting to <code
className="max-w-full text-wrap">{server.serverAddress}</code>.
Please check the game is running and <span className="underline cursor-pointer"
onClick={() => setServerReloadCounter(serverReloadCounter + 1)}>try again</span>.
</AlertDescription>
</Alert>
)}
{server && ( {server && (
<div className="flex flex-col gap-4 w-full flex-grow"> <div className="flex flex-col gap-4 w-full grow">
{selectedCard && (<> {selectedCard && (<>
<h4 className="text-2xl font-bold">Selected Card</h4> <h4 className="text-2xl font-bold">{gameName ? `Play ${gameName}` : "Selected Card"}</h4>
<AimeCardEntry card={selectedCard}> <AimeCardEntry card={selectedCard}>
<Button size="icon" variant="ghost" disabled> <Button size="icon" variant="ghost" disabled>
@@ -178,7 +197,7 @@ function ServerCardInPage() {
</Button> </Button>
</AimeCardEntry> </AimeCardEntry>
<Button className="flex-grow-0 bg-green-600 mb-5" <Button className="grow-0 bg-green-600 mb-5"
disabled={(cardInStatus || connectionError || !server)} disabled={(cardInStatus || connectionError || !server)}
onClick={() => performCardInAction(selectedCard, server as AimeServer, setCardInStatus, toast)}> onClick={() => performCardInAction(selectedCard, server as AimeServer, setCardInStatus, toast)}>
{cardInStatus ? "Carding in..." : <>Card In <Nfc className="ml-2 h-4 w-4"/></>} {cardInStatus ? "Carding in..." : <>Card In <Nfc className="ml-2 h-4 w-4"/></>}
@@ -198,8 +217,7 @@ function ServerCardInPage() {
</div> </div>
)} )}
<AMFooter <AMFooter className="flex flex-col-reverse sm:flex-row sm:flex-wrap justify-center sm:justify-between sm:gap-2 gap-4">
className="flex flex-col-reverse sm:flex-row sm:flex-wrap justify-center sm:justify-between sm:gap-2 gap-4">
{server?.id && ( {server?.id && (
<div className="flex items-center justify-center sm:gap-5 gap-2"> <div className="flex items-center justify-center sm:gap-5 gap-2">
{location.protocol === "https:" && (<> {location.protocol === "https:" && (<>
+1 -1
View File
@@ -53,7 +53,7 @@ function CardManagerPage() {
</Button> </Button>
</AMHeader> </AMHeader>
<div className="flex flex-col gap-4 w-full flex-grow"> <div className="flex flex-col gap-4 w-full grow">
{cards ? <CardList cards={cards}/> : <LoadingIndicator/>} {cards ? <CardList cards={cards}/> : <LoadingIndicator/>}
</div> </div>
+39
View File
@@ -0,0 +1,39 @@
// Google Play forces a Privacy Policy even for closed tests...
import {Server} from "lucide-react";
import {useNavigate} from "react-router-dom";
import {Button} from "@/components/ui/button.tsx";
import {AMFooter} from "@/components/am-footer.tsx";
import {AMHeader} from "@/components/am-header.tsx";
function PrivacyPolicy() {
const navigate = useNavigate();
return (
<main className="flex flex-col gap-5 container min-h-screen py-10">
<AMHeader title="AMNet">
<Button variant="ghost" size={"default"} onClick={() => navigate('/')}>
<Server className="h-4 w-4 sm:mr-2"/>
<span className="hidden sm:block">Manage Servers</span>
</Button>
</AMHeader>
<div className="flex flex-col gap-4 w-full grow">
<h4 className="font-semibold text-2xl">Privacy</h4>
<p>The AMNet card switcher collects and stores the following information locally (on-device):</p>
<ul className="list-disc">
<li>Server Addresses</li>
<li>Server Information (including machine name, identifiers and metrics that are cached and updated on each load)</li>
<li>Card Names and Access Codes</li>
</ul>
<p>The card switcher does <strong>NOT</strong> send any data to external servers or services, except to perform the card-in action where only the card number is transmitted.</p>
</div>
<AMFooter/>
</main>
)
}
export default PrivacyPolicy;

Some files were not shown because too many files have changed in this diff Show More