├── .dockerignore
├── .github
├── ISSUE_TEMPLATE
│ ├── bug.yml
│ ├── config.yml
│ └── discord-title-configuration.yml
└── funding.yml
├── .gitignore
├── .gitlab-ci.yml
├── .vscode
├── generate-schemas.sh
├── launch.json
├── schema
│ ├── moon
│ │ ├── dailysummary.schema.json
│ │ └── monthlysummary.schema.json
│ ├── nooklink
│ │ └── newspaper.schema.json
│ ├── splatnet2
│ │ ├── coop-result.schema.json
│ │ ├── coop-summary.schema.json
│ │ ├── hero.schema.json
│ │ ├── ni.schema.json
│ │ ├── records.schema.json
│ │ ├── result.schema.json
│ │ ├── results-summary.schema.json
│ │ └── timeline.schema.json
│ └── splatnet3
│ │ ├── coop-result.schema.json
│ │ └── result.schema.json
└── settings.json
├── Dockerfile
├── LICENSE
├── README.md
├── bin
└── nxapi.js
├── docker-compose.yml
├── docs
├── cli.md
└── lib
│ ├── README.md
│ ├── coral.md
│ ├── index.md
│ ├── moon.md
│ ├── nooklink.md
│ ├── splatnet2.md
│ └── splatnet3.md
├── package-lock.json
├── package.json
├── resources
├── app.png
├── app
│ └── menu-icon.png
├── build
│ ├── app
│ │ ├── cli-linux.sh
│ │ ├── cli-macos.sh
│ │ └── deb
│ │ │ ├── postinst
│ │ │ └── postrm
│ ├── ci-main-version.js
│ └── ci-package-json.js
├── cli
│ └── fonts
│ │ ├── opensans-normal-400.ttf
│ │ └── opensans-normal-500.ttf
├── common
│ └── remote-config.json
├── discord-activity-splatoon3.png
├── discord-activity.png
├── docker-entrypoint.sh
├── menu-app.png
└── notification.png
├── rollup.config.js
├── src
├── api
│ ├── coral-types.ts
│ ├── coral.ts
│ ├── f.ts
│ ├── moon-types.ts
│ ├── moon.ts
│ ├── na.ts
│ ├── nooklink-types.ts
│ ├── nooklink.ts
│ ├── splatnet2-types.ts
│ ├── splatnet2-xrank.ts
│ ├── splatnet2.ts
│ ├── splatnet3-types.ts
│ ├── splatnet3.ts
│ ├── util.ts
│ └── znc-proxy.ts
├── app
│ ├── README.md
│ ├── app-entry.cts
│ ├── app-init.ts
│ ├── browser
│ │ ├── add-account-manual
│ │ │ └── index.tsx
│ │ ├── add-friend
│ │ │ └── index.tsx
│ │ ├── components
│ │ │ ├── button.tsx
│ │ │ ├── friend-code.tsx
│ │ │ ├── icons
│ │ │ │ ├── add-outline.tsx
│ │ │ │ ├── util.ts
│ │ │ │ └── warning.tsx
│ │ │ ├── index.ts
│ │ │ └── nintendo-switch-user.tsx
│ │ ├── constants.ts
│ │ ├── discord
│ │ │ └── index.tsx
│ │ ├── friend
│ │ │ └── index.tsx
│ │ ├── index.ts
│ │ ├── ipc.ts
│ │ ├── main
│ │ │ ├── discord-setup.tsx
│ │ │ ├── discord.tsx
│ │ │ ├── event.tsx
│ │ │ ├── friends.tsx
│ │ │ ├── index.tsx
│ │ │ ├── main.tsx
│ │ │ ├── section.tsx
│ │ │ ├── sidebar.tsx
│ │ │ ├── status-updates.tsx
│ │ │ ├── update.tsx
│ │ │ └── webservices.tsx
│ │ ├── preferences
│ │ │ └── index.tsx
│ │ ├── react-native-web.d.ts
│ │ └── util.tsx
│ ├── common
│ │ └── types.ts
│ ├── i18n
│ │ ├── index.ts
│ │ └── locale
│ │ │ ├── de-de.ts
│ │ │ ├── en-gb.ts
│ │ │ ├── es-es.ts
│ │ │ └── ja-jp.ts
│ ├── main
│ │ ├── app-menu.ts
│ │ ├── index.ts
│ │ ├── ipc.ts
│ │ ├── menu.ts
│ │ ├── monitor.ts
│ │ ├── na-auth.ts
│ │ ├── support.ts
│ │ ├── util.ts
│ │ ├── webservices.ts
│ │ └── windows.ts
│ ├── preload-webservice
│ │ ├── index.ts
│ │ ├── ipc.ts
│ │ ├── loading.ts
│ │ ├── quirks
│ │ │ ├── nooklink.ts
│ │ │ ├── splatnet2.ts
│ │ │ └── splatnet3.ts
│ │ └── znca-js-api.ts
│ └── preload
│ │ └── index.ts
├── cli-entry.ts
├── cli.ts
├── cli
│ ├── android-znca-api-server-frida.ts
│ ├── app.ts
│ ├── commands.ts
│ ├── nooklink
│ │ ├── commands.ts
│ │ ├── dump-newspapers.ts
│ │ ├── index.ts
│ │ ├── island.ts
│ │ ├── keyboard.ts
│ │ ├── newspaper.ts
│ │ ├── newspapers.ts
│ │ ├── post-reaction.ts
│ │ ├── reactions.ts
│ │ ├── user-token.ts
│ │ ├── user.ts
│ │ └── users.ts
│ ├── nso
│ │ ├── active-event.ts
│ │ ├── add-friend.ts
│ │ ├── announcements.ts
│ │ ├── auth.ts
│ │ ├── commands.ts
│ │ ├── friendcode.ts
│ │ ├── friends.ts
│ │ ├── http-server.ts
│ │ ├── index.ts
│ │ ├── lookup.ts
│ │ ├── notify.ts
│ │ ├── permissions.ts
│ │ ├── presence.ts
│ │ ├── token.ts
│ │ ├── user.ts
│ │ ├── webservices.ts
│ │ ├── webservicetoken.ts
│ │ └── znc-proxy-tokens.ts
│ ├── pctl
│ │ ├── auth.ts
│ │ ├── commands.ts
│ │ ├── daily-summaries.ts
│ │ ├── devices.ts
│ │ ├── dump-summaries.ts
│ │ ├── index.ts
│ │ ├── monthly-summaries.ts
│ │ ├── monthly-summary.ts
│ │ ├── settings.ts
│ │ ├── token.ts
│ │ └── user.ts
│ ├── presence-server.ts
│ ├── splatnet2
│ │ ├── battles.ts
│ │ ├── challenges.ts
│ │ ├── commands.ts
│ │ ├── dump-records.ts
│ │ ├── dump-results.ts
│ │ ├── hero.ts
│ │ ├── index.ts
│ │ ├── monitor.ts
│ │ ├── schedule.ts
│ │ ├── stages.ts
│ │ ├── token.ts
│ │ ├── user.ts
│ │ ├── weapons.ts
│ │ └── x-rank-seasons.ts
│ ├── splatnet3
│ │ ├── battles.ts
│ │ ├── commands.ts
│ │ ├── dump-album.ts
│ │ ├── dump-fests.ts
│ │ ├── dump-records.ts
│ │ ├── dump-results.ts
│ │ ├── festival.ts
│ │ ├── festivals.ts
│ │ ├── friends.ts
│ │ ├── index.ts
│ │ ├── monitor.ts
│ │ ├── schedule.ts
│ │ ├── token.ts
│ │ └── user.ts
│ ├── users.ts
│ └── util
│ │ ├── captureid.ts
│ │ ├── commands.ts
│ │ ├── decrypt-log-archive.ts
│ │ ├── discord-activity.ts
│ │ ├── discord-rpc.ts
│ │ ├── export-discord-titles.ts
│ │ ├── index.ts
│ │ ├── log-archive.ts
│ │ ├── presence-embed-render.ts
│ │ ├── presence-embed-server.ts
│ │ ├── remote-config.ts
│ │ ├── status.ts
│ │ ├── storage.ts
│ │ └── validate-discord-titles.ts
├── client
│ ├── coral.ts
│ ├── na.ts
│ ├── splatnet3.ts
│ ├── storage
│ │ ├── index.ts
│ │ └── local.ts
│ ├── users.ts
│ └── util.ts
├── common
│ ├── auth
│ │ ├── coral.ts
│ │ ├── moon.ts
│ │ ├── na.ts
│ │ ├── nooklink.ts
│ │ ├── splatnet2.ts
│ │ ├── splatnet3.ts
│ │ └── util.ts
│ ├── constants.ts
│ ├── globals.ts
│ ├── notify.ts
│ ├── presence-embed.ts
│ ├── presence.ts
│ ├── remote-config.ts
│ ├── splatnet2
│ │ ├── dump-records.ts
│ │ ├── dump-results.ts
│ │ └── monitor.ts
│ ├── status.ts
│ ├── update.ts
│ └── users.ts
├── discord
│ ├── monitor
│ │ └── splatoon3.ts
│ ├── rpc.ts
│ ├── titles.ts
│ ├── titles
│ │ ├── README.md
│ │ ├── capcom.ts
│ │ ├── concernedape.ts
│ │ ├── epicgames.ts
│ │ ├── index.ts
│ │ ├── mojang.ts
│ │ ├── nintendo.ts
│ │ ├── phoenix-labs.ts
│ │ ├── thatgamecompany.ts
│ │ └── the-pokémon-company.ts
│ ├── types.ts
│ └── util.ts
├── exports
│ ├── coral.ts
│ ├── index.ts
│ ├── moon.ts
│ ├── nintendo-account.ts
│ ├── nooklink.ts
│ ├── splatnet2.ts
│ └── splatnet3.ts
├── index.ts
└── util
│ ├── debug.ts
│ ├── errors.ts
│ ├── eventsource.ts
│ ├── http-server.ts
│ ├── http.ts
│ ├── jwt.ts
│ ├── loop.ts
│ ├── misc.ts
│ ├── net.ts
│ ├── product.ts
│ ├── storage.ts
│ ├── support.ts
│ ├── table.ts
│ ├── undici-proxy.ts
│ ├── useragent.ts
│ └── yargs.ts
└── tsconfig.json
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | data
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.yml:
--------------------------------------------------------------------------------
1 | name: Bug
2 | description: Report something that isn't working as expected
3 |
4 | labels:
5 | - bug
6 |
7 | body:
8 |
9 | - type: markdown
10 | attributes:
11 | value: |
12 | Please remember to search other issues before opening an issue, but make sure it is the same as the issue you have before commenting on other issues, for example make sure any error messages are the same.
13 |
14 | Not all issues are bugs - some error messages may indicate a different problem, for example, the error code `ENOTFOUND` may mean there is a problem with your network. Make sure your Internet connection is working and there are no [known service issues](https://nxapi-status.fancy.org.uk).
15 |
16 | Make sure you test the latest development build as the issue may have already been fixed: [macOS/Linux](https://gitlab.fancy.org.uk/samuel/nxapi/-/jobs/artifacts/main/browse/app?job=build-app), [Windows](https://gitlab.fancy.org.uk/samuel/nxapi/-/jobs/artifacts/main/browse/app?job=build-windows).
17 |
18 | > [!IMPORTANT]
19 | > The latest release of nxapi no longer works due to changes by Nintendo. If you are using nxapi 1.6.1 or before and do not update to the latest development build you will see an error message from the f-generation API as the `X-znca-Client-Version` header is not sent.
20 |
21 | - type: textarea
22 | id: what-you-did
23 | attributes:
24 | label: What did you try to do?
25 | description: |
26 | Describe the steps you took to reproduce the issue. You can upload screenshots or screen recordings. Please don't add links to files or take photos of your screen using a phone or camera.
27 | validations:
28 | required: true
29 |
30 | - type: textarea
31 | id: what-should-happen
32 | attributes:
33 | label: What should happen?
34 | description: |
35 | Describe what you expected to happen.
36 |
37 | - type: textarea
38 | id: what-did-happen
39 | attributes:
40 | label: What did happen?
41 | description: |
42 | Describe what actually happened instead of what you expected. You can upload screenshots or screen recordings. Please don't add links to files or take photos of your screen using a phone or camera.
43 | validations:
44 | required: true
45 |
46 | - type: textarea
47 | id: other-information
48 | attributes:
49 | label: Other information
50 | description: |
51 | Include any other relevant information, such as when the problem started, or if it only happens sometimes, and information about your system such as your operating system and nxapi version.
52 |
53 | You can upload log files from nxapi by selecting export logs in the help menu. On Windows/Linux, press the Alt key in the main window to temporarily show the application menus. This will generate a file containing nxapi's debug logs, which are stored on your computer for 14 days. Log files may contain sensitive information, but the export logs option encrypts the saved logs so only @samuelthomas2774 can read them. You can also use the `nxapi util log-archive` command to generate an encrypted log archive.
54 | validations:
55 | required: true
56 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
2 |
3 | contact_links:
4 |
5 | - name: Discussions
6 | about: Suggest new features and ask questions.
7 | url: https://github.com/samuelthomas2774/nxapi/discussions
8 |
9 | - name: Discord
10 | about: https://discord.com/invite/4D82rFkXRv
11 | url: https://discord.com/invite/4D82rFkXRv
12 |
13 | - name: nxapi Service Status
14 | about: Check the status of services used by nxapi. Issues may take a few minutes to appear here.
15 | url: https://nxapi-status.fancy.org.uk
16 |
--------------------------------------------------------------------------------
/.github/funding.yml:
--------------------------------------------------------------------------------
1 | ko_fi: samuelelliott
2 | custom:
3 | - https://www.buymeacoffee.com/samuelelliott
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | data
4 | docker-compose.override.yml
5 | .env
6 | .vscode/schema/generated
7 |
--------------------------------------------------------------------------------
/.vscode/generate-schemas.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | mkdir -p .vscode/schema/generated
4 |
5 | npx ts-json-schema-generator --path src/api/moon-types.ts --expose all --no-type-check > .vscode/schema/generated/moon-types.schema.json
6 | npx ts-json-schema-generator --path src/api/splatnet2-types.ts --expose all --no-type-check > .vscode/schema/generated/splatnet2-types.schema.json
7 | npx ts-json-schema-generator --path src/api/nooklink-types.ts --expose all --no-type-check > .vscode/schema/generated/nooklink-types.schema.json
8 | npx ts-json-schema-generator --path src/api/splatnet3-types.ts --expose all --no-type-check > .vscode/schema/generated/splatnet3-types.schema.json
9 |
10 | npx ts-json-schema-generator --path src/common/remote-config.ts --type NxapiRemoteConfig --no-type-check > .vscode/schema/generated/remote-config.schema.json
11 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Debug Electron app",
9 | "type": "node",
10 | "request": "launch",
11 | "cwd": "${workspaceFolder}",
12 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
13 | "osx": {
14 | "runtimeExecutable": "${workspaceFolder}/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron"
15 | },
16 | "windows": {
17 | "runtimeExecutable": "${workspaceFolder}/node_modules/electron/dist/electron.exe"
18 | },
19 | "args": [
20 | "dist/app/app-entry.cjs"
21 | ],
22 | "outputCapture": "std",
23 | "env": {
24 | "DEBUG": "*,-express:*,-body-parser:*",
25 | "DEBUG_COLORS": "1",
26 | "FORCE_COLOR": "3"
27 | },
28 | "envFile": "${workspaceFolder}/.env"
29 | },
30 | {
31 | "name": "Coral API proxy",
32 | "type": "node",
33 | "request": "launch",
34 | "cwd": "${workspaceFolder}",
35 | "program": "bin/nxapi.js",
36 | "args": [
37 | "nso",
38 | "http-server",
39 | "--listen",
40 | "[::1]:8080",
41 | "--no-require-token"
42 | ],
43 | "outputCapture": "std",
44 | "env": {
45 | "DEBUG": "*,-express:*,-body-parser:*",
46 | "DEBUG_COLORS": "1",
47 | "FORCE_COLOR": "3"
48 | },
49 | "envFile": "${workspaceFolder}/.env"
50 | }
51 | ]
52 | }
53 |
--------------------------------------------------------------------------------
/.vscode/schema/moon/dailysummary.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "$ref": "../generated/moon-types.schema.json#/definitions/DailySummary"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/schema/moon/monthlysummary.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "$ref": "../generated/moon-types.schema.json#/definitions/MonthlySummary"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/schema/nooklink/newspaper.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "$ref": "../generated/nooklink-types.schema.json#/definitions/Newspaper"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/schema/splatnet2/coop-result.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "$ref": "../generated/splatnet2-types.schema.json#/definitions/CoopResultWithPlayerNicknameAndIcons"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/schema/splatnet2/coop-summary.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "$ref": "../generated/splatnet2-types.schema.json#/definitions/CoopResults"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/schema/splatnet2/hero.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "$ref": "../generated/splatnet2-types.schema.json#/definitions/HeroRecords"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/schema/splatnet2/ni.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "$ref": "../generated/splatnet2-types.schema.json#/definitions/NicknameAndIcon"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/schema/splatnet2/records.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "$ref": "../generated/splatnet2-types.schema.json#/definitions/Records"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/schema/splatnet2/result.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "$ref": "../generated/splatnet2-types.schema.json#/definitions/ResultWithPlayerNicknameAndIcons"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/schema/splatnet2/results-summary.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "$ref": "../generated/splatnet2-types.schema.json#/definitions/Results"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/schema/splatnet2/timeline.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "$ref": "../generated/splatnet2-types.schema.json#/definitions/Timeline"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/schema/splatnet3/coop-result.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "type": "object",
4 | "properties": {
5 | "result": {
6 | "$ref": "../generated/splatnet3-types.schema.json#/definitions/CoopHistoryDetail"
7 | }
8 | },
9 | "required": [
10 | "result"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/.vscode/schema/splatnet3/result.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "type": "object",
4 | "properties": {
5 | "result": {
6 | "$ref": "../generated/splatnet3-types.schema.json#/definitions/VsHistoryDetail"
7 | }
8 | },
9 | "required": [
10 | "result"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.associations": {
3 | "**/data/persist/*": "json"
4 | },
5 | "json.schemas": [
6 | {
7 | "fileMatch": ["**/pctl-daily-*.json"],
8 | "url": "./.vscode/schema/moon/dailysummary.schema.json"
9 | },
10 | {
11 | "fileMatch": ["**/pctl-monthly-*.json"],
12 | "url": "./.vscode/schema/moon/monthlysummary.schema.json"
13 | },
14 |
15 | {
16 | "fileMatch": ["**/splatnet2-records-*.json", "!**-latest.json"],
17 | "url": "./.vscode/schema/splatnet2/records.schema.json"
18 | },
19 | {
20 | "fileMatch": ["**/splatnet2-ni-*.json"],
21 | "url": "./.vscode/schema/splatnet2/ni.schema.json"
22 | },
23 | {
24 | "fileMatch": ["**/splatnet2-timeline-*.json"],
25 | "url": "./.vscode/schema/splatnet2/timeline.schema.json"
26 | },
27 | {
28 | "fileMatch": ["**/splatnet2-hero-*.json"],
29 | "url": "./.vscode/schema/splatnet2/hero.schema.json"
30 | },
31 | {
32 | "fileMatch": ["**/splatnet2-results-summary-*.json", "!**/splatnet2-results-summary-image-*", "!**-latest.json"],
33 | "url": "./.vscode/schema/splatnet2/results-summary.schema.json"
34 | },
35 | {
36 | "fileMatch": ["**/splatnet2-result-*.json", "!**/splatnet2-result-image-*.json"],
37 | "url": "./.vscode/schema/splatnet2/result.schema.json"
38 | },
39 | {
40 | "fileMatch": ["**/splatnet2-coop-summary-*.json", "!**-latest.json"],
41 | "url": "./.vscode/schema/splatnet2/coop-summary.schema.json"
42 | },
43 | {
44 | "fileMatch": ["**/splatnet2-coop-result-*.json"],
45 | "url": "./.vscode/schema/splatnet2/coop-result.schema.json"
46 | },
47 |
48 | {
49 | "fileMatch": ["**/nooklink-newspaper-*.json"],
50 | "url": "./.vscode/schema/nooklink/newspaper.schema.json"
51 | },
52 |
53 | {
54 | "fileMatch": ["**/splatnet3-result-*.json"],
55 | "url": "./.vscode/schema/splatnet3/result.schema.json"
56 | },
57 | {
58 | "fileMatch": ["**/splatnet3-coop-result-*.json"],
59 | "url": "./.vscode/schema/splatnet3/coop-result.schema.json"
60 | },
61 |
62 | {
63 | "fileMatch": ["**/resources/common/remote-config.json", "**/data/remote-config.json"],
64 | "url": "./.vscode/schema/generated/remote-config.schema.json"
65 | },
66 | ],
67 | "typescript.tsdk": "node_modules/typescript/lib",
68 | "typescript.preferences.quoteStyle": "single",
69 | }
70 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20 as build
2 |
3 | WORKDIR /app
4 |
5 | ADD package.json /app
6 | ADD package-lock.json /app
7 |
8 | RUN npm install
9 |
10 | COPY src /app/src
11 | COPY bin /app/bin
12 | ADD tsconfig.json /app
13 |
14 | RUN npx tsc
15 |
16 | FROM node:20
17 |
18 | WORKDIR /app
19 |
20 | ADD package.json /app
21 | ADD package-lock.json /app
22 |
23 | RUN npm ci --production
24 |
25 | COPY bin /app/bin
26 | COPY resources /app/resources
27 | COPY resources/cli/fonts /usr/local/share/fonts
28 | COPY --from=build /app/dist /app/dist
29 |
30 | RUN ln -s /app/bin/nxapi.js /usr/local/bin/nxapi
31 | ENV NXAPI_DATA_PATH=/data
32 | ENV NODE_ENV=production
33 |
34 | VOLUME [ "/data" ]
35 |
36 | ENTRYPOINT [ "/app/resources/docker-entrypoint.sh" ]
37 | CMD [ "--help" ]
38 |
--------------------------------------------------------------------------------
/bin/nxapi.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import('../dist/cli-entry.js');
4 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | #
5 | # Coral/znc proxy
6 | #
7 |
8 | znc-proxy:
9 | build: .
10 | command: nso http-server --listen \[::]:80
11 | restart: unless-stopped
12 | labels:
13 | traefik.enable: true
14 | traefik.http.routers.nxapi-znc.entrypoints: websecure
15 | traefik.http.routers.nxapi-znc.rule: Host(`${TRAEFIK_HOST:-nxapi.ta.fancy.org.uk}`) && PathPrefix(`/api/znc/`)
16 | traefik.http.routers.nxapi-znc.tls: true
17 | traefik.http.services.nxapi-znc.loadbalancer.server.port: 80
18 | environment:
19 | DEBUG: '*,-express:*,-body-parser:*'
20 | # ZNCA_API_URL: http://znca-api/api/znca
21 | env_file: .env
22 | volumes:
23 | - data:/data
24 |
25 | #
26 | # Presence server
27 | #
28 | # Start with docker compose --profile presence-server up -d.
29 | #
30 | # Users used to fetch presence data must have a saved session token, e.g. by
31 | # running docker compose run --rm -it presence-server nso auth/token. If not
32 | # all users have access to SplatNet 3, remove the --splatnet3 option and don't
33 | # start the presence-splatnet3-proxy service.
34 | #
35 | # The znc-proxy and presence-splatnet3-proxy services allow the presence-server
36 | # service to be scaled without sending additional requests to Nintendo servers.
37 | # The znc-proxy and presence-splatnet3-proxy services should not be scaled as
38 | # they handle fetching data from Nintendo.
39 | #
40 |
41 | presence-server:
42 | build: .
43 | command: presence-server --listen \[::]:80 --splatnet3 --splatnet3-fest-votes
44 | restart: unless-stopped
45 | profiles:
46 | - presence-server
47 | labels:
48 | traefik.enable: true
49 | traefik.http.routers.nxapi-presence.entrypoints: websecure
50 | traefik.http.routers.nxapi-presence.rule: Host(`${TRAEFIK_HOST:-nxapi.ta.fancy.org.uk}`) && (Path(`/api/presence`) || PathPrefix(`/api/presence/`) || PathPrefix(`/api/splatnet3/resources/`))
51 | traefik.http.routers.nxapi-presence.tls: true
52 | traefik.http.services.nxapi-presence.loadbalancer.server.port: 80
53 | environment:
54 | DEBUG: '*,-express:*,-send'
55 | ZNC_PROXY_URL: http://znc-proxy/api/znc
56 | NXAPI_PRESENCE_SERVER_USER: ${NXAPI_PRESENCE_SERVER_USER:-}
57 | NXAPI_PRESENCE_SERVER_SPLATNET3_PROXY_URL: http://presence-splatnet3-proxy/api/splatnet3-presence
58 | volumes:
59 | - data:/data
60 |
61 | presence-splatnet3-proxy:
62 | build: .
63 | command: presence-server --listen \[::]:80 --splatnet3 --splatnet3-proxy --splatnet3-record-fest-votes
64 | restart: unless-stopped
65 | profiles:
66 | - presence-server
67 | environment:
68 | DEBUG: '*,-express:*'
69 | ZNC_PROXY_URL: http://znc-proxy/api/znc
70 | volumes:
71 | - data:/data
72 |
73 | volumes:
74 | data:
75 |
--------------------------------------------------------------------------------
/docs/lib/README.md:
--------------------------------------------------------------------------------
1 | index.md
--------------------------------------------------------------------------------
/resources/app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samuelthomas2774/nxapi/4888544f11e14b2d9220ec85d880f72cab57e9e3/resources/app.png
--------------------------------------------------------------------------------
/resources/app/menu-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samuelthomas2774/nxapi/4888544f11e14b2d9220ec85d880f72cab57e9e3/resources/app/menu-icon.png
--------------------------------------------------------------------------------
/resources/build/app/cli-linux.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Run as /opt/Nintendo Switch Online/nxapi
4 |
5 | APP_BUNDLE_PATH="$(dirname "$0")"
6 | export ELECTRON_RUN_AS_NODE=1
7 |
8 | exec "$APP_BUNDLE_PATH/nxapi-app" "$APP_BUNDLE_PATH/resources/app/dist/bundle/cli-bundle.js" $@
9 |
--------------------------------------------------------------------------------
/resources/build/app/cli-macos.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Run as Nintendo Switch Online.app/Contents/bin/nxapi
4 |
5 | APP_BUNDLE_PATH="$(dirname "$0")/../.."
6 | export ELECTRON_RUN_AS_NODE=1
7 |
8 | exec "$APP_BUNDLE_PATH/Contents/MacOS/Nintendo Switch Online" "$APP_BUNDLE_PATH/Contents/Resources/app/dist/bundle/cli-bundle.js" $@
9 |
--------------------------------------------------------------------------------
/resources/build/app/deb/postinst:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | ln -sf '/opt/Nintendo Switch Online/nxapi' '/usr/bin/nxapi'
4 |
5 | # Link to the binary
6 | ln -sf '/opt/Nintendo Switch Online/nxapi-app' '/usr/bin/nxapi-app'
7 |
8 | # SUID chrome-sandbox for Electron 5+
9 | chmod 4755 '/opt/Nintendo Switch Online/chrome-sandbox' || true
10 |
11 | update-mime-database /usr/share/mime || true
12 | update-desktop-database /usr/share/applications || true
13 |
--------------------------------------------------------------------------------
/resources/build/app/deb/postrm:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | rm -f '/usr/bin/nxapi'
4 |
5 | # Delete the link to the binary
6 | rm -f '/usr/bin/nxapi-app'
7 |
--------------------------------------------------------------------------------
/resources/build/ci-main-version.js:
--------------------------------------------------------------------------------
1 | import * as fs from 'node:fs/promises';
2 | import * as child_process from 'node:child_process';
3 | import * as util from 'node:util';
4 | import { fileURLToPath } from 'node:url';
5 |
6 | const execFile = util.promisify(child_process.execFile);
7 |
8 | const options = {cwd: fileURLToPath(new URL('../..', import.meta.url))};
9 | const git = (...args) => execFile('git', args, options).then(({stdout}) => stdout.toString().trim());
10 |
11 | const pkg = JSON.parse(await fs.readFile(new URL('../../package.json', import.meta.url), 'utf-8'));
12 |
13 | const [revision, branch_str, changed_files_str, tags_str, commit_count_str] = await Promise.all([
14 | process.env.CI_COMMIT_SHA || git('rev-parse', 'HEAD'),
15 | process.env.CI_COMMIT_BRANCH || git('rev-parse', '--abbrev-ref', 'HEAD'),
16 | git('diff', '--name-only', 'HEAD'),
17 | git('log', '--tags', '--no-walk', '--pretty=%D'),
18 | git('rev-list', '--count', 'HEAD'),
19 | ]);
20 |
21 | const branch = branch_str && branch_str !== 'HEAD' ? branch_str : null;
22 | const changed_files = changed_files_str.length ? changed_files_str.split('\n') : [];
23 | const tags = tags_str.split(/\n|, /).filter(t => t.startsWith('tag: ')).map(t => t.substr(5));
24 | const last_tagged_version = tags.find(t => t.startsWith('v'))?.substr(1) ?? null;
25 | const last_version = last_tagged_version ?? pkg.version;
26 | const commit_count = parseInt(commit_count_str);
27 |
28 | console.warn({
29 | version: pkg.version,
30 | last_tagged_version,
31 | last_version,
32 | revision,
33 | branch,
34 | changed_files,
35 | tags,
36 | tags_str,
37 | commit_count,
38 | });
39 |
40 | if (!last_tagged_version || pkg.version !== last_tagged_version) {
41 | console.warn('Last tagged version does not match package.json version', {
42 | version: pkg.version,
43 | tag: last_tagged_version ? 'v' + last_tagged_version : null,
44 | });
45 | process.exit();
46 | }
47 |
48 | const last_tagged_version_commit_count = parseInt(await git('rev-list', '--count', 'v' + last_version));
49 | const commit_count_since_last_version = commit_count - last_tagged_version_commit_count;
50 |
51 | console.warn({
52 | last_tagged_version_commit_count,
53 | commit_count_since_last_version,
54 | });
55 |
56 | if (commit_count_since_last_version <= 0) {
57 | console.warn('No changes since last tagged version');
58 | process.exit();
59 | }
60 |
61 | const version = last_version +
62 | '-next.' + (commit_count_since_last_version - 1) +
63 | '+sha.' + revision.substr(0, 7);
64 |
65 | console.warn({
66 | version,
67 | });
68 |
69 | console.log(version);
70 |
--------------------------------------------------------------------------------
/resources/build/ci-package-json.js:
--------------------------------------------------------------------------------
1 | import * as fs from 'node:fs/promises';
2 | import * as child_process from 'node:child_process';
3 | import * as util from 'node:util';
4 | import { fileURLToPath } from 'node:url';
5 |
6 | const execFile = util.promisify(child_process.execFile);
7 | const options = {cwd: fileURLToPath(new URL('../..', import.meta.url))};
8 | const git = (...args) => execFile('git', args, options).then(({stdout}) => stdout.toString().trim());
9 |
10 | const pkg = JSON.parse(await fs.readFile(new URL('../../package.json', import.meta.url), 'utf-8'));
11 |
12 | const [revision, branch, changed_files] = await Promise.all([
13 | process.env.CI_COMMIT_SHA || git('rev-parse', 'HEAD'),
14 | process.env.CI_COMMIT_BRANCH || git('rev-parse', '--abbrev-ref', 'HEAD'),
15 | git('diff', '--name-only', 'HEAD'),
16 | ]);
17 |
18 | if (process.argv[2] === 'gitlab') {
19 | pkg.name = process.env.GITLAB_NPM_PACKAGE_NAME ?? pkg.name;
20 | pkg.publishConfig = {access: 'public'};
21 | }
22 |
23 | if (process.argv[2] === 'github') {
24 | pkg.name = process.env.GITHUB_NPM_PACKAGE_NAME ?? pkg.name;
25 | pkg.repository = {
26 | type: 'git',
27 | url: 'https://github.com/' + process.env.GITHUB_REPOSITORY + '.git',
28 | };
29 | pkg.publishConfig = {access: 'public'};
30 | }
31 |
32 | pkg.version = process.env.VERSION || pkg.version;
33 | pkg.__nxapi_release = process.env.CI_COMMIT_TAG;
34 |
35 | if (process.argv[2] === 'docker') {
36 | pkg.__nxapi_docker = process.argv[3];
37 | }
38 |
39 | pkg.__nxapi_git = pkg.__nxapi_git ?? {
40 | revision,
41 | branch: branch && branch !== 'HEAD' ? branch : null,
42 | changed_files: changed_files.length ? changed_files.split('\n') : [],
43 | };
44 |
45 | await fs.writeFile(new URL('../../package.json', import.meta.url), JSON.stringify(pkg, null, 4) + '\n', 'utf-8');
46 |
--------------------------------------------------------------------------------
/resources/cli/fonts/opensans-normal-400.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samuelthomas2774/nxapi/4888544f11e14b2d9220ec85d880f72cab57e9e3/resources/cli/fonts/opensans-normal-400.ttf
--------------------------------------------------------------------------------
/resources/cli/fonts/opensans-normal-500.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samuelthomas2774/nxapi/4888544f11e14b2d9220ec85d880f72cab57e9e3/resources/cli/fonts/opensans-normal-500.ttf
--------------------------------------------------------------------------------
/resources/common/remote-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "require_version": [],
3 | "log_encryption_key": "E2Sii_7drCzK-68RsEoArmopiAIlZD_6TMA2F_UAAU0",
4 | "coral": {
5 | "znca_version": "2.12.0"
6 | },
7 | "coral_auth": {
8 | "default": [
9 | "nxapi",
10 | "https:\/\/nxapi-znca-api.fancy.org.uk\/api\/znca"
11 | ],
12 | "splatnet2statink": null,
13 | "flapg": null,
14 | "imink": {}
15 | },
16 | "moon": {
17 | "znma_version": "1.18.0",
18 | "znma_build": "275"
19 | },
20 | "coral_gws_nooklink": {
21 | "blanco_version": "2.1.1"
22 | },
23 | "coral_gws_splatnet3": {
24 | "app_ver": "4.0.0-091d4283",
25 | "version": "4.0.0",
26 | "revision": "091d428399dc86fd3a7fc43d64bd33b8bd1e875d"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/resources/discord-activity-splatoon3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samuelthomas2774/nxapi/4888544f11e14b2d9220ec85d880f72cab57e9e3/resources/discord-activity-splatoon3.png
--------------------------------------------------------------------------------
/resources/discord-activity.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samuelthomas2774/nxapi/4888544f11e14b2d9220ec85d880f72cab57e9e3/resources/discord-activity.png
--------------------------------------------------------------------------------
/resources/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | mkdir -p /data/android
4 |
5 | # Logs will be captured by Docker if enabled
6 | # This is set here so that running another process with `docker exec` (which
7 | # doesn't capture logs) will still write to a file by default
8 | export NXAPI_DEBUG_FILE=0
9 |
10 | exec /app/bin/nxapi.js --data-path /data "$@"
11 |
--------------------------------------------------------------------------------
/resources/menu-app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samuelthomas2774/nxapi/4888544f11e14b2d9220ec85d880f72cab57e9e3/resources/menu-app.png
--------------------------------------------------------------------------------
/resources/notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samuelthomas2774/nxapi/4888544f11e14b2d9220ec85d880f72cab57e9e3/resources/notification.png
--------------------------------------------------------------------------------
/src/api/splatnet3-types.ts:
--------------------------------------------------------------------------------
1 | /** /bullet_tokens */
2 | export interface BulletToken {
3 | bulletToken: string;
4 | lang: string;
5 | is_noe_country: 'true' | 'false';
6 | }
7 |
--------------------------------------------------------------------------------
/src/api/util.ts:
--------------------------------------------------------------------------------
1 | import * as util from 'node:util';
2 | import { Response as UndiciResponse } from 'undici';
3 |
4 | export const ResponseSymbol = Symbol('Response');
5 | const ErrorResponseSymbol = Symbol('IsErrorResponse');
6 |
7 | export interface ResponseData {
8 | [ResponseSymbol]: R;
9 | }
10 | export type HasResponse = T & ResponseData;
11 |
12 | export function defineResponse(data: T, response: R) {
13 | Object.defineProperty(data, ResponseSymbol, {enumerable: false, value: response});
14 | return data as HasResponse;
15 | }
16 |
17 | export class ErrorResponse extends Error {
18 | readonly body: string | undefined;
19 | readonly data: T | undefined = undefined;
20 |
21 | constructor(
22 | message: string,
23 | readonly response: Response | UndiciResponse,
24 | body?: string | ArrayBuffer | T
25 | ) {
26 | super(message);
27 |
28 | Object.defineProperty(this, ErrorResponseSymbol, {enumerable: false, value: ErrorResponseSymbol});
29 |
30 | if (body instanceof ArrayBuffer) {
31 | body = (new TextDecoder()).decode(body);
32 | }
33 |
34 | if (typeof body === 'string') {
35 | this.body = body;
36 | try {
37 | this.data = body ? JSON.parse(body) : undefined;
38 | } catch (err) {}
39 | } else if (typeof body !== 'undefined') {
40 | this.data = body;
41 | }
42 |
43 | const stack = this.stack ?? (this.name + ': ' + message);
44 | const lines = stack.split('\n');
45 | const head = lines.shift()!;
46 |
47 | Object.defineProperty(this, 'stack', {
48 | value: head + '\n' +
49 | ' from ' + response.url + ' (' + response.status + ' ' + response.statusText + ')\n' +
50 | ' ' + util.inspect(this.data ? this.data : this.body, {
51 | compact: true,
52 | maxStringLength: 100,
53 | }).replace(/\n/g, '\n ') +
54 | (lines.length ? '\n' + lines.join('\n') : ''),
55 | });
56 | }
57 |
58 | static async fromResponse(response: UndiciResponse, message: string) {
59 | const body = await response.arrayBuffer();
60 |
61 | return new this(message, response, body);
62 | }
63 | }
64 |
65 | Object.defineProperty(ErrorResponse, Symbol.hasInstance, {
66 | configurable: true,
67 | value: (instance: ErrorResponse) => {
68 | return instance && ErrorResponseSymbol in instance;
69 | },
70 | });
71 |
--------------------------------------------------------------------------------
/src/app/README.md:
--------------------------------------------------------------------------------
1 | Electron app
2 | ---
3 |
4 | The Electron app is bundled into ~4 files in `dist/app/bundle` using Rollup. The main process code is not bundled for development, but is when packaging the app at `dist/bundle` (with the command line executable at `dist/bundle/cli-bundle.js`).
5 |
6 | [electron.ts](electron.ts) exports all Electron APIs used in the main process. This is because the `electron` module doesn't actually exist - Electron patches the `require` function (but not the module importer). Additionally Electron does not support using a standard JavaScript module as the app entrypoint, so [app-entry.cts](app-entry.cts) (a CommonJS module) is used to import the actual app entrypoint after the `ready` event.
7 |
8 | Electron APIs used in renderer processes should be imported directly from the `electron` module as they are always bundled into a CommonJS module.
9 |
10 | Any files outside this directory must not depend on anything here, as this directory won't be included in the npm package.
11 |
--------------------------------------------------------------------------------
/src/app/app-entry.cts:
--------------------------------------------------------------------------------
1 | const electron = require('electron');
2 |
3 | // Do anything that must be run before the app is ready...
4 |
5 | Promise.all([
6 | // @ts-expect-error
7 | typeof __NXAPI_BUNDLE_APP_INIT__ !== 'undefined' ? import(__NXAPI_BUNDLE_APP_INIT__) : import('./app-init.js'),
8 | electron.app.whenReady(),
9 | ])
10 | // @ts-expect-error
11 | .then(() => typeof __NXAPI_BUNDLE_APP_MAIN__ !== 'undefined' ? import(__NXAPI_BUNDLE_APP_MAIN__) : import('./main/index.js'))
12 | .then(m => m.init.call(null))
13 | .catch(err => {
14 | electron.dialog.showErrorBox('Error during startup', err?.stack ?? err?.message ?? err);
15 | process.exit(1);
16 | });
17 |
--------------------------------------------------------------------------------
/src/app/app-init.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'node:path';
2 | import { init as initDebug } from '../util/debug.js';
3 | import { paths } from '../util/product.js';
4 |
5 | await initDebug(join(paths.log, 'app'));
6 |
--------------------------------------------------------------------------------
/src/app/browser/components/friend-code.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useMemo } from 'react';
2 | import { StyleSheet, Text } from 'react-native';
3 | import { CurrentUser } from '../../../api/coral-types.js';
4 | import ipc from '../ipc.js';
5 |
6 | export default function FriendCode(props: {
7 | friendcode: CurrentUser['links']['friendCode'];
8 | } | {
9 | id: string;
10 | }) {
11 | const friendcode = useMemo(() => 'friendcode' in props ? props.friendcode : {
12 | id: props.id,
13 | regenerable: false,
14 | regenerableAt: 0,
15 | }, ['friendcode' in props ? props.friendcode : null, 'id' in props ? props.id : null]);
16 |
17 | const onFriendCodeContextMenu = useCallback(() => {
18 | ipc.showFriendCodeMenu(friendcode);
19 | }, [ipc, friendcode]);
20 |
21 | return SW-{friendcode.id};
26 | }
27 |
28 | const styles = StyleSheet.create({
29 | friendCodeValue: {
30 | userSelect: 'all',
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/src/app/browser/components/icons/add-outline.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Platform, Text } from 'react-native';
3 | import { svg_styles } from './util.js';
4 |
5 | const IconWeb = React.memo((props: {
6 | title?: string;
7 | }) =>
8 |
12 | );
13 |
14 | export default Platform.OS === 'web' ? IconWeb : React.memo(() => null);
15 |
--------------------------------------------------------------------------------
/src/app/browser/components/icons/util.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const svg_styles: React.CSSProperties = {
4 | height: '1.2em',
5 | width: 'auto',
6 | fill: 'currentColor',
7 | alignSelf: 'center',
8 | verticalAlign: -4,
9 | };
10 |
--------------------------------------------------------------------------------
/src/app/browser/components/icons/warning.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Platform, Text } from 'react-native';
3 | import { svg_styles } from './util.js';
4 |
5 | const IconWeb = React.memo((props: {
6 | title?: string;
7 | }) =>
8 |
12 | );
13 |
14 | export default Platform.OS === 'web' ? IconWeb : React.memo(() => null);
15 |
--------------------------------------------------------------------------------
/src/app/browser/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Button } from './button.js';
2 | export { default as NintendoSwitchUser } from './nintendo-switch-user.js';
3 | export { NintendoSwitchUsers } from './nintendo-switch-user.js';
4 | export { default as FriendCode } from './friend-code.js';
5 |
--------------------------------------------------------------------------------
/src/app/browser/components/nintendo-switch-user.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Image, StyleSheet } from 'react-native';
3 | import { CurrentUser, Friend } from '../../../api/coral-types.js';
4 |
5 | export default function NintendoSwitchUser(props: {
6 | friend: Friend;
7 | nickname?: string;
8 | } | {
9 | user: CurrentUser;
10 | nickname?: string;
11 | }) {
12 | const user = 'friend' in props ? props.friend : props.user;
13 |
14 | return <>
15 |
17 | {' '}
18 | {user.name}
19 | {props.nickname && user.name !== props.nickname ? ' (' + props.nickname + ')' : ''}
20 | >;
21 | }
22 |
23 | export function NintendoSwitchUsers(props: {
24 | users: Parameters[0][];
25 | }) {
26 | return <>
27 | {props.users.map((u, i) =>
28 | {i === 0 ? '' : ', '}
29 |
30 | )}
31 | >;
32 | }
33 |
34 | const styles = StyleSheet.create({
35 | userImage: {
36 | borderRadius: 8,
37 | // @ts-expect-error react-native-web
38 | verticalAlign: -3 as 'auto',
39 | },
40 | });
41 |
--------------------------------------------------------------------------------
/src/app/browser/constants.ts:
--------------------------------------------------------------------------------
1 | import ipc from './ipc.js';
2 |
3 | export const NSO_COLOUR = '#e60012';
4 | export const NSO_COLOUR_DARK: `${typeof NSO_COLOUR}e0` = `${NSO_COLOUR}e0`;
5 | export const DISCORD_COLOUR = '#5865f2';
6 |
7 | export const BACKGROUND_COLOUR_MAIN_LIGHT = ipc.platform === 'win32' ? '#ffffff' : '#ececec';
8 | export const BACKGROUND_COLOUR_MAIN_DARK = ipc.platform === 'win32' ? '#000000' : '#252424';
9 |
10 | export const BACKGROUND_COLOUR_SECONDARY_LIGHT = '#ffffff';
11 | export const BACKGROUND_COLOUR_SECONDARY_DARK = '#353535';
12 |
13 | export const UPDATE_COLOUR = '#006064e0';
14 |
15 | export const HIGHLIGHT_COLOUR_LIGHT = '#00000020';
16 | export const HIGHLIGHT_COLOUR_DARK = '#ffffff20';
17 |
18 | export const BORDER_COLOUR_LIGHT = '#00000020';
19 | export const BORDER_COLOUR_DARK = '#00000080';
20 | export const BORDER_COLOUR_SECONDARY_DARK = '#ffffff20';
21 |
22 | export const TEXT_COLOUR_LIGHT = '#212121';
23 | export const TEXT_COLOUR_DARK = '#f5f5f5';
24 | export const TEXT_COLOUR_ACTIVE = '#3ba55d';
25 |
26 | export const DEFAULT_ACCENT_COLOUR = NSO_COLOUR.substr(1).toUpperCase() + 'FF';
27 |
--------------------------------------------------------------------------------
/src/app/browser/index.ts:
--------------------------------------------------------------------------------
1 | import { AppRegistry } from 'react-native';
2 | import { config } from './ipc.js';
3 | import App from './main/index.js';
4 | import Friend from './friend/index.js';
5 | import DiscordSetup from './discord/index.js';
6 | import AddFriend from './add-friend/index.js';
7 | import Preferences from './preferences/index.js';
8 | import AddAccountManualPrompt from './add-account-manual/index.js';
9 |
10 | AppRegistry.registerComponent('App', () => App);
11 | AppRegistry.registerComponent('Friend', () => Friend);
12 | AppRegistry.registerComponent('DiscordPresence', () => DiscordSetup);
13 | AppRegistry.registerComponent('AddFriend', () => AddFriend);
14 | AppRegistry.registerComponent('Preferences', () => Preferences);
15 | AppRegistry.registerComponent('AddAccountManualPrompt', () => AddAccountManualPrompt);
16 |
17 | const style = window.document.createElement('style');
18 |
19 | style.textContent = `
20 | :root {
21 | user-select: none;
22 | overflow-x: hidden;
23 | }
24 | *:focus-visible {
25 | outline-style: solid;
26 | outline-width: medium;
27 | }
28 | input,
29 | input:focus-visible {
30 | outline: none 0;
31 | }
32 | `;
33 |
34 | window.document.head.appendChild(style);
35 |
36 | const rootTag = window.document.createElement('div');
37 |
38 | rootTag.style.minHeight = '100vh';
39 | window.document.body.appendChild(rootTag);
40 |
41 | AppRegistry.runApplication(config.type, {
42 | rootTag,
43 | initialProps: config.props,
44 | });
45 |
--------------------------------------------------------------------------------
/src/app/browser/ipc.ts:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from 'node:events';
2 | import createDebug from 'debug';
3 | import type { NxapiElectronIpc } from '../preload/index.js';
4 |
5 | const debug = createDebug('app:browser:ipc');
6 |
7 | declare global {
8 | interface Window {
9 | nxapiElectronIpc: NxapiElectronIpc;
10 | }
11 | }
12 |
13 | export const events = new EventEmitter();
14 | events.setMaxListeners(0);
15 |
16 | const ipc = {
17 | ...window.nxapiElectronIpc,
18 |
19 | events,
20 | };
21 |
22 | events.on('newListener', (event: string, listener: (...args: any[]) => void) => {
23 | ipc.registerEventListener(event, listener);
24 | });
25 | events.on('removeListener', (event: string, listener: (...args: any[]) => void) => {
26 | ipc.removeEventListener(event, listener);
27 | });
28 |
29 | export default ipc;
30 |
31 | export const config = ipc.getWindowData();
32 |
33 | debug('Window configuration', config);
34 |
--------------------------------------------------------------------------------
/src/app/browser/main/section.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
3 | import { useAccentColour, useColourScheme } from '../util.js';
4 | import { useTranslation } from 'react-i18next';
5 | import { BORDER_COLOUR_LIGHT, BORDER_COLOUR_SECONDARY_DARK, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
6 | import ipc from '../ipc.js';
7 | import Warning from '../components/icons/warning.js';
8 |
9 | export default function Section(props: React.PropsWithChildren<{
10 | title: string;
11 | loading?: boolean;
12 | error?: Error;
13 | headerButtons?: React.ReactNode;
14 | }>) {
15 | const theme = useColourScheme() === 'light' ? light : dark;
16 | const accent_colour = useAccentColour();
17 | const { t, i18n } = useTranslation('main_window', { keyPrefix: 'main_section' });
18 |
19 | const showErrorDetails = useCallback(() => {
20 | alert(props.error);
21 | }, [props.error]);
22 |
23 | return
24 |
25 | {props.title}
26 | {props.loading ? :
28 | props.error ?
29 |
30 | : null}
31 | {props.headerButtons}
32 |
33 |
34 | {props.children}
35 | ;
36 | }
37 |
38 | export const HEADER_SIZE = ipc.platform === 'win32' ? 24 : 14;
39 |
40 | const styles = StyleSheet.create({
41 | container: {
42 | marginBottom: ipc.platform === 'win32' ? 10 : 0,
43 | borderBottomWidth: ipc.platform === 'win32' ? 0 : 1,
44 | },
45 | header: {
46 | paddingVertical: ipc.platform === 'win32' ? 20 : 16,
47 | paddingHorizontal: ipc.platform === 'win32' ? 24 : 20,
48 | flexDirection: 'row',
49 | },
50 | headerText: {
51 | flex: 1,
52 | fontSize: HEADER_SIZE,
53 | },
54 | activityIndicator: {
55 | marginLeft: 10,
56 | },
57 | iconTouchable: {
58 | marginLeft: 10,
59 | },
60 | icon: {
61 | fontSize: HEADER_SIZE,
62 | },
63 | });
64 |
65 | const light = StyleSheet.create({
66 | container: {
67 | borderBottomColor: BORDER_COLOUR_LIGHT,
68 | },
69 | text: {
70 | color: TEXT_COLOUR_LIGHT,
71 | },
72 | });
73 |
74 | const dark = StyleSheet.create({
75 | container: {
76 | borderBottomColor: BORDER_COLOUR_SECONDARY_DARK,
77 | },
78 | text: {
79 | color: TEXT_COLOUR_DARK,
80 | },
81 | });
82 |
--------------------------------------------------------------------------------
/src/app/browser/main/status-updates.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { StyleSheet, Text, View } from 'react-native';
3 | import ipc from '../ipc.js';
4 | import { useAccentColour, useEventListener } from '../util.js';
5 | import type { StatusUpdate } from '../../../common/status.js';
6 | import { BORDER_COLOUR_SECONDARY_DARK, TEXT_COLOUR_DARK, UPDATE_COLOUR } from '../constants.js';
7 | import { Button } from '../components/index.js';
8 |
9 | enum StatusUpdateFlag {
10 | HIDDEN = 0,
11 | }
12 |
13 | export default function StatusUpdates() {
14 | const accent_colour = useAccentColour();
15 |
16 | const [status_updates, setStatusUpdateData] = useState(null);
17 | useEffect(() => (ipc.getStatusUpdateData().then(setStatusUpdateData), undefined), [ipc]);
18 | useEventListener(ipc.events, 'status-updates', setStatusUpdateData, [ipc.events]);
19 |
20 | return status_updates?.map(status_update => status_update.flags & (1 << StatusUpdateFlag.HIDDEN) ? null :
21 | {status_update.content}
22 | {status_update.action ?
23 | : null}
27 | );
28 | }
29 |
30 | const styles = StyleSheet.create({
31 | container: {
32 | backgroundColor: UPDATE_COLOUR,
33 | borderBottomWidth: 1,
34 | borderBottomColor: BORDER_COLOUR_SECONDARY_DARK,
35 | paddingVertical: 8,
36 | paddingHorizontal: 20,
37 | flexDirection: 'row',
38 | alignItems: 'center',
39 | },
40 | updateText: {
41 | marginVertical: 4,
42 | flex: 1,
43 | color: TEXT_COLOUR_DARK,
44 | },
45 | updateButton: {
46 | marginLeft: 14,
47 | },
48 | });
49 |
--------------------------------------------------------------------------------
/src/app/browser/main/update.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useMemo, useState } from 'react';
2 | import { StyleSheet, Text, View } from 'react-native';
3 | import { useTranslation } from 'react-i18next';
4 | import ipc from '../ipc.js';
5 | import { useAccentColour, useEventListener } from '../util.js';
6 | import type { UpdateCacheData } from '../../../common/update.js';
7 | import type { StatusUpdate } from '../../../common/status.js';
8 | import { BORDER_COLOUR_SECONDARY_DARK, TEXT_COLOUR_DARK, UPDATE_COLOUR } from '../constants.js';
9 | import { Button } from '../components/index.js';
10 |
11 | enum StatusUpdateFlag {
12 | SUPPRESS_UPDATE_BANNER = 1,
13 | }
14 |
15 | export default function Update() {
16 | const accent_colour = useAccentColour();
17 | const { t, i18n } = useTranslation('main_window', { keyPrefix: 'update' });
18 |
19 | const [update, setUpdateData] = useState(null);
20 | useEffect(() => (ipc.getUpdateData().then(setUpdateData), undefined), [ipc]);
21 | useEventListener(ipc.events, 'nxapi:update:latest', setUpdateData, [ipc.events]);
22 |
23 | const [status_updates, setStatusUpdateData] = useState(null);
24 | useEffect(() => (ipc.getStatusUpdateData().then(setStatusUpdateData), undefined), [ipc]);
25 | useEventListener(ipc.events, 'status-updates', setStatusUpdateData, [ipc.events]);
26 |
27 | const status_update_suppress_update_banner = useMemo(() =>
28 | status_updates?.find(s => s.flags & (1 << StatusUpdateFlag.SUPPRESS_UPDATE_BANNER)),
29 | [status_updates]);
30 |
31 | return update && 'update_available' in update && update.update_available && !status_update_suppress_update_banner ?
32 | {t('update_available', {name: update.latest.name})}
33 |
34 |
38 | : update && 'error_message' in update ?
39 | {t('error', {message: update.error_message})}
40 |
41 |
45 | : null;
46 | }
47 |
48 | const styles = StyleSheet.create({
49 | container: {
50 | backgroundColor: UPDATE_COLOUR,
51 | borderBottomWidth: 1,
52 | borderBottomColor: BORDER_COLOUR_SECONDARY_DARK,
53 | paddingVertical: 8,
54 | paddingHorizontal: 20,
55 | flexDirection: 'row',
56 | alignItems: 'center',
57 | },
58 | updateText: {
59 | marginVertical: 4,
60 | flex: 1,
61 | color: TEXT_COLOUR_DARK,
62 | },
63 | updateButton: {
64 | marginLeft: 14,
65 | },
66 | });
67 |
--------------------------------------------------------------------------------
/src/app/browser/main/webservices.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
3 | import { useTranslation } from 'react-i18next';
4 | import ipc from '../ipc.js';
5 | import { useColourScheme, User } from '../util.js';
6 | import { WebService } from '../../../api/coral-types.js';
7 | import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
8 | import Section from './section.js';
9 |
10 | export default function WebServices(props: {
11 | user: User;
12 | webservices: WebService[];
13 | loading?: boolean;
14 | error?: Error;
15 | }) {
16 | const { t, i18n } = useTranslation('main_window', { keyPrefix: 'webservices_section' });
17 |
18 | if (!props.webservices.length) return null;
19 |
20 | return
21 |
22 |
23 | {props.webservices.map(g => )}
24 |
25 |
26 | ;
27 | }
28 |
29 | function WebService(props: {
30 | webservice: WebService;
31 | token?: string;
32 | }) {
33 | const theme = useColourScheme() === 'light' ? light : dark;
34 |
35 | const content =
36 |
37 |
38 | {props.webservice.name}
39 | ;
40 |
41 | return
42 | {props.token ? ipc.openWebService(props.webservice, props.token!)}>
43 | {content}
44 | : content}
45 | ;
46 | }
47 |
48 | const styles = StyleSheet.create({
49 | content: {
50 | paddingBottom: 16,
51 | paddingLeft: ipc.platform === 'win32' ? 24 : 20,
52 | paddingRight: ipc.platform === 'win32' ? 10 : 6,
53 | flexDirection: 'row',
54 | },
55 |
56 | webserviceContainer: {
57 | marginRight: 14,
58 | },
59 | webservice: {
60 | maxWidth: 120,
61 | alignItems: 'center',
62 | },
63 | webserviceImage: {
64 | borderRadius: ipc.platform === 'win32' ? 0 : 2,
65 | marginBottom: 12,
66 | },
67 | webserviceName: {
68 | textAlign: 'center',
69 | },
70 | });
71 |
72 | const light = StyleSheet.create({
73 | text: {
74 | color: TEXT_COLOUR_LIGHT,
75 | },
76 | });
77 |
78 | const dark = StyleSheet.create({
79 | text: {
80 | color: TEXT_COLOUR_DARK,
81 | },
82 | });
83 |
--------------------------------------------------------------------------------
/src/app/common/types.ts:
--------------------------------------------------------------------------------
1 | import { DiscordPresencePlayTime } from '../../discord/types.js';
2 |
3 | export enum WindowType {
4 | MAIN_WINDOW = 'App',
5 | FRIEND = 'Friend',
6 | DISCORD_PRESENCE = 'DiscordPresence',
7 | ADD_FRIEND = 'AddFriend',
8 | PREFERENCES = 'Preferences',
9 | ADD_ACCOUNT_MANUAL_PROMPT = 'AddAccountManualPrompt',
10 | }
11 |
12 | interface WindowProps {
13 | [WindowType.MAIN_WINDOW]: import('../browser/main/index.js').AppProps;
14 | [WindowType.FRIEND]: import('../browser/friend/index.js').FriendProps;
15 | [WindowType.DISCORD_PRESENCE]: import('../browser/discord/index.js').DiscordSetupProps;
16 | [WindowType.ADD_FRIEND]: import('../browser/add-friend/index.js').AddFriendProps;
17 | [WindowType.PREFERENCES]: import('../browser/preferences/index.js').PreferencesProps;
18 | [WindowType.ADD_ACCOUNT_MANUAL_PROMPT]: import('../browser/add-account-manual/index.js').AddAccountManualPromptProps;
19 | }
20 |
21 | export interface WindowConfiguration {
22 | type: T;
23 | props: WindowProps[T];
24 | }
25 |
26 | export interface DiscordPresenceConfiguration {
27 | source: DiscordPresenceSource;
28 | /** Discord user ID */
29 | user?: string;
30 | /** Friend code in the format "0000-0000-0000" */
31 | friend_code?: string;
32 | show_console_online?: boolean;
33 | show_active_event?: boolean;
34 | show_play_time?: DiscordPresencePlayTime;
35 | monitors?: DiscordPresenceExternalMonitorsConfiguration;
36 | }
37 |
38 | export type DiscordPresenceSource = DiscordPresenceSourceCoral | DiscordPresenceSourceUrl;
39 | export interface DiscordPresenceSourceCoral {
40 | na_id: string;
41 | friend_nsa_id?: string;
42 | }
43 | export interface DiscordPresenceSourceUrl {
44 | url: string;
45 | }
46 |
47 | export interface DiscordPresenceExternalMonitorsConfiguration {
48 | enable_splatnet3_monitoring?: boolean;
49 | }
50 |
51 | export interface DiscordStatus {
52 | error_message: string | null;
53 | }
54 |
55 | export interface LoginItem {
56 | supported: boolean;
57 | startup_enabled: boolean;
58 | startup_hidden: boolean;
59 | }
60 | export type LoginItemOptions = Omit;
61 |
--------------------------------------------------------------------------------
/src/app/i18n/index.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 | import { BackendModule, CallbackError, createInstance, ReadCallback } from 'i18next';
3 |
4 | const debug = createDebug('app:i18n');
5 |
6 | import './locale/en-gb.js';
7 |
8 | export const languages = {
9 | 'en-GB': {
10 | name: 'English',
11 | app: () => import('./locale/en-gb.js'),
12 | authors: [
13 | ['Samuel Elliott', 'https://gitlab.fancy.org.uk/samuel', 'https://github.com/samuelthomas2774'],
14 | ],
15 | },
16 | 'de-DE': {
17 | name: 'Deutsch',
18 | app: () => import('./locale/de-de.js'),
19 | authors: [
20 | ['Inkception', 'https://github.com/Inkception'],
21 | ],
22 | },
23 | 'es-ES': {
24 | name: 'Español',
25 | app: () => import('./locale/es-es.js'),
26 | authors: [
27 | ['sarayalth', 'https://github.com/sarayalth'],
28 | ],
29 | },
30 | 'ja-JP': {
31 | name: 'Japanese',
32 | app: () => import('./locale/ja-jp.js'),
33 | authors: [
34 | ['hilot06', 'https://github.com/hilot06'],
35 | ],
36 | },
37 | };
38 |
39 | const namespaces = {
40 | app: 'app',
41 | app_menu: 'app',
42 | menu_app: 'app',
43 | menus: 'app',
44 | notifications: 'app',
45 | handle_uri: 'app',
46 |
47 | main_window: 'app',
48 | preferences_window: 'app',
49 | friend_window: 'app',
50 | addfriend_window: 'app',
51 | discordsetup_window: 'app',
52 | addaccountmanual_window: 'app',
53 | } as const;
54 |
55 | type Namespace = keyof typeof namespaces;
56 |
57 | export default function createI18n() {
58 | const i18n = createInstance({
59 | fallbackLng: 'en-GB',
60 | debug: true,
61 | supportedLngs: Object.keys(languages),
62 | load: 'currentOnly',
63 | returnNull: false,
64 |
65 | interpolation: {
66 | escapeValue: false, // not needed for react as it escapes by default
67 | },
68 |
69 | react: {
70 | useSuspense: false,
71 | },
72 | });
73 |
74 | i18n.use(LanguageBackend);
75 |
76 | return i18n;
77 | }
78 |
79 | const LanguageBackend: BackendModule = {
80 | type: 'backend',
81 | read: (
82 | language: keyof typeof languages,
83 | namespace: Namespace,
84 | callback: ReadCallback,
85 | ) => {
86 | debug('Loading %s translations for %s', namespace, language);
87 |
88 | importLocale(language, namespaces[namespace]).then(resources => {
89 | callback(null, resources[namespace as keyof typeof resources]);
90 | }, (error: CallbackError) => {
91 | callback(error, null);
92 | });
93 | },
94 | init: null as any,
95 | };
96 |
97 | async function importLocale(
98 | language: keyof typeof languages,
99 | chunk: typeof namespaces[keyof typeof namespaces] = 'app',
100 | ) {
101 | if (!(language in languages)) throw new Error('Unknown language ' + language);
102 |
103 | return languages[language][chunk]();
104 | }
105 |
--------------------------------------------------------------------------------
/src/app/main/support.ts:
--------------------------------------------------------------------------------
1 | import { Buffer } from 'node:buffer';
2 | import { createWriteStream, WriteStream } from 'node:fs';
3 | import { app, dialog, Notification, shell } from 'electron';
4 | import createDebug from '../../util/debug.js';
5 | import { generateEncryptedLogArchive } from '../../util/support.js';
6 | import { join } from 'node:path';
7 | import { showErrorDialog } from './util.js';
8 |
9 | const debug = createDebug('app:main:support');
10 |
11 | export async function createLogArchive() {
12 | let start_notification: Notification | null = null;
13 |
14 | try {
15 | const { default: config } = await import('../../common/remote-config.js');
16 |
17 | if (!config.log_encryption_key) {
18 | throw new Error('No log encryption key in remote configuration');
19 | }
20 |
21 | const default_name = 'nxapi-logs-' +
22 | new Date().toISOString().replace(/[-:Z]/g, '').replace(/\.\d+/, '').replace(/T/, '-') +
23 | '.tar.gz';
24 |
25 | const result = await dialog.showSaveDialog({
26 | defaultPath: join(app.getPath('downloads'), default_name),
27 | filters: [{name: 'Tape archive (encrypted)', extensions: ['tgz', 'tar.gz']}],
28 | });
29 |
30 | if (result.canceled) return;
31 |
32 | const out = await createOutputStream(result.filePath);
33 |
34 | debug('creating log archive');
35 |
36 | start_notification = new Notification({
37 | title: 'Creating log archive',
38 | });
39 | start_notification.show();
40 |
41 | const key = Buffer.from(config.log_encryption_key, 'base64url');
42 | const [encrypt] = await generateEncryptedLogArchive(key);
43 |
44 | encrypt.pipe(out);
45 |
46 | await new Promise((rs, rj) => {
47 | encrypt.on('end', rs);
48 | encrypt.on('error', rj);
49 | });
50 |
51 | debug('done');
52 |
53 | start_notification.close();
54 |
55 | new Notification({
56 | title: 'Created log archive',
57 | }).show();
58 |
59 | shell.showItemInFolder(result.filePath);
60 | } catch (err) {
61 | start_notification?.close();
62 |
63 | showErrorDialog({
64 | message: 'Error creating log archive',
65 | error: err,
66 | });
67 | }
68 | }
69 |
70 | async function createOutputStream(path: string) {
71 | return new Promise((rs, rj) => {
72 | const out = createWriteStream(path);
73 |
74 | const onready = () => {
75 | out.removeListener('ready', onready);
76 | out.removeListener('error', onerror);
77 | rs(out);
78 | };
79 | const onerror = () => {
80 | out.removeListener('ready', onready);
81 | out.removeListener('error', onerror);
82 | rs(out);
83 | };
84 |
85 | out.on('ready', onready);
86 | out.on('error', onerror);
87 | });
88 | }
89 |
--------------------------------------------------------------------------------
/src/app/preload-webservice/index.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 |
3 | // Logs are written to the browser window developer tools, and are hidden by default (enable verbose logs)
4 | const debug = createDebug('app:preload-webservice');
5 |
6 | import './loading.js';
7 | import './znca-js-api.js';
8 | import './quirks/splatnet2.js';
9 | import './quirks/nooklink.js';
10 | import './quirks/splatnet3.js';
11 |
12 | const style = window.document.createElement('style');
13 |
14 | style.textContent = `
15 | *:focus-visible {
16 | outline-style: solid;
17 | outline-width: medium;
18 | }
19 | `;
20 |
21 | document.addEventListener('DOMContentLoaded', () => {
22 | (document.scrollingElement as HTMLElement).style.overflowX = 'hidden';
23 | window.document.head.appendChild(style);
24 | });
25 |
--------------------------------------------------------------------------------
/src/app/preload-webservice/ipc.ts:
--------------------------------------------------------------------------------
1 | import { ipcRenderer, IpcRendererEvent } from 'electron';
2 | import { EventEmitter } from 'node:events';
3 | import createDebug from 'debug';
4 | import { QrCodeReaderOptions, WebServiceData } from '../main/webservices.js';
5 |
6 | const debug = createDebug('app:preload-webservice:ipc');
7 |
8 | export const events = new EventEmitter();
9 |
10 | const ipc = {
11 | getWebServiceSync: () => ipcRenderer.sendSync('nxapi:webserviceapi:getWebServiceSync') as WebServiceData,
12 | invokeNativeShare: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:invokeNativeShare', data) as Promise,
13 | invokeNativeShareUrl: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:invokeNativeShareUrl', data) as Promise,
14 | requestGameWebToken: () => ipcRenderer.invoke('nxapi:webserviceapi:requestGameWebToken') as Promise,
15 | restorePersistentData: () => ipcRenderer.invoke('nxapi:webserviceapi:restorePersistentData') as Promise,
16 | storePersistentData: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:storePersistentData', data) as Promise,
17 | openQrCodeReader: (data: QrCodeReaderOptions) => ipcRenderer.invoke('nxapi:webserviceapi:openQrCodeReader', data) as Promise,
18 | closeQrCodeReader: () => ipcRenderer.invoke('nxapi:webserviceapi:closeQrCodeReader') as Promise,
19 | sendMessage: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:sendMessage', data) as Promise,
20 | copyToClipboard: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:copyToClipboard', data) as Promise,
21 | downloadImages: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:downloadImages', data) as Promise,
22 | completeLoading: () => ipcRenderer.invoke('nxapi:webserviceapi:completeLoading') as Promise,
23 | clearUnreadFlag: () => ipcRenderer.invoke('nxapi:webserviceapi:clearUnreadFlag') as Promise,
24 | };
25 |
26 | export default ipc;
27 |
28 | export const {webservice, url: webserviceurl} = ipc.getWebServiceSync();
29 |
30 | debug('Web service', webservice);
31 | debug('Web service URL', webserviceurl);
32 |
33 | ipcRenderer.on('nxapi:window:refresh', () => events.emit('window:refresh') || location.reload());
34 |
35 | ipcRenderer.on('nxapi:webserviceapi:deeplink', (event: IpcRendererEvent, qs: string) => {
36 | if (events.emit('deeplink', qs)) return;
37 |
38 | const url = new URL(webserviceurl);
39 | url.search += (url.search ? '&' : '') + qs;
40 | location.href = url.toString();
41 | });
42 |
--------------------------------------------------------------------------------
/src/app/preload-webservice/loading.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 |
3 | const debug = createDebug('app:preload-webservice:loading');
4 |
5 | if (location.href === 'about:blank') {
6 | const BACKGROUND_COLOUR_MAIN_LIGHT = process.platform === 'win32' ? '#ffffff' : '#ececec';
7 | const BACKGROUND_COLOUR_MAIN_DARK = process.platform === 'win32' ? '#000000' : '#252424';
8 |
9 | const style = window.document.createElement('style');
10 |
11 | style.textContent = `
12 | :root {
13 | background-color: ${BACKGROUND_COLOUR_MAIN_DARK};
14 | }
15 | @media (prefers-color-scheme: light) {
16 | :root {
17 | background-color: ${BACKGROUND_COLOUR_MAIN_LIGHT};
18 | }
19 | }
20 | `;
21 |
22 | document.addEventListener('DOMContentLoaded', () => {
23 | (document.scrollingElement as HTMLElement).style.overflow = 'hidden';
24 | window.document.head.appendChild(style);
25 | });
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/preload-webservice/quirks/nooklink.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 | import { webservice, webserviceurl } from '../ipc.js';
3 |
4 | const debug = createDebug('app:preload-webservice:quirks:nooklink');
5 |
6 | //
7 | // NookLink must start at the main page, otherwise it will fail to load.
8 | // This is required so refreshing the page works.
9 | //
10 |
11 | const NOOKLINK_WEBSERVICE_ID = 4953919198265344;
12 |
13 | if (webservice.id === NOOKLINK_WEBSERVICE_ID) {
14 | const url = new URL(location.href);
15 | const initurl = new URL(webserviceurl);
16 |
17 | for (const key of [...url.searchParams.keys()]) {
18 | if (!initurl.searchParams.has(key)) {
19 | // Allow any extra query string parameters for deep links
20 | url.searchParams.delete(key);
21 | }
22 | }
23 |
24 | debug('URL', url, initurl);
25 |
26 | if (url.origin === initurl.origin && url.href !== initurl.href) {
27 | history.replaceState(null, document.title, initurl);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/preload-webservice/quirks/splatnet2.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 | import { events, webservice } from '../ipc.js';
3 |
4 | const debug = createDebug('app:preload-webservice:quirks:splatnet2');
5 |
6 | const SPLATNET2_WEBSERVICE_ID = 5741031244955648;
7 |
8 | if (webservice.id === SPLATNET2_WEBSERVICE_ID) {
9 | const style = window.document.createElement('style');
10 |
11 | style.textContent = `
12 | .popup-dim {
13 | /* Hide the horizonal scroll bar that only appears during the popup animation */
14 | overflow-x: hidden;
15 | }
16 | `;
17 |
18 | document.addEventListener('DOMContentLoaded', () => {
19 | // Always show the scroll bar for the document root since no scrollable containers are used anywhere
20 | (document.scrollingElement as HTMLElement).style.overflowY = 'scroll';
21 | window.document.head.appendChild(style);
22 | });
23 |
24 | events.on('window:refresh', () => {
25 | const refresh_button = document.querySelector('.refresh-button');
26 |
27 | if (refresh_button) {
28 | refresh_button.click();
29 | } else {
30 | location.reload();
31 | }
32 | });
33 | }
34 |
--------------------------------------------------------------------------------
/src/app/preload-webservice/quirks/splatnet3.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 | import { events, webservice } from '../ipc.js';
3 |
4 | const debug = createDebug('app:preload-webservice:quirks:splatnet3');
5 |
6 | const SPLATNET3_WEBSERVICE_ID = 4834290508791808;
7 |
8 | if (webservice.id === SPLATNET3_WEBSERVICE_ID && location.hostname.endsWith('.av5ja.srv.nintendo.net')) {
9 | const style = window.document.createElement('style');
10 |
11 | style.textContent = `
12 | [class*=NavigationBar_exitButton] {
13 | display: none;
14 | }
15 |
16 | [class*=App_App] {
17 | /* Hide scroll bars in the main app container, which includes the title bar that should always be fixed */
18 | overflow: auto;
19 | }
20 | [class*=InAppContent_children] {
21 | /* Maybe hide scroll bars in the main content container */
22 | overflow-y: auto;
23 | /* Hide the horizonal scroll bar that only appears during the page sliding transition */
24 | overflow-x: hidden;
25 | }
26 | [class*=SwipableView_swipableViewItem] {
27 | /* Maybe hide scroll bars in swipable views (e.g. in the schedules page) */
28 | overflow-y: auto;
29 | }
30 | [class*=StyledModal_Container] {
31 | /* Maybe hide the scroll bar in modals */
32 | overflow-y: auto;
33 | }
34 | `;
35 |
36 | document.addEventListener('DOMContentLoaded', () => {
37 | window.document.head.appendChild(style);
38 | });
39 |
40 | events.on('window:refresh', () => {
41 | const pulltorefresh_container = document.querySelector('[class*=PullToRefresh_container]');
42 | debug('PullToRefresh container HTMLElement', pulltorefresh_container);
43 | if (!pulltorefresh_container) return location.reload();
44 |
45 | const keys = Object.keys(pulltorefresh_container) as (keyof typeof pulltorefresh_container)[];
46 | const react_fiber: any = pulltorefresh_container[keys.find(k => k.startsWith('__reactFiber$'))!];
47 | debug('PullToRefresh container React fiber', react_fiber);
48 | if (!react_fiber) return location.reload();
49 |
50 | try {
51 | const props = react_fiber.return.return.memoizedProps;
52 | debug('PullToRefresh root props', props);
53 | props.onRefresh.call(null);
54 | } catch (err) {
55 | debug('Error triggering refresh, forcing full page reload', err);
56 | location.reload();
57 | }
58 | });
59 | }
60 |
61 | if (webservice.id === SPLATNET3_WEBSERVICE_ID && location.hostname === 'c.nintendo.com' && location.pathname.match(/^\/splatoon3-tournament(\/|$)/i)) {
62 | const style = window.document.createElement('style');
63 |
64 | style.textContent = `
65 | [class*=AppHeader_closeWebView] {
66 | display: none;
67 | }
68 | `;
69 |
70 | document.addEventListener('DOMContentLoaded', () => {
71 | window.document.head.appendChild(style);
72 | });
73 | }
74 |
--------------------------------------------------------------------------------
/src/cli-entry.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'node:path';
2 | import { init as initDebug } from './util/debug.js';
3 | import { paths } from './util/product.js';
4 |
5 | //
6 | // cli entrypoint
7 | //
8 |
9 | if (process.env.NXAPI_DEBUG_FILE !== '0') {
10 | await initDebug(join(paths.log, 'cli'));
11 | }
12 |
13 | import('./cli.js').then(cli => cli.main.call(null));
14 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import Yargs from 'yargs';
3 | import { setGlobalDispatcher } from 'undici';
4 | import * as commands from './cli/commands.js';
5 | import { checkUpdates } from './common/update.js';
6 | import createDebug from './util/debug.js';
7 | import { dev } from './util/product.js';
8 | import { paths } from './util/storage.js';
9 | import { YargsArguments } from './util/yargs.js';
10 | import { addUserAgent } from './util/useragent.js';
11 | import { USER_AGENT_INFO_URL } from './common/constants.js';
12 | import { init as initGlobals } from './common/globals.js';
13 | import { buildEnvironmentProxyAgent } from './util/undici-proxy.js';
14 |
15 | const debug = createDebug('cli');
16 |
17 | initGlobals();
18 |
19 | const agent = buildEnvironmentProxyAgent();
20 | setGlobalDispatcher(agent);
21 |
22 | export function createYargs(argv: string[]) {
23 | const yargs = Yargs(argv).option('data-path', {
24 | describe: 'Data storage path',
25 | type: 'string',
26 | default: process.env.NXAPI_DATA_PATH || paths.data,
27 | });
28 |
29 | for (const command of Object.values(commands)) {
30 | if (command.command === 'app' && !dev) continue;
31 |
32 | // @ts-expect-error
33 | yargs.command(command);
34 | }
35 |
36 | yargs
37 | .scriptName('nxapi')
38 | .demandCommand()
39 | .help()
40 | // .version(false)
41 | .showHelpOnFail(false, 'Specify --help for available options');
42 |
43 | return yargs;
44 | }
45 |
46 | export type Arguments = YargsArguments>;
47 |
48 | // Node.js docs recommend using process.stdout.isTTY (see https://github.com/samuelthomas2774/nxapi/issues/15)
49 | const is_terminal = process.stdin.isTTY && process.stderr.isTTY;
50 |
51 | export async function main(argv = process.argv.slice(2)) {
52 | addUserAgent('nxapi-cli');
53 |
54 | if (process.env.NXAPI_USER_AGENT) {
55 | addUserAgent(process.env.NXAPI_USER_AGENT);
56 | } else if (!is_terminal) {
57 | console.warn('[warn] The nxapi command is not running in a terminal. If using the nxapi command in a script or other program, the NXAPI_USER_AGENT environment variable should be set. See ' + USER_AGENT_INFO_URL + '.');
58 | addUserAgent('unidentified-script');
59 | }
60 |
61 | const yargs = createYargs(argv);
62 |
63 | if (!process.env.NXAPI_SKIP_UPDATE_CHECK) await checkUpdates();
64 |
65 | yargs.argv;
66 | }
67 |
--------------------------------------------------------------------------------
/src/cli/android-znca-api-server-frida.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import createDebug from '../util/debug.js';
3 | import type { Arguments as ParentArguments } from '../cli.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
5 |
6 | const debug = createDebug('cli:android-znca-api-server-frida');
7 |
8 | export const command = 'android-znca-api-server-frida';
9 | export const desc = null;
10 |
11 | export function builder(yargs: Argv) {
12 | return yargs;
13 | }
14 |
15 | type Arguments = YargsArguments>;
16 |
17 | export async function handler(argv: ArgumentsCamelCase) {
18 | console.log('This command is now part of a separate package available at https://gitlab.fancy.org.uk/samuel/nxapi-znca-api or https://github.com/samuelthomas2774/nxapi-znca-api.');
19 | process.exit(1);
20 | }
21 |
--------------------------------------------------------------------------------
/src/cli/app.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import { createRequire } from 'node:module';
3 | import * as path from 'node:path';
4 | import { execFileSync } from 'node:child_process';
5 | import type { Arguments as ParentArguments } from '../cli.js';
6 | import createDebug from '../util/debug.js';
7 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
8 | import { dir } from '../util/product.js';
9 |
10 | const debug = createDebug('cli:app');
11 |
12 | export const command = 'app';
13 | export const desc = 'Start the Electron app';
14 |
15 | export function builder(yargs: Argv) {
16 | return yargs;
17 | }
18 |
19 | type Arguments = YargsArguments>;
20 |
21 | export async function handler(argv: ArgumentsCamelCase) {
22 | const require = createRequire(import.meta.url);
23 | const electron = require('electron');
24 |
25 | if (typeof electron !== 'string') {
26 | throw new Error('Already running in Electron??');
27 | }
28 |
29 | execFileSync(electron, [
30 | path.resolve(dir, 'dist', 'app', 'app-entry.cjs'),
31 | ], {
32 | stdio: 'inherit',
33 | env: {
34 | ...process.env,
35 | NXAPI_SKIP_UPDATE_CHECK: '1',
36 | },
37 | });
38 | }
39 |
--------------------------------------------------------------------------------
/src/cli/commands.ts:
--------------------------------------------------------------------------------
1 | export * as users from './users.js';
2 | export * as nso from './nso/index.js';
3 | export * as splatnet2 from './splatnet2/index.js';
4 | export * as nooklink from './nooklink/index.js';
5 | export * as splatnet3 from './splatnet3/index.js';
6 | export * as pctl from './pctl/index.js';
7 | export * as androidZncaApiServerFrida from './android-znca-api-server-frida.js';
8 | export * as presenceServer from './presence-server.js';
9 | export * as util from './util/index.js';
10 | export * as app from './app.js';
11 |
--------------------------------------------------------------------------------
/src/cli/nooklink/commands.ts:
--------------------------------------------------------------------------------
1 | export * as users from './users.js';
2 | export * as user from './user.js';
3 | export * as island from './island.js';
4 | export * as newspapers from './newspapers.js';
5 | export * as newspaper from './newspaper.js';
6 | export * as dumpNewspapers from './dump-newspapers.js';
7 | export * as keyboard from './keyboard.js';
8 | export * as reactions from './reactions.js';
9 | export * as postReaction from './post-reaction.js';
10 | export * as userToken from './user-token.js';
11 |
--------------------------------------------------------------------------------
/src/cli/nooklink/dump-newspapers.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'node:fs/promises';
2 | import * as path from 'node:path';
3 | import type { Arguments as ParentArguments } from './index.js';
4 | import createDebug from '../../util/debug.js';
5 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
6 | import { initStorage } from '../../util/storage.js';
7 | import { getUserToken } from '../../common/auth/nooklink.js';
8 |
9 | const debug = createDebug('cli:nooklink:dump-newspapers');
10 |
11 | export const command = 'dump-newspapers [directory]';
12 | export const desc = 'Download all newspaper articles';
13 |
14 | export function builder(yargs: Argv) {
15 | return yargs.positional('directory', {
16 | describe: 'Directory to write record data to',
17 | type: 'string',
18 | }).option('user', {
19 | describe: 'Nintendo Account ID',
20 | type: 'string',
21 | }).option('token', {
22 | describe: 'Nintendo Account session token',
23 | type: 'string',
24 | }).option('islander', {
25 | describe: 'NookLink user ID',
26 | type: 'string',
27 | });
28 | }
29 |
30 | type Arguments = YargsArguments>;
31 |
32 | export async function handler(argv: ArgumentsCamelCase) {
33 | const storage = await initStorage(argv.dataPath);
34 |
35 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
36 | const token: string = argv.token ||
37 | await storage.getItem('NintendoAccountToken.' + usernsid);
38 | const {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
39 |
40 | const directory = argv.directory ?? path.join(argv.dataPath, 'nooklink');
41 |
42 | await fs.mkdir(directory, {recursive: true});
43 |
44 | const latest = await nooklinkuser.getLatestNewspaper();
45 | const newspapers = await nooklinkuser.getNewspapers();
46 |
47 | for (const item of newspapers.newspapers) {
48 | const is_latest = item.findKey === latest.findKey;
49 |
50 | const filename = 'nooklink-newspaper-' + nooklinkuser.user_id + '-' + item.beginDate + '-' + item.findKey +
51 | (is_latest ? '-' + Date.now() : '') + '.json';
52 | const file = path.join(directory, filename);
53 |
54 | try {
55 | await fs.stat(file);
56 | debug('Skipping newspaper %s, date %s, file already exists', item.findKey, item.beginDate);
57 | continue;
58 | } catch (err) {}
59 |
60 | if (!is_latest) debug('Fetching newspaper %s, date %s', item.findKey, item.beginDate);
61 | const newspaper = is_latest ? latest : await nooklinkuser.getNewspaper(item.findKey);
62 |
63 | debug('Writing %s', filename);
64 | await fs.writeFile(file, JSON.stringify(newspaper, null, 4) + '\n', 'utf-8');
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/cli/nooklink/index.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import type { Arguments as ParentArguments } from '../../cli.js';
3 | import createDebug from '../../util/debug.js';
4 | import { Argv, YargsArguments } from '../../util/yargs.js';
5 | import * as commands from './commands.js';
6 |
7 | const debug = createDebug('cli:nooklink');
8 |
9 | export const command = 'nooklink ';
10 | export const desc = 'NookLink';
11 |
12 | export function builder(yargs: Argv) {
13 | for (const command of Object.values(commands)) {
14 | // @ts-expect-error
15 | yargs.command(command);
16 | }
17 |
18 | return yargs.option('znc-proxy-url', {
19 | describe: 'URL of Nintendo Switch Online app API proxy server to use',
20 | type: 'string',
21 | default: process.env.ZNC_PROXY_URL,
22 | }).option('auto-update-session', {
23 | describe: 'Automatically obtain and refresh the NookLink game web token and user token',
24 | type: 'boolean',
25 | default: true,
26 | });
27 | }
28 |
29 | export type Arguments = YargsArguments>;
30 |
--------------------------------------------------------------------------------
/src/cli/nooklink/island.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getUserToken, getWebServiceToken } from '../../common/auth/nooklink.js';
7 |
8 | const debug = createDebug('cli:nooklink:island');
9 |
10 | export const command = 'island';
11 | export const desc = 'Get the player\'s passport data (island information)';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('islander', {
21 | describe: 'NookLink user ID',
22 | type: 'string',
23 | }).option('json', {
24 | describe: 'Output raw JSON',
25 | type: 'boolean',
26 | }).option('json-pretty-print', {
27 | describe: 'Output pretty-printed JSON',
28 | type: 'boolean',
29 | });
30 | }
31 |
32 | type Arguments = YargsArguments>;
33 |
34 | export async function handler(argv: ArgumentsCamelCase) {
35 | const storage = await initStorage(argv.dataPath);
36 |
37 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
38 | const token: string = argv.token ||
39 | await storage.getItem('NintendoAccountToken.' + usernsid);
40 | const {nooklink, data: wstoken} = await getWebServiceToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
41 | const {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl);
42 |
43 | const users = await nooklink.getUsers();
44 | const user = users.users.find(u => u.id === nooklinkuser.user_id)!;
45 |
46 | const island = await nooklinkuser.getIslandProfile(user.land.id);
47 |
48 | if (argv.jsonPrettyPrint) {
49 | console.log(JSON.stringify(island, null, 4));
50 | return;
51 | }
52 | if (argv.json) {
53 | console.log(JSON.stringify(island));
54 | return;
55 | }
56 |
57 | console.log('Island', {
58 | ...island,
59 | mNormalNpc: undefined,
60 | mVillager: undefined,
61 | });
62 |
63 | const table = new Table({
64 | head: [
65 | 'Type',
66 | 'Name',
67 | 'Birthday',
68 | 'NookLink user ID',
69 | ],
70 | });
71 |
72 | for (const villager of [...island.mVillager, ...island.mNormalNpc]) {
73 | table.push([
74 | 'mPNm' in villager ? villager.mIsLandMaster ? 'Resident Representative' : 'Player' : 'NPC',
75 | 'mPNm' in villager ? villager.mPNm : villager.name,
76 | 'mPNm' in villager ?
77 | villager.mBirthDay + '/' + villager.mBirthMonth :
78 | villager.birthDay + '/' + villager.birthMonth,
79 | 'mPNm' in villager ? villager.userId ?? '' : '',
80 | ]);
81 | }
82 |
83 | console.log('Residents');
84 | console.log(table.toString());
85 | }
86 |
--------------------------------------------------------------------------------
/src/cli/nooklink/keyboard.ts:
--------------------------------------------------------------------------------
1 | import { read } from 'read';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getUserToken } from '../../common/auth/nooklink.js';
7 |
8 | const debug = createDebug('cli:nooklink:keyboard');
9 |
10 | export const command = 'keyboard [message]';
11 | export const desc = 'Send a message in an online Animal Crossing: New Horizons session';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.positional('message', {
15 | describe: 'Message text',
16 | type: 'string',
17 | }).option('user', {
18 | describe: 'Nintendo Account ID',
19 | type: 'string',
20 | }).option('token', {
21 | describe: 'Nintendo Account session token',
22 | type: 'string',
23 | }).option('islander', {
24 | describe: 'NookLink user ID',
25 | type: 'string',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | if (!argv.message) {
33 | argv.message = await read({
34 | output: process.stderr,
35 | prompt: 'Message: ',
36 | });
37 | }
38 |
39 | if (!argv.message) return;
40 | if (argv.message?.length > 32) {
41 | throw new Error('Message must be less than or equal to 32 characters');
42 | }
43 |
44 | const storage = await initStorage(argv.dataPath);
45 |
46 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
47 | const token: string = argv.token ||
48 | await storage.getItem('NintendoAccountToken.' + usernsid);
49 | const {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
50 |
51 | await nooklinkuser.keyboard(argv.message);
52 | }
53 |
--------------------------------------------------------------------------------
/src/cli/nooklink/newspapers.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getUserToken } from '../../common/auth/nooklink.js';
7 |
8 | const debug = createDebug('cli:nooklink:newspapers');
9 |
10 | export const command = 'newspapers';
11 | export const desc = 'List all newspaper issues';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('islander', {
21 | describe: 'NookLink user ID',
22 | type: 'string',
23 | }).option('json', {
24 | describe: 'Output raw JSON',
25 | type: 'boolean',
26 | }).option('json-pretty-print', {
27 | describe: 'Output pretty-printed JSON',
28 | type: 'boolean',
29 | });
30 | }
31 |
32 | type Arguments = YargsArguments>;
33 |
34 | export async function handler(argv: ArgumentsCamelCase) {
35 | const storage = await initStorage(argv.dataPath);
36 |
37 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
38 | const token: string = argv.token ||
39 | await storage.getItem('NintendoAccountToken.' + usernsid);
40 | const {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
41 |
42 | const latest = await nooklinkuser.getLatestNewspaper();
43 | const newspapers = await nooklinkuser.getNewspapers();
44 |
45 | if (argv.jsonPrettyPrint) {
46 | console.log(JSON.stringify(newspapers, null, 4));
47 | return;
48 | }
49 | if (argv.json) {
50 | console.log(JSON.stringify(newspapers));
51 | return;
52 | }
53 |
54 | const table = new Table({
55 | head: [
56 | 'ID',
57 | 'Type',
58 | 'Start date',
59 | 'End date',
60 | ],
61 | });
62 |
63 | for (const newspaper of newspapers.newspapers) {
64 | table.push([
65 | newspaper.findKey + (newspaper.findKey === latest.findKey ? ' *' : ''),
66 | newspaper.type,
67 | newspaper.beginDate,
68 | newspaper.endDate,
69 | ]);
70 | }
71 |
72 | console.log(table.toString());
73 | }
74 |
--------------------------------------------------------------------------------
/src/cli/nooklink/post-reaction.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getUserToken } from '../../common/auth/nooklink.js';
6 |
7 | const debug = createDebug('cli:nooklink:post-reaction');
8 |
9 | export const command = 'post-reaction ';
10 | export const desc = 'Send a reaction in an online Animal Crossing: New Horizons session';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.positional('reaction', {
14 | describe: 'Reaction ID',
15 | type: 'string',
16 | demandOption: true,
17 | }).option('user', {
18 | describe: 'Nintendo Account ID',
19 | type: 'string',
20 | }).option('token', {
21 | describe: 'Nintendo Account session token',
22 | type: 'string',
23 | }).option('islander', {
24 | describe: 'NookLink user ID',
25 | type: 'string',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | const storage = await initStorage(argv.dataPath);
33 |
34 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
35 | const token: string = argv.token ||
36 | await storage.getItem('NintendoAccountToken.' + usernsid);
37 | const {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
38 |
39 | const emoticons = await nooklinkuser.getEmoticons();
40 | const reaction = emoticons.emoticons.find(r => r.label.toLowerCase() === argv.reaction.toLowerCase());
41 |
42 | if (!reaction) {
43 | throw new Error('Unknown reaction "' + argv.reaction + '"');
44 | }
45 |
46 | await nooklinkuser.reaction(reaction);
47 | }
48 |
--------------------------------------------------------------------------------
/src/cli/nooklink/reactions.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getUserToken } from '../../common/auth/nooklink.js';
7 |
8 | const debug = createDebug('cli:nooklink:reactions');
9 |
10 | export const command = 'reactions';
11 | export const desc = 'List all reactions available to the authenticated user/player';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('islander', {
21 | describe: 'NookLink user ID',
22 | type: 'string',
23 | }).option('json', {
24 | describe: 'Output raw JSON',
25 | type: 'boolean',
26 | }).option('json-pretty-print', {
27 | describe: 'Output pretty-printed JSON',
28 | type: 'boolean',
29 | });
30 | }
31 |
32 | type Arguments = YargsArguments>;
33 |
34 | export async function handler(argv: ArgumentsCamelCase) {
35 | const storage = await initStorage(argv.dataPath);
36 |
37 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
38 | const token: string = argv.token ||
39 | await storage.getItem('NintendoAccountToken.' + usernsid);
40 | const {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
41 |
42 | const emoticons = await nooklinkuser.getEmoticons();
43 |
44 | if (argv.jsonPrettyPrint) {
45 | console.log(JSON.stringify(emoticons, null, 4));
46 | return;
47 | }
48 | if (argv.json) {
49 | console.log(JSON.stringify(emoticons));
50 | return;
51 | }
52 |
53 | const table = new Table({
54 | head: [
55 | 'ID',
56 | 'Name',
57 | ],
58 | });
59 |
60 | for (const emoticon of emoticons.emoticons) {
61 | table.push([
62 | emoticon.label,
63 | emoticon.name,
64 | ]);
65 | }
66 |
67 | console.log(table.toString());
68 | }
69 |
--------------------------------------------------------------------------------
/src/cli/nooklink/user-token.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getUserToken } from '../../common/auth/nooklink.js';
6 | import { NooklinkUserCliTokenData } from '../../api/nooklink.js';
7 |
8 | const debug = createDebug('cli:nooklink:user-token');
9 |
10 | export const command = 'user-token';
11 | export const desc = 'Get the player\'s NookLink user authentication token';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('islander', {
21 | describe: 'NookLink user ID',
22 | type: 'string',
23 | }).option('json', {
24 | describe: 'Output raw JSON',
25 | type: 'boolean',
26 | }).option('json-pretty-print', {
27 | describe: 'Output pretty-printed JSON',
28 | type: 'boolean',
29 | });
30 | }
31 |
32 | type Arguments = YargsArguments>;
33 |
34 | export async function handler(argv: ArgumentsCamelCase) {
35 | const storage = await initStorage(argv.dataPath);
36 |
37 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
38 | const token: string = argv.token ||
39 | await storage.getItem('NintendoAccountToken.' + usernsid);
40 | const {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
41 |
42 | if (argv.json || argv.jsonPrettyPrint) {
43 | const result: NooklinkUserCliTokenData = {
44 | gtoken: nooklinkuser.gtoken,
45 | version: nooklinkuser.client_version,
46 |
47 | auth_token: data.token.token,
48 | expires_at: data.token.expireAt,
49 | user_id: data.user_id,
50 | language: nooklinkuser.language,
51 | };
52 |
53 | console.log(JSON.stringify(result, null, argv.jsonPrettyPrint ? 4 : 0));
54 | return;
55 | }
56 |
57 | console.log(data.token.token);
58 | }
59 |
--------------------------------------------------------------------------------
/src/cli/nooklink/user.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getUserToken } from '../../common/auth/nooklink.js';
6 |
7 | const debug = createDebug('cli:nooklink:user');
8 |
9 | export const command = 'user';
10 | export const desc = 'Get the player\'s passport data (player information)';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.option('user', {
14 | describe: 'Nintendo Account ID',
15 | type: 'string',
16 | }).option('token', {
17 | describe: 'Nintendo Account session token',
18 | type: 'string',
19 | }).option('islander', {
20 | describe: 'NookLink user ID',
21 | type: 'string',
22 | }).option('json', {
23 | describe: 'Output raw JSON',
24 | type: 'boolean',
25 | }).option('json-pretty-print', {
26 | describe: 'Output pretty-printed JSON',
27 | type: 'boolean',
28 | });
29 | }
30 |
31 | type Arguments = YargsArguments>;
32 |
33 | export async function handler(argv: ArgumentsCamelCase) {
34 | const storage = await initStorage(argv.dataPath);
35 |
36 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
37 | const token: string = argv.token ||
38 | await storage.getItem('NintendoAccountToken.' + usernsid);
39 | const {nooklinkuser, data} = await getUserToken(storage, token, argv.islander, argv.zncProxyUrl, argv.autoUpdateSession);
40 |
41 | const profile = await nooklinkuser.getUserProfile();
42 |
43 | if (argv.jsonPrettyPrint) {
44 | console.log(JSON.stringify(profile, null, 4));
45 | return;
46 | }
47 | if (argv.json) {
48 | console.log(JSON.stringify(profile));
49 | return;
50 | }
51 |
52 | console.log('User', profile);
53 | }
54 |
--------------------------------------------------------------------------------
/src/cli/nooklink/users.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getWebServiceToken } from '../../common/auth/nooklink.js';
7 |
8 | const debug = createDebug('cli:nooklink:users');
9 |
10 | export const command = 'users';
11 | export const desc = 'List the authenticated user\'s NookLink enabled players';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | const storage = await initStorage(argv.dataPath);
33 |
34 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
35 | const token: string = argv.token ||
36 | await storage.getItem('NintendoAccountToken.' + usernsid);
37 | const {nooklink} = await getWebServiceToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
38 |
39 | const users = await nooklink.getUsers();
40 |
41 | if (argv.jsonPrettyPrint) {
42 | console.log(JSON.stringify(users.users, null, 4));
43 | return;
44 | }
45 | if (argv.json) {
46 | console.log(JSON.stringify(users.users));
47 | return;
48 | }
49 |
50 | const table = new Table({
51 | head: [
52 | 'ID',
53 | 'Name',
54 | 'Island ID',
55 | 'Island name',
56 | ],
57 | });
58 |
59 | for (const user of users.users) {
60 | table.push([
61 | user.id,
62 | user.name,
63 | user.land.id,
64 | user.land.name,
65 | ]);
66 | }
67 |
68 | console.log(table.toString());
69 | }
70 |
--------------------------------------------------------------------------------
/src/cli/nso/active-event.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getToken, Login } from '../../common/auth/coral.js';
6 |
7 | const debug = createDebug('cli:nso:active-event');
8 |
9 | export const command = 'active-event';
10 | export const desc = 'Show the user\'s current Online Lounge/voice chat event';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.option('user', {
14 | describe: 'Nintendo Account ID',
15 | type: 'string',
16 | }).option('token', {
17 | describe: 'Nintendo Account session token',
18 | type: 'string',
19 | }).option('json', {
20 | describe: 'Output raw JSON',
21 | type: 'boolean',
22 | }).option('json-pretty-print', {
23 | describe: 'Output pretty-printed JSON',
24 | type: 'boolean',
25 | });
26 | }
27 |
28 | type Arguments = YargsArguments>;
29 |
30 | export async function handler(argv: ArgumentsCamelCase) {
31 | const storage = await initStorage(argv.dataPath);
32 |
33 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
34 | const token: string = argv.token ||
35 | await storage.getItem('NintendoAccountToken.' + usernsid);
36 | const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
37 |
38 | if (data[Login]) {
39 | const announcements = await nso.getAnnouncements();
40 | }
41 |
42 | const webservices = await nso.getWebServices();
43 | const friends = await nso.getFriendList();
44 | const activeevent = await nso.getActiveEvent();
45 |
46 | if ('id' in activeevent) {
47 | if (argv.jsonPrettyPrint) {
48 | console.log(JSON.stringify(activeevent, null, 4));
49 | return;
50 | }
51 | if (argv.json) {
52 | console.log(JSON.stringify(activeevent));
53 | return;
54 | }
55 |
56 | console.log('Active event', activeevent);
57 | } else {
58 | if (argv.json || argv.jsonPrettyPrint) {
59 | console.log('null');
60 | return;
61 | }
62 |
63 | console.log('No active event');
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/cli/nso/add-friend.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getToken } from '../../common/auth/coral.js';
6 |
7 | const debug = createDebug('cli:nso:add-friend');
8 |
9 | export const command = 'add-friend ';
10 | export const desc = 'Send a friend request using a user\'s friend code or NSA ID';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.option('id', {
14 | describe: 'Friend code or NSA ID',
15 | type: 'string',
16 | demandOption: true,
17 | }).option('user', {
18 | describe: 'Nintendo Account ID',
19 | type: 'string',
20 | }).option('token', {
21 | describe: 'Nintendo Account session token',
22 | type: 'string',
23 | });
24 | }
25 |
26 | type Arguments = YargsArguments>;
27 |
28 | export async function handler(argv: ArgumentsCamelCase) {
29 | const storage = await initStorage(argv.dataPath);
30 |
31 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
32 | const token: string = argv.token ||
33 | await storage.getItem('NintendoAccountToken.' + usernsid);
34 | const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
35 |
36 | let nsa_id: string;
37 |
38 | if (/^\d{4}-\d{4}-\d{4}$/.test(argv.id)) {
39 | // Friend code
40 |
41 | const user = await nso.getUserByFriendCode(argv.id);
42 | nsa_id = user.nsaId;
43 |
44 | console.log('User', user);
45 | } else if (/^[0-9a-f]{16}$/.test(argv.id)) {
46 | // NSA ID
47 | nsa_id = argv.id;
48 | } else {
49 | throw new Error('Invalid ID');
50 | }
51 |
52 | if (nsa_id === data.nsoAccount.user.nsaId) {
53 | throw new Error('Cannot send a friend request to yourself');
54 | }
55 |
56 | await nso.sendFriendRequest(nsa_id);
57 |
58 | // Check if the user is now friends
59 | // This means the other user had already sent this user a friend request,
60 | // so sending them a friend request just accepted theirs
61 | const friends = await nso.getFriendList();
62 | const friend = friends.friends.find(f => f.nsaId === nsa_id);
63 |
64 | if (friend) {
65 | console.log('You are now friends with %s.', friend.name);
66 | } else {
67 | console.log('Friend request sent');
68 | console.log('The friend request can be accepted using a Nintendo Switch console, or by sending a friend request to your friend code: %s.', data.nsoAccount.user.links.friendCode.id);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/cli/nso/announcements.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getToken } from '../../common/auth/coral.js';
7 |
8 | const debug = createDebug('cli:nso:announcements');
9 |
10 | export const command = 'announcements';
11 | export const desc = 'List Nintendo Switch Online app announcements';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | console.warn('Listing announcements');
33 |
34 | const storage = await initStorage(argv.dataPath);
35 |
36 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
37 | const token: string = argv.token ||
38 | await storage.getItem('NintendoAccountToken.' + usernsid);
39 | const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
40 |
41 | const announcements = await nso.getAnnouncements();
42 | const friends = await nso.getFriendList();
43 | const webservices = await nso.getWebServices();
44 | const activeevent = await nso.getActiveEvent();
45 |
46 | if (argv.jsonPrettyPrint) {
47 | console.log(JSON.stringify(announcements, null, 4));
48 | return;
49 | }
50 | if (argv.json) {
51 | console.log(JSON.stringify(announcements));
52 | return;
53 | }
54 |
55 | const table = new Table({
56 | head: [
57 | 'ID',
58 | 'Title',
59 | 'Priority',
60 | 'Date',
61 | 'Display end date',
62 | ],
63 | });
64 |
65 | for (const announcement of announcements) {
66 | table.push([
67 | announcement.announcementId,
68 | announcement.title.substr(0, 60),
69 | announcement.priority,
70 | new Date(announcement.distributionDate * 1000).toISOString(),
71 | new Date(announcement.forceDisplayEndDate * 1000).toISOString(),
72 | ]);
73 | }
74 |
75 | console.log(table.toString());
76 | }
77 |
--------------------------------------------------------------------------------
/src/cli/nso/auth.ts:
--------------------------------------------------------------------------------
1 | import { read } from 'read';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getToken } from '../../common/auth/coral.js';
7 | import { NintendoAccountSessionAuthorisationCoral } from '../../api/coral.js';
8 |
9 | const debug = createDebug('cli:nso:auth');
10 |
11 | export const command = 'auth';
12 | export const desc = 'Generate a link to login to a Nintendo Account';
13 |
14 | export function builder(yargs: Argv) {
15 | return yargs.option('auth', {
16 | describe: 'Authenticate immediately',
17 | type: 'boolean',
18 | default: true,
19 | }).option('select', {
20 | describe: 'Set as default user (default: true if only user)',
21 | type: 'boolean',
22 | });
23 | }
24 |
25 | type Arguments = YargsArguments>;
26 |
27 | export async function handler(argv: ArgumentsCamelCase) {
28 | const authenticator = NintendoAccountSessionAuthorisationCoral.create();
29 |
30 | debug('Authentication parameters', authenticator);
31 |
32 | console.log('1. Open this URL and login to your Nintendo Account:');
33 | console.log('');
34 | console.log(authenticator.authorise_url);
35 | console.log('');
36 |
37 | console.log('2. On the "Linking an External Account" page, right click "Select this person" and copy the link. It should start with "npf71b963c1b7b6d119://auth".');
38 | console.log('');
39 |
40 | const applink = await read({
41 | output: process.stderr,
42 | prompt: `Paste the link: `,
43 | });
44 |
45 | console.log('');
46 |
47 | const authorisedurl = new URL(applink);
48 | const authorisedparams = new URLSearchParams(authorisedurl.hash.substr(1));
49 | debug('Redirect URL parameters', [...authorisedparams.entries()]);
50 |
51 | const token = await authenticator.getSessionToken(authorisedparams);
52 |
53 | console.log('Session token', token);
54 |
55 | if (argv.auth) {
56 | const storage = await initStorage(argv.dataPath);
57 |
58 | const {nso, data} = await getToken(storage, token.session_token, argv.zncProxyUrl);
59 |
60 | console.log('Authenticated as Nintendo Account %s (NA %s, NSO %s)',
61 | data.user.screenName, data.user.nickname, data.nsoAccount.user.name);
62 |
63 | await storage.setItem('NintendoAccountToken.' + data.user.id, token.session_token);
64 |
65 | const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
66 | users.add(data.user.id);
67 | await storage.setItem('NintendoAccountIds', [...users]);
68 |
69 | if ('select' in argv ? argv.select : users.size === 1) {
70 | await storage.setItem('SelectedUser', data.user.id);
71 |
72 | console.log('Set as default user');
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/cli/nso/commands.ts:
--------------------------------------------------------------------------------
1 | export * as token from './token.js';
2 | export * as auth from './auth.js';
3 | export * as user from './user.js';
4 | export * as permissions from './permissions.js';
5 | export * as announcements from './announcements.js';
6 | export * as webservices from './webservices.js';
7 | export * as webservicetoken from './webservicetoken.js';
8 | export * as friends from './friends.js';
9 | export * as activeEvent from './active-event.js';
10 | export * as presence from './presence.js';
11 | export * as notify from './notify.js';
12 | export * as httpServer from './http-server.js';
13 | export * as zncProxyTokens from './znc-proxy-tokens.js';
14 | export * as friendcode from './friendcode.js';
15 | export * as lookup from './lookup.js';
16 | export * as addFriend from './add-friend.js';
17 |
--------------------------------------------------------------------------------
/src/cli/nso/friendcode.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getToken, Login } from '../../common/auth/coral.js';
6 |
7 | const debug = createDebug('cli:nso:friendcode');
8 |
9 | export const command = 'friendcode';
10 | export const desc = 'Get a friend code URL';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.option('user', {
14 | describe: 'Nintendo Account ID',
15 | type: 'string',
16 | }).option('token', {
17 | describe: 'Nintendo Account session token',
18 | type: 'string',
19 | }).option('json', {
20 | describe: 'Output raw JSON',
21 | type: 'boolean',
22 | }).option('json-pretty-print', {
23 | describe: 'Output pretty-printed JSON',
24 | type: 'boolean',
25 | });
26 | }
27 |
28 | type Arguments = YargsArguments>;
29 |
30 | export async function handler(argv: ArgumentsCamelCase) {
31 | const storage = await initStorage(argv.dataPath);
32 |
33 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
34 | const token: string = argv.token ||
35 | await storage.getItem('NintendoAccountToken.' + usernsid);
36 | const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
37 |
38 | if (data[Login]) {
39 | const announcements = await nso.getAnnouncements();
40 | const friends = await nso.getFriendList();
41 | const webservices = await nso.getWebServices();
42 | const activeevent = await nso.getActiveEvent();
43 | }
44 |
45 | const friendcodeurl = await nso.getFriendCodeUrl();
46 |
47 | if (argv.jsonPrettyPrint) {
48 | console.log(JSON.stringify(friendcodeurl, null, 4));
49 | return;
50 | }
51 | if (argv.json) {
52 | console.log(JSON.stringify(friendcodeurl));
53 | return;
54 | }
55 |
56 | console.warn('Friend code', friendcodeurl);
57 | console.log(friendcodeurl.url);
58 | }
59 |
--------------------------------------------------------------------------------
/src/cli/nso/index.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import type { Arguments as ParentArguments } from '../../cli.js';
3 | import createDebug from '../../util/debug.js';
4 | import { Argv, YargsArguments } from '../../util/yargs.js';
5 | import * as commands from './commands.js';
6 |
7 | const debug = createDebug('cli:nso');
8 |
9 | export const command = 'nso ';
10 | export const desc = 'Nintendo Switch Online';
11 |
12 | export function builder(yargs: Argv) {
13 | for (const command of Object.values(commands)) {
14 | // @ts-expect-error
15 | yargs.command(command);
16 | }
17 |
18 | return yargs.option('znc-proxy-url', {
19 | describe: 'URL of Nintendo Switch Online app API proxy server to use',
20 | type: 'string',
21 | default: process.env.ZNC_PROXY_URL,
22 | });
23 | }
24 |
25 | export type Arguments = YargsArguments>;
26 |
--------------------------------------------------------------------------------
/src/cli/nso/lookup.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getToken, Login } from '../../common/auth/coral.js';
6 |
7 | const debug = createDebug('cli:nso:lookup');
8 |
9 | export const command = 'lookup ';
10 | export const desc = 'Lookup a user using their friend code';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.option('id', {
14 | describe: 'Friend code',
15 | type: 'string',
16 | demandOption: true,
17 | }).option('user', {
18 | describe: 'Nintendo Account ID',
19 | type: 'string',
20 | }).option('token', {
21 | describe: 'Nintendo Account session token',
22 | type: 'string',
23 | }).option('json', {
24 | describe: 'Output raw JSON',
25 | type: 'boolean',
26 | }).option('json-pretty-print', {
27 | describe: 'Output pretty-printed JSON',
28 | type: 'boolean',
29 | });
30 | }
31 |
32 | type Arguments = YargsArguments>;
33 |
34 | export async function handler(argv: ArgumentsCamelCase) {
35 | const storage = await initStorage(argv.dataPath);
36 |
37 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
38 | const token: string = argv.token ||
39 | await storage.getItem('NintendoAccountToken.' + usernsid);
40 | const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
41 |
42 | if (data[Login]) {
43 | const announcements = await nso.getAnnouncements();
44 | const friends = await nso.getFriendList();
45 | const webservices = await nso.getWebServices();
46 | const activeevent = await nso.getActiveEvent();
47 | }
48 |
49 | const user = await nso.getUserByFriendCode(argv.id);
50 |
51 | if (argv.jsonPrettyPrint) {
52 | console.log(JSON.stringify(user, null, 4));
53 | return;
54 | }
55 | if (argv.json) {
56 | console.log(JSON.stringify(user));
57 | return;
58 | }
59 |
60 | console.log('User', user);
61 | }
62 |
--------------------------------------------------------------------------------
/src/cli/nso/permissions.ts:
--------------------------------------------------------------------------------
1 | import { PresencePermissions } from '../../api/coral-types.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getToken, Login } from '../../common/auth/coral.js';
7 |
8 | const debug = createDebug('cli:nso:permissions');
9 |
10 | export const command = 'permissions';
11 | export const desc = 'Get or update Nintendo Switch presence permissions';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | }).option('presence', {
27 | describe: 'New presence permission',
28 | type: 'string',
29 | });
30 | }
31 |
32 | type Arguments = YargsArguments>;
33 |
34 | export async function handler(argv: ArgumentsCamelCase) {
35 | if (argv.presence && !['FRIENDS', 'FAVORITE_FRIENDS', 'SELF'].includes(argv.presence)) {
36 | throw new Error('Invalid permissions');
37 | }
38 |
39 | const storage = await initStorage(argv.dataPath);
40 |
41 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
42 | const token: string = argv.token ||
43 | await storage.getItem('NintendoAccountToken.' + usernsid);
44 | const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
45 |
46 | if (data[Login]) {
47 | const announcements = await nso.getAnnouncements();
48 | const friends = await nso.getFriendList();
49 | const webservices = await nso.getWebServices();
50 | const activeevent = await nso.getActiveEvent();
51 | }
52 |
53 | const permissions = await nso.getCurrentUserPermissions();
54 |
55 | if (argv.presence) {
56 | await nso.updateCurrentUserPermissions(argv.presence as PresencePermissions,
57 | permissions.permissions.presence, permissions.etag);
58 | } else {
59 | if (argv.jsonPrettyPrint) {
60 | console.log(JSON.stringify(permissions, null, 4));
61 | return;
62 | }
63 | if (argv.json) {
64 | console.log(JSON.stringify(permissions));
65 | return;
66 | }
67 |
68 | console.log('Presence is visible to %s', permissions.permissions.presence);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/cli/nso/token.ts:
--------------------------------------------------------------------------------
1 | import { read } from 'read';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getToken } from '../../common/auth/coral.js';
7 |
8 | const debug = createDebug('cli:nso:token');
9 |
10 | export const command = 'token [token]';
11 | export const desc = 'Set the default Nintendo Account session token';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.positional('token', {
15 | describe: 'Nintendo Account session token (it is recommended this is not set and you enter it interactively)',
16 | type: 'string',
17 | }).option('select', {
18 | describe: 'Set as default user (default: true if only user)',
19 | type: 'boolean',
20 | });
21 | }
22 |
23 | type Arguments = YargsArguments>;
24 |
25 | export async function handler(argv: ArgumentsCamelCase) {
26 | const storage = await initStorage(argv.dataPath);
27 |
28 | if (!argv.token) {
29 | argv.token = await read({
30 | output: process.stderr,
31 | prompt: `Token: `,
32 | silent: true,
33 | });
34 | }
35 |
36 | const {nso, data} = await getToken(storage, argv.token, argv.zncProxyUrl);
37 |
38 | console.warn('Authenticated as Nintendo Account %s (NA %s, NSO %s)',
39 | data.user.screenName, data.user.nickname, data.nsoAccount.user.name);
40 |
41 | await storage.setItem('NintendoAccountToken.' + data.user.id, argv.token);
42 |
43 | const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
44 | users.add(data.user.id);
45 | await storage.setItem('NintendoAccountIds', [...users]);
46 |
47 | console.log('Saved token');
48 |
49 | if ('select' in argv ? argv.select : users.size === 1) {
50 | await storage.setItem('SelectedUser', data.user.id);
51 |
52 | console.log('Set as default user');
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/cli/nso/user.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getToken, Login } from '../../common/auth/coral.js';
6 |
7 | const debug = createDebug('cli:nso:user');
8 |
9 | export const command = 'user';
10 | export const desc = 'Get the authenticated Nintendo Account';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.option('user', {
14 | describe: 'Nintendo Account ID',
15 | type: 'string',
16 | }).option('token', {
17 | describe: 'Nintendo Account session token',
18 | type: 'string',
19 | }).option('force-refresh', {
20 | describe: 'Always fetch Nintendo Switch user data (not including Nintendo Account user data)',
21 | type: 'boolean',
22 | default: false,
23 | });
24 | }
25 |
26 | type Arguments = YargsArguments>;
27 |
28 | export async function handler(argv: ArgumentsCamelCase) {
29 | const storage = await initStorage(argv.dataPath);
30 |
31 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
32 | const token: string = argv.token ||
33 | await storage.getItem('NintendoAccountToken.' + usernsid);
34 | const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
35 |
36 | if (data[Login]) {
37 | const announcements = await nso.getAnnouncements();
38 | const friends = await nso.getFriendList();
39 | const webservices = await nso.getWebServices();
40 | const activeevent = await nso.getActiveEvent();
41 | }
42 |
43 | if (argv.forceRefresh && !data[Login]) {
44 | const user = await nso.getCurrentUser();
45 |
46 | console.log('Nintendo Account', data.user);
47 | console.log('Nintendo Switch user', user);
48 | } else {
49 | console.log('Nintendo Account', data.user);
50 | console.log('Nintendo Switch user', data.nsoAccount.user);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/cli/nso/webservices.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getToken, Login } from '../../common/auth/coral.js';
7 |
8 | const debug = createDebug('cli:nso:webservices');
9 |
10 | export const command = 'webservices';
11 | export const desc = 'List Nintendo Switch Online web services';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | console.warn('Listing web services');
33 |
34 | const storage = await initStorage(argv.dataPath);
35 |
36 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
37 | const token: string = argv.token ||
38 | await storage.getItem('NintendoAccountToken.' + usernsid);
39 | const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
40 |
41 | if (data[Login]) {
42 | const announcements = await nso.getAnnouncements();
43 | }
44 |
45 | const friends = await nso.getFriendList();
46 | const webservices = await nso.getWebServices();
47 | const activeevent = await nso.getActiveEvent();
48 |
49 | if (argv.jsonPrettyPrint) {
50 | console.log(JSON.stringify(webservices, null, 4));
51 | return;
52 | }
53 | if (argv.json) {
54 | console.log(JSON.stringify(webservices));
55 | return;
56 | }
57 |
58 | const table = new Table({
59 | head: [
60 | 'ID',
61 | 'Name',
62 | 'URL',
63 | ],
64 | });
65 |
66 | for (const webservice of webservices) {
67 | table.push([
68 | webservice.id,
69 | webservice.name,
70 | webservice.uri,
71 | ]);
72 | }
73 |
74 | console.log(table.toString());
75 | }
76 |
--------------------------------------------------------------------------------
/src/cli/pctl/auth.ts:
--------------------------------------------------------------------------------
1 | import { read } from 'read';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getPctlToken } from '../../common/auth/moon.js';
7 | import { NintendoAccountSessionAuthorisationMoon } from '../../api/moon.js';
8 |
9 | const debug = createDebug('cli:pctl:auth');
10 |
11 | export const command = 'auth';
12 | export const desc = 'Generate a link to login to a Nintendo Account';
13 |
14 | export function builder(yargs: Argv) {
15 | return yargs.option('auth', {
16 | describe: 'Authenticate immediately',
17 | type: 'boolean',
18 | default: true,
19 | }).option('select', {
20 | describe: 'Set as default user (default: true if only user)',
21 | type: 'boolean',
22 | });
23 | }
24 |
25 | type Arguments = YargsArguments>;
26 |
27 | export async function handler(argv: ArgumentsCamelCase) {
28 | const authenticator = NintendoAccountSessionAuthorisationMoon.create();
29 |
30 | debug('Authentication parameters', authenticator);
31 |
32 | console.log('1. Open this URL and login to your Nintendo Account:');
33 | console.log('');
34 | console.log(authenticator.authorise_url);
35 | console.log('');
36 |
37 | console.log('2. On the "Linking an External Account" page, right click "Select this person" and copy the link. It should start with "npf54789befb391a838://auth".');
38 | console.log('');
39 |
40 | const applink = await read({
41 | output: process.stderr,
42 | prompt: `Paste the link: `,
43 | });
44 |
45 | console.log('');
46 |
47 | const authorisedurl = new URL(applink);
48 | const authorisedparams = new URLSearchParams(authorisedurl.hash.substr(1));
49 | debug('Redirect URL parameters', [...authorisedparams.entries()]);
50 |
51 | const token = await authenticator.getSessionToken(authorisedparams);
52 |
53 | console.log('Session token', token);
54 |
55 | if (argv.auth) {
56 | const storage = await initStorage(argv.dataPath);
57 |
58 | const {moon, data} = await getPctlToken(storage, token.session_token);
59 |
60 | console.log('Authenticated as Nintendo Account %s (%s)',
61 | data.user.nickname, data.user.id);
62 |
63 | await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, token.session_token);
64 |
65 | const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
66 | users.add(data.user.id);
67 | await storage.setItem('NintendoAccountIds', [...users]);
68 |
69 | if ('select' in argv ? argv.select : users.size === 1) {
70 | await storage.setItem('SelectedUser', data.user.id);
71 |
72 | console.log('Set as default user');
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/cli/pctl/commands.ts:
--------------------------------------------------------------------------------
1 | export * as token from './token.js';
2 | export * as auth from './auth.js';
3 | export * as devices from './devices.js';
4 | export * as dailySummaries from './daily-summaries.js';
5 | export * as monthlySummaries from './monthly-summaries.js';
6 | export * as monthlySummary from './monthly-summary.js';
7 | export * as settings from './settings.js';
8 | export * as dumpSummaries from './dump-summaries.js';
9 |
--------------------------------------------------------------------------------
/src/cli/pctl/daily-summaries.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { hrduration } from '../../util/misc.js';
7 | import { getPctlToken } from '../../common/auth/moon.js';
8 |
9 | const debug = createDebug('cli:pctl:daily-summaries');
10 |
11 | export const command = 'daily-summaries ';
12 | export const desc = 'Show daily summaries';
13 |
14 | export function builder(yargs: Argv) {
15 | return yargs.positional('device', {
16 | describe: 'Nintendo Switch device ID',
17 | type: 'string',
18 | demandOption: true,
19 | }).option('user', {
20 | describe: 'Nintendo Account ID',
21 | type: 'string',
22 | }).option('token', {
23 | describe: 'Nintendo Account session token',
24 | type: 'string',
25 | }).option('json', {
26 | describe: 'Output raw JSON',
27 | type: 'boolean',
28 | }).option('json-pretty-print', {
29 | describe: 'Output pretty-printed JSON',
30 | type: 'boolean',
31 | });
32 | }
33 |
34 | type Arguments = YargsArguments>;
35 |
36 | export async function handler(argv: ArgumentsCamelCase) {
37 | const storage = await initStorage(argv.dataPath);
38 |
39 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
40 | const token: string = argv.token ||
41 | await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
42 | const {moon, data} = await getPctlToken(storage, token);
43 |
44 | const summaries = await moon.getDailySummaries(argv.device);
45 |
46 | if (argv.jsonPrettyPrint) {
47 | console.log(JSON.stringify(summaries, null, 4));
48 | return;
49 | }
50 | if (argv.json) {
51 | console.log(JSON.stringify(summaries));
52 | return;
53 | }
54 |
55 | const table = new Table({
56 | head: [
57 | 'Date',
58 | 'Status',
59 | 'Play time',
60 | 'Misc. time',
61 | 'Titles played',
62 | 'Users played',
63 | 'Notices',
64 | ],
65 | });
66 |
67 | for (const summary of summaries.items) {
68 | table.push([
69 | summary.date,
70 | summary.result,
71 | hrduration(summary.playingTime / 60, true),
72 | hrduration(summary.miscTime / 60, true),
73 | summary.playedApps.map(t => t.title).join('\n'),
74 | summary.devicePlayers.map(p => p.nickname)
75 | .concat(summary.anonymousPlayer ? ['Unknown user'] : []).join('\n'),
76 | [...summary.importantInfos, ...summary.observations.map(o => o.type)].join('\n'),
77 | ]);
78 | }
79 |
80 | console.log(table.toString());
81 | }
82 |
--------------------------------------------------------------------------------
/src/cli/pctl/devices.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getPctlToken } from '../../common/auth/moon.js';
7 |
8 | const debug = createDebug('cli:pctl:devices');
9 |
10 | export const command = 'devices';
11 | export const desc = 'List Nintendo Switch consoles';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | console.warn('Listing devices');
33 |
34 | const storage = await initStorage(argv.dataPath);
35 |
36 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
37 | const token: string = argv.token ||
38 | await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
39 | const {moon, data} = await getPctlToken(storage, token);
40 |
41 | const devices = await moon.getDevices();
42 |
43 | if (argv.jsonPrettyPrint) {
44 | console.log(JSON.stringify(devices, null, 4));
45 | return;
46 | }
47 | if (argv.json) {
48 | console.log(JSON.stringify(devices));
49 | return;
50 | }
51 |
52 | const table = new Table({
53 | head: [
54 | 'ID',
55 | 'Label',
56 | 'Serial number',
57 | 'Software version',
58 | 'PIN',
59 | 'Last synchronised',
60 | ],
61 | });
62 |
63 | for (const device of devices.items) {
64 | table.push([
65 | device.deviceId,
66 | device.label,
67 | device.device.serialNumber,
68 | device.device.firmwareVersion.displayedVersion + ' (' + device.device.firmwareVersion.internalVersion + ')',
69 | device.device.synchronizedUnlockCode,
70 | new Date(device.device.synchronizedParentalControlSetting.synchronizedAt * 1000).toISOString(),
71 | ]);
72 | }
73 |
74 | console.log(table.toString());
75 | }
76 |
--------------------------------------------------------------------------------
/src/cli/pctl/index.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from '../../cli.js';
2 | import createDebug from '../../util/debug.js';
3 | import { Argv, YargsArguments } from '../../util/yargs.js';
4 | import * as commands from './commands.js';
5 |
6 | const debug = createDebug('cli:pctl');
7 |
8 | export const command = 'pctl ';
9 | export const desc = 'Nintendo Switch Parental Controls';
10 |
11 | export function builder(yargs: Argv) {
12 | for (const command of Object.values(commands)) {
13 | // @ts-expect-error
14 | yargs.command(command);
15 | }
16 |
17 | return yargs;
18 | }
19 |
20 | export type Arguments = YargsArguments>;
21 |
--------------------------------------------------------------------------------
/src/cli/pctl/monthly-summaries.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getPctlToken } from '../../common/auth/moon.js';
7 |
8 | const debug = createDebug('cli:pctl:monthly-summaries');
9 |
10 | export const command = 'monthly-summaries ';
11 | export const desc = 'List monthly summaries';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.positional('device', {
15 | describe: 'Nintendo Switch device ID',
16 | type: 'string',
17 | demandOption: true,
18 | }).option('user', {
19 | describe: 'Nintendo Account ID',
20 | type: 'string',
21 | }).option('token', {
22 | describe: 'Nintendo Account session token',
23 | type: 'string',
24 | });
25 | }
26 |
27 | type Arguments = YargsArguments>;
28 |
29 | export async function handler(argv: ArgumentsCamelCase) {
30 | const storage = await initStorage(argv.dataPath);
31 |
32 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
33 | const token: string = argv.token ||
34 | await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
35 | const {moon, data} = await getPctlToken(storage, token);
36 |
37 | const summaries = await moon.getMonthlySummaries(argv.device);
38 |
39 | const table = new Table({
40 | head: [
41 | 'Month',
42 | ],
43 | });
44 |
45 | for (const summary of summaries.items) {
46 | table.push([
47 | summary.month,
48 | ]);
49 | }
50 |
51 | console.log(table.toString());
52 | }
53 |
--------------------------------------------------------------------------------
/src/cli/pctl/monthly-summary.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getPctlToken } from '../../common/auth/moon.js';
7 |
8 | const debug = createDebug('cli:pctl:monthly-summary');
9 |
10 | export const command = 'monthly-summary ';
11 | export const desc = 'Show monthly summary data';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.positional('device', {
15 | describe: 'Nintendo Switch device ID',
16 | type: 'string',
17 | demandOption: true,
18 | }).positional('month', {
19 | describe: 'Report month',
20 | type: 'string',
21 | demandOption: true,
22 | }).option('user', {
23 | describe: 'Nintendo Account ID',
24 | type: 'string',
25 | }).option('token', {
26 | describe: 'Nintendo Account session token',
27 | type: 'string',
28 | }).option('json', {
29 | describe: 'Output raw JSON',
30 | type: 'boolean',
31 | }).option('json-pretty-print', {
32 | describe: 'Output pretty-printed JSON',
33 | type: 'boolean',
34 | });
35 | }
36 |
37 | type Arguments = YargsArguments>;
38 |
39 | export async function handler(argv: ArgumentsCamelCase) {
40 | const storage = await initStorage(argv.dataPath);
41 |
42 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
43 | const token: string = argv.token ||
44 | await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
45 | const {moon, data} = await getPctlToken(storage, token);
46 |
47 | const summary = await moon.getMonthlySummary(argv.device, argv.month);
48 |
49 | if (argv.jsonPrettyPrint) {
50 | console.log(JSON.stringify(summary, null, 4));
51 | return;
52 | }
53 | if (argv.json) {
54 | console.log(JSON.stringify(summary));
55 | return;
56 | }
57 |
58 | const titles = new Table({
59 | head: [
60 | 'Title',
61 | 'First played',
62 | 'Days',
63 | 'Ranking',
64 | ],
65 | });
66 |
67 | for (const title of summary.playedApps) {
68 | titles.push([
69 | title.title,
70 | title.firstPlayDate,
71 | title.playingDays,
72 | title.position,
73 | ]);
74 | }
75 |
76 | console.log(titles.toString());
77 | }
78 |
--------------------------------------------------------------------------------
/src/cli/pctl/settings.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getPctlToken } from '../../common/auth/moon.js';
6 |
7 | const debug = createDebug('cli:pctl:settings');
8 |
9 | export const command = 'settings ';
10 | export const desc = 'Show parental control setting state';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.positional('device', {
14 | describe: 'Nintendo Switch device ID',
15 | type: 'string',
16 | demandOption: true,
17 | }).option('user', {
18 | describe: 'Nintendo Account ID',
19 | type: 'string',
20 | }).option('token', {
21 | describe: 'Nintendo Account session token',
22 | type: 'string',
23 | });
24 | }
25 |
26 | type Arguments = YargsArguments>;
27 |
28 | export async function handler(argv: ArgumentsCamelCase) {
29 | const storage = await initStorage(argv.dataPath);
30 |
31 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
32 | const token: string = argv.token ||
33 | await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
34 | const {moon, data} = await getPctlToken(storage, token);
35 |
36 | const d = await moon.getParentalControlSettingState(argv.device);
37 |
38 | console.log(d);
39 | }
40 |
--------------------------------------------------------------------------------
/src/cli/pctl/token.ts:
--------------------------------------------------------------------------------
1 | import { read } from 'read';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getPctlToken } from '../../common/auth/moon.js';
7 |
8 | const debug = createDebug('cli:pctl:token');
9 |
10 | export const command = 'token [token]';
11 | export const desc = 'Authenticate with a Nintendo Account session token';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.positional('token', {
15 | describe: 'Nintendo Account session token (it is recommended this is not set and you enter it interactively)',
16 | type: 'string',
17 | }).option('select', {
18 | describe: 'Set as default user (default: true if only user)',
19 | type: 'boolean',
20 | });
21 | }
22 |
23 | type Arguments = YargsArguments>;
24 |
25 | export async function handler(argv: ArgumentsCamelCase) {
26 | const storage = await initStorage(argv.dataPath);
27 |
28 | if (!argv.token) {
29 | argv.token = await read({
30 | output: process.stderr,
31 | prompt: `Token: `,
32 | silent: true,
33 | });
34 | }
35 |
36 | const {moon, data} = await getPctlToken(storage, argv.token);
37 |
38 | console.warn('Authenticated as Nintendo Account %s (%s)',
39 | data.user.nickname, data.user.id);
40 |
41 | await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, argv.token);
42 |
43 | const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
44 | users.add(data.user.id);
45 | await storage.setItem('NintendoAccountIds', [...users]);
46 |
47 | console.log('Saved token');
48 |
49 | if ('select' in argv ? argv.select : users.size === 1) {
50 | await storage.setItem('SelectedUser', data.user.id);
51 |
52 | console.log('Set as default user');
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/cli/pctl/user.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import { getPctlToken } from '../../common/auth/moon.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 |
7 | const debug = createDebug('cli:pctl:user');
8 |
9 | export const command = 'user';
10 | export const desc = 'Get the authenticated Nintendo Account';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.option('user', {
14 | describe: 'Nintendo Account ID',
15 | type: 'string',
16 | }).option('token', {
17 | describe: 'Nintendo Account session token',
18 | type: 'string',
19 | });
20 | }
21 |
22 | type Arguments = YargsArguments>;
23 |
24 | export async function handler(argv: ArgumentsCamelCase) {
25 | const storage = await initStorage(argv.dataPath);
26 |
27 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
28 | const token: string = argv.token ||
29 | await storage.getItem('NintendoAccountToken-pctl.' + usernsid);
30 | const {moon, data} = await getPctlToken(storage, token);
31 |
32 | console.log('Nintendo Account', data.user);
33 | }
34 |
--------------------------------------------------------------------------------
/src/cli/splatnet2/commands.ts:
--------------------------------------------------------------------------------
1 | export * as user from './user.js';
2 | export * as token from './token.js';
3 | export * as stages from './stages.js';
4 | export * as challenges from './challenges.js';
5 | export * as weapons from './weapons.js';
6 | export * as hero from './hero.js';
7 | export * as battles from './battles.js';
8 | export * as schedule from './schedule.js';
9 | export * as dumpResults from './dump-results.js';
10 | export * as dumpRecords from './dump-records.js';
11 | export * as monitor from './monitor.js';
12 | export * as xRankSeasons from './x-rank-seasons.js';
13 |
--------------------------------------------------------------------------------
/src/cli/splatnet2/dump-results.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'node:path';
2 | import * as fs from 'node:fs/promises';
3 | import type { Arguments as ParentArguments } from './index.js';
4 | import createDebug from '../../util/debug.js';
5 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
6 | import { initStorage } from '../../util/storage.js';
7 | import { getIksmToken } from '../../common/auth/splatnet2.js';
8 | import { dumpCoopResults, dumpResults } from '../../common/splatnet2/dump-results.js';
9 |
10 | const debug = createDebug('cli:splatnet2:dump-results');
11 |
12 | export const command = 'dump-results [directory]';
13 | export const desc = 'Download all battle and coop results';
14 |
15 | export function builder(yargs: Argv) {
16 | return yargs.positional('directory', {
17 | describe: 'Directory to write record data to',
18 | type: 'string',
19 | }).option('user', {
20 | describe: 'Nintendo Account ID',
21 | type: 'string',
22 | }).option('token', {
23 | describe: 'Nintendo Account session token',
24 | type: 'string',
25 | }).option('battles', {
26 | describe: 'Include regular/ranked/private/festival battle results',
27 | type: 'boolean',
28 | default: true,
29 | }).option('battle-summary-image', {
30 | describe: 'Include regular/ranked/private/festival battle summary image',
31 | type: 'boolean',
32 | default: false,
33 | }).option('battle-images', {
34 | describe: 'Include regular/ranked/private/festival battle result images',
35 | type: 'boolean',
36 | default: false,
37 | }).option('coop', {
38 | describe: 'Include coop (Salmon Run) results',
39 | type: 'boolean',
40 | default: true,
41 | }).option('check-updated', {
42 | describe: 'Only download data if user records have been updated',
43 | type: 'boolean',
44 | default: true,
45 | });
46 | }
47 |
48 | type Arguments = YargsArguments>;
49 |
50 | export async function handler(argv: ArgumentsCamelCase) {
51 | const storage = await initStorage(argv.dataPath);
52 |
53 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
54 | const token: string = argv.token ||
55 | await storage.getItem('NintendoAccountToken.' + usernsid);
56 | const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
57 |
58 | const directory = argv.directory ?? path.join(argv.dataPath, 'splatnet2');
59 |
60 | await fs.mkdir(directory, {recursive: true});
61 |
62 | const updated = argv.checkUpdated ? new Date((await splatnet.getRecords()).records.update_time * 1000) : undefined;
63 |
64 | const records = await splatnet.getRecords();
65 |
66 | if (argv.battles) {
67 | await dumpResults(splatnet, directory, records.records.unique_id,
68 | argv.battleImages, argv.battleSummaryImage, updated);
69 | }
70 | if (argv.coop) {
71 | await dumpCoopResults(splatnet, directory, records.records.unique_id, updated);
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/cli/splatnet2/index.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import type { Arguments as ParentArguments } from '../../cli.js';
3 | import createDebug from '../../util/debug.js';
4 | import { Argv, YargsArguments } from '../../util/yargs.js';
5 | import * as commands from './commands.js';
6 |
7 | const debug = createDebug('cli:splatnet2');
8 |
9 | export const command = 'splatnet2 ';
10 | export const desc = 'SplatNet 2';
11 |
12 | export function builder(yargs: Argv) {
13 | for (const command of Object.values(commands)) {
14 | // @ts-expect-error
15 | yargs.command(command);
16 | }
17 |
18 | return yargs.option('znc-proxy-url', {
19 | describe: 'URL of Nintendo Switch Online app API proxy server to use',
20 | type: 'string',
21 | default: process.env.ZNC_PROXY_URL,
22 | }).option('auto-update-session', {
23 | alias: ['auto-update-iksm-session'],
24 | describe: 'Automatically obtain and refresh the iksm_session cookie',
25 | type: 'boolean',
26 | default: true,
27 | });
28 | }
29 |
30 | export type Arguments = YargsArguments>;
31 |
--------------------------------------------------------------------------------
/src/cli/splatnet2/schedule.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getIksmToken } from '../../common/auth/splatnet2.js';
7 |
8 | const debug = createDebug('cli:splatnet2:schedule');
9 |
10 | export const command = 'schedule';
11 | export const desc = 'Show stage schedules';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | const storage = await initStorage(argv.dataPath);
33 |
34 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
35 | const token: string = argv.token ||
36 | await storage.getItem('NintendoAccountToken.' + usernsid);
37 | const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
38 |
39 | const schedules = await splatnet.getSchedules();
40 |
41 | if (argv.jsonPrettyPrint) {
42 | console.log(JSON.stringify(schedules, null, 4));
43 | return;
44 | }
45 | if (argv.json) {
46 | console.log(JSON.stringify(schedules));
47 | return;
48 | }
49 |
50 | for (const [text, schedule] of [
51 | ['Regular Battle', schedules.regular],
52 | ['Ranked Battle', schedules.gachi],
53 | ['League Battle', schedules.league],
54 | ] as const) {
55 | const table = new Table({
56 | head: [
57 | 'ID',
58 | 'Start',
59 | 'Rule',
60 | 'Stage',
61 | 'Stage',
62 | ],
63 | });
64 |
65 | for (const item of schedule) {
66 | table.push([
67 | item.id,
68 | new Date(item.start_time * 1000).toISOString(),
69 | item.rule.name,
70 | item.stage_a.name,
71 | item.stage_b.name,
72 | ]);
73 | }
74 |
75 | console.log(text);
76 | console.log(table.toString());
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/cli/splatnet2/stages.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getIksmToken } from '../../common/auth/splatnet2.js';
7 |
8 | const debug = createDebug('cli:splatnet2:stages');
9 |
10 | export const command = 'stages';
11 | export const desc = 'List stages';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | const storage = await initStorage(argv.dataPath);
33 |
34 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
35 | const token: string = argv.token ||
36 | await storage.getItem('NintendoAccountToken.' + usernsid);
37 | const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
38 |
39 | const stages = await splatnet.getStages();
40 |
41 | if (argv.jsonPrettyPrint) {
42 | console.log(JSON.stringify(stages, null, 4));
43 | return;
44 | }
45 | if (argv.json) {
46 | console.log(JSON.stringify(stages));
47 | return;
48 | }
49 |
50 | const table = new Table({
51 | head: [
52 | 'ID',
53 | 'Name',
54 | ],
55 | });
56 |
57 | stages.stages.sort((a, b) => parseInt(a.id) > parseInt(b.id) ? 1 : parseInt(a.id) < parseInt(b.id) ? -1 : 0);
58 |
59 | for (const stage of stages.stages) {
60 | table.push([
61 | stage.id,
62 | stage.name,
63 | ]);
64 | }
65 |
66 | console.log(table.toString());
67 | }
68 |
--------------------------------------------------------------------------------
/src/cli/splatnet2/token.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getIksmToken } from '../../common/auth/splatnet2.js';
6 | import { SplatNet2CliTokenData } from '../../api/splatnet2.js';
7 |
8 | const debug = createDebug('cli:splatnet2:token');
9 |
10 | export const command = 'token';
11 | export const desc = 'Get the authenticated Nintendo Account\'s SplatNet 2 user data and iksm_session cookie';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | const storage = await initStorage(argv.dataPath);
33 |
34 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
35 | const token: string = argv.token || await storage.getItem('NintendoAccountToken.' + usernsid);
36 | const {splatnet, data} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
37 |
38 | if (argv.json || argv.jsonPrettyPrint) {
39 | const result: SplatNet2CliTokenData = {
40 | iksm_session: data.iksm_session,
41 | language: data.language,
42 | region: data.region,
43 | user_id: data.user_id,
44 | nsa_id: data.nsa_id,
45 | };
46 |
47 | console.log(JSON.stringify(result, null, argv.jsonPrettyPrint ? 4 : 0));
48 | return;
49 | }
50 |
51 | console.log(data.iksm_session);
52 | }
53 |
--------------------------------------------------------------------------------
/src/cli/splatnet2/user.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getIksmToken } from '../../common/auth/splatnet2.js';
6 |
7 | const debug = createDebug('cli:splatnet2:user');
8 |
9 | export const command = 'user';
10 | export const desc = 'Get the authenticated Nintendo Account\'s player record';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.option('user', {
14 | describe: 'Nintendo Account ID',
15 | type: 'string',
16 | }).option('token', {
17 | describe: 'Nintendo Account session token',
18 | type: 'string',
19 | });
20 | }
21 |
22 | type Arguments = YargsArguments>;
23 |
24 | export async function handler(argv: ArgumentsCamelCase) {
25 | const storage = await initStorage(argv.dataPath);
26 |
27 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
28 | const token: string = argv.token ||
29 | await storage.getItem('NintendoAccountToken.' + usernsid);
30 | const {splatnet, data} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
31 |
32 | const [records, stages, activefestivals, timeline] = await Promise.all([
33 | splatnet.getRecords(),
34 | splatnet.getStages(),
35 | splatnet.getActiveFestivals(),
36 | splatnet.getTimeline(),
37 | ]);
38 | const nickname_and_icons = await splatnet.getUserNicknameAndIcon([records.records.player.principal_id]);
39 |
40 | console.log('Player %s (Splatoon 2 ID %s, NSA ID %s) level %d',
41 | records.records.player.nickname,
42 | records.records.unique_id,
43 | records.records.player.principal_id,
44 | records.records.player.player_rank,
45 | records.records.player.player_type);
46 | }
47 |
--------------------------------------------------------------------------------
/src/cli/splatnet2/weapons.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getIksmToken } from '../../common/auth/splatnet2.js';
7 |
8 | const debug = createDebug('cli:splatnet2:weapons');
9 |
10 | export const command = 'weapons';
11 | export const desc = 'Show weapon stats';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | const storage = await initStorage(argv.dataPath);
33 |
34 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
35 | const token: string = argv.token ||
36 | await storage.getItem('NintendoAccountToken.' + usernsid);
37 | const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
38 |
39 | const records = await splatnet.getRecords();
40 |
41 | if (argv.jsonPrettyPrint) {
42 | console.log(JSON.stringify(records.records.weapon_stats, null, 4));
43 | return;
44 | }
45 | if (argv.json) {
46 | console.log(JSON.stringify(records.records.weapon_stats));
47 | return;
48 | }
49 |
50 | const table = new Table({
51 | head: [
52 | 'ID',
53 | 'Name',
54 | 'Sub',
55 | 'Special',
56 | 'Wins',
57 | 'Losses',
58 | 'Meter',
59 | 'H. meter',
60 | 'Turf inked',
61 | 'Last used',
62 | ],
63 | });
64 |
65 | for (const weaponstats of Object.values(records.records.weapon_stats)) {
66 | table.push([
67 | weaponstats.weapon.id,
68 | weaponstats.weapon.name,
69 | weaponstats.weapon.sub.name,
70 | weaponstats.weapon.special.name,
71 | weaponstats.win_count,
72 | weaponstats.lose_count,
73 | weaponstats.win_meter,
74 | weaponstats.max_win_meter,
75 | weaponstats.total_paint_point + 'p',
76 | new Date(weaponstats.last_use_time * 1000).toISOString(),
77 | ]);
78 | }
79 |
80 | console.log('Weapon stats');
81 | console.log(table.toString());
82 | }
83 |
--------------------------------------------------------------------------------
/src/cli/splatnet2/x-rank-seasons.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { getAllSeasons } from '../../api/splatnet2-xrank.js';
6 |
7 | const debug = createDebug('cli:splatnet2:x-rank-seasons');
8 |
9 | export const command = 'x-rank-seasons';
10 | export const desc = 'Show X Rank seasons';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.option('sort', {
14 | describe: 'Sort',
15 | type: 'string',
16 | choices: ['asc', 'desc'],
17 | default: 'asc',
18 | }).option('json', {
19 | describe: 'Output raw JSON',
20 | type: 'boolean',
21 | }).option('json-pretty-print', {
22 | describe: 'Output pretty-printed JSON',
23 | type: 'boolean',
24 | });
25 | }
26 |
27 | type Arguments = YargsArguments>;
28 |
29 | export async function handler(argv: ArgumentsCamelCase) {
30 | const sort_ascending = argv.sort !== 'desc';
31 |
32 | if (argv.json || argv.jsonPrettyPrint) {
33 | const result = {
34 | seasons: [...getAllSeasons()],
35 | };
36 |
37 | console.log(JSON.stringify(result, null, argv.jsonPrettyPrint ? 4 : 0));
38 | return;
39 | }
40 |
41 | const table = new Table({
42 | head: [
43 | '#',
44 | 'ID',
45 | 'Key',
46 | 'Start',
47 | 'End',
48 | 'Status',
49 | ],
50 | });
51 |
52 | for (const season of getAllSeasons(sort_ascending)) {
53 | table.push([
54 | season.index + 1,
55 | season.id,
56 | season.key,
57 | season.start.toLocaleString('en-GB'),
58 | season.end.toLocaleString('en-GB'),
59 | season.complete ? 'Complete' : 'Calculating',
60 | ]);
61 | }
62 |
63 | console.log(table.toString());
64 | }
65 |
--------------------------------------------------------------------------------
/src/cli/splatnet3/commands.ts:
--------------------------------------------------------------------------------
1 | export * as user from './user.js';
2 | export * as token from './token.js';
3 | export * as friends from './friends.js';
4 | export * as schedule from './schedule.js';
5 | export * as festivals from './festivals.js';
6 | export * as festival from './festival.js';
7 | export * as battles from './battles.js';
8 | export * as dumpRecords from './dump-records.js';
9 | export * as dumpFests from './dump-fests.js';
10 | export * as dumpAlbum from './dump-album.js';
11 | export * as dumpResults from './dump-results.js';
12 | export * as monitor from './monitor.js';
13 |
--------------------------------------------------------------------------------
/src/cli/splatnet3/festivals.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getBulletToken } from '../../common/auth/splatnet3.js';
7 |
8 | const debug = createDebug('cli:splatnet3:festivals');
9 |
10 | export const command = 'festivals';
11 | export const desc = 'List all Splatfests in your region';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | const storage = await initStorage(argv.dataPath);
33 |
34 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
35 | const token: string = argv.token ||
36 | await storage.getItem('NintendoAccountToken.' + usernsid);
37 | const {splatnet} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
38 |
39 | const fest_records = await splatnet.getFestRecords();
40 |
41 | if (argv.jsonPrettyPrint) {
42 | console.log(JSON.stringify({festRecords: fest_records.data.festRecords.nodes}, null, 4));
43 | return;
44 | }
45 | if (argv.json) {
46 | console.log(JSON.stringify({festRecords: fest_records.data.festRecords.nodes}));
47 | return;
48 | }
49 |
50 | const table = new Table({
51 | head: [
52 | 'ID',
53 | 'State',
54 | 'Start',
55 | // 'L',
56 | 'Title',
57 | 'A',
58 | 'B',
59 | 'C',
60 | ],
61 | });
62 |
63 | for (const fest of fest_records.data.festRecords.nodes) {
64 | const id_str = Buffer.from(fest.id, 'base64').toString() || fest.id;
65 |
66 | table.push([
67 | id_str,
68 | fest.state,
69 | fest.startTime,
70 | // fest.lang,
71 | fest.title,
72 | ...fest.teams.map(t => t.teamName),
73 | ]);
74 | }
75 |
76 | console.log(table.toString());
77 | }
78 |
--------------------------------------------------------------------------------
/src/cli/splatnet3/index.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import type { Arguments as ParentArguments } from '../../cli.js';
3 | import createDebug from '../../util/debug.js';
4 | import { Argv, YargsArguments } from '../../util/yargs.js';
5 | import * as commands from './commands.js';
6 |
7 | const debug = createDebug('cli:splatnet3');
8 |
9 | export const command = 'splatnet3 ';
10 | export const desc = 'SplatNet 3';
11 |
12 | export function builder(yargs: Argv) {
13 | for (const command of Object.values(commands)) {
14 | // @ts-expect-error
15 | yargs.command(command);
16 | }
17 |
18 | return yargs.option('znc-proxy-url', {
19 | describe: 'URL of Nintendo Switch Online app API proxy server to use',
20 | type: 'string',
21 | default: process.env.ZNC_PROXY_URL,
22 | }).option('auto-update-session', {
23 | describe: 'Automatically obtain and refresh the SplatNet 3 access token',
24 | type: 'boolean',
25 | default: true,
26 | });
27 | }
28 |
29 | export type Arguments = YargsArguments>;
30 |
--------------------------------------------------------------------------------
/src/cli/splatnet3/schedule.ts:
--------------------------------------------------------------------------------
1 | import Table from '../../util/table.js';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
5 | import { initStorage } from '../../util/storage.js';
6 | import { getBulletToken } from '../../common/auth/splatnet3.js';
7 |
8 | const debug = createDebug('cli:splatnet3:schedule');
9 |
10 | export const command = 'schedule';
11 | export const desc = 'Show stage schedules';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | const storage = await initStorage(argv.dataPath);
33 |
34 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
35 | const token: string = argv.token ||
36 | await storage.getItem('NintendoAccountToken.' + usernsid);
37 | const {splatnet} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
38 |
39 | const schedules = await splatnet.getSchedules();
40 |
41 | if (argv.jsonPrettyPrint) {
42 | console.log(JSON.stringify(schedules.data, null, 4));
43 | return;
44 | }
45 | if (argv.json) {
46 | console.log(JSON.stringify(schedules.data));
47 | return;
48 | }
49 |
50 | throw new Error('Not implemented');
51 | }
52 |
--------------------------------------------------------------------------------
/src/cli/splatnet3/token.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getBulletToken } from '../../common/auth/splatnet3.js';
6 | import { SplatNet3CliTokenData } from '../../api/splatnet3.js';
7 |
8 | const debug = createDebug('cli:splatnet3:token');
9 |
10 | export const command = 'token';
11 | export const desc = 'Get the authenticated Nintendo Account\'s SplatNet 3 user data and access token';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.option('user', {
15 | describe: 'Nintendo Account ID',
16 | type: 'string',
17 | }).option('token', {
18 | describe: 'Nintendo Account session token',
19 | type: 'string',
20 | }).option('json', {
21 | describe: 'Output raw JSON',
22 | type: 'boolean',
23 | }).option('json-pretty-print', {
24 | describe: 'Output pretty-printed JSON',
25 | type: 'boolean',
26 | });
27 | }
28 |
29 | type Arguments = YargsArguments>;
30 |
31 | export async function handler(argv: ArgumentsCamelCase) {
32 | const storage = await initStorage(argv.dataPath);
33 |
34 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
35 | const token: string = argv.token || await storage.getItem('NintendoAccountToken.' + usernsid);
36 | const {splatnet, data} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
37 |
38 | if (argv.json || argv.jsonPrettyPrint) {
39 | const result: SplatNet3CliTokenData = {
40 | bullet_token: data.bullet_token.bulletToken,
41 | expires_at: data.expires_at,
42 | language: data.bullet_token.lang,
43 | country: data.country,
44 | version: data.version,
45 | queries: data.queries,
46 | };
47 |
48 | console.log(JSON.stringify(result, null, argv.jsonPrettyPrint ? 4 : 0));
49 | return;
50 | }
51 |
52 | console.log(data.bullet_token.bulletToken);
53 | }
54 |
--------------------------------------------------------------------------------
/src/cli/splatnet3/user.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { initStorage } from '../../util/storage.js';
5 | import { getBulletToken } from '../../common/auth/splatnet3.js';
6 |
7 | const debug = createDebug('cli:splatnet3:user');
8 |
9 | export const command = 'user';
10 | export const desc = 'Get the authenticated Nintendo Account\'s player record';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.option('user', {
14 | describe: 'Nintendo Account ID',
15 | type: 'string',
16 | }).option('token', {
17 | describe: 'Nintendo Account session token',
18 | type: 'string',
19 | });
20 | }
21 |
22 | type Arguments = YargsArguments>;
23 |
24 | export async function handler(argv: ArgumentsCamelCase) {
25 | const storage = await initStorage(argv.dataPath);
26 |
27 | const usernsid = argv.user ?? await storage.getItem('SelectedUser');
28 | const token: string = argv.token ||
29 | await storage.getItem('NintendoAccountToken.' + usernsid);
30 | const {splatnet, data} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
31 |
32 | const history = await splatnet.getHistoryRecords();
33 |
34 | console.log('Player %s#%s (title %s, first played %s)',
35 | history.data.currentPlayer.name,
36 | history.data.currentPlayer.nameId,
37 | history.data.currentPlayer.byname,
38 | new Date(history.data.playHistory.gameStartTime).toLocaleString());
39 | }
40 |
--------------------------------------------------------------------------------
/src/cli/util/captureid.ts:
--------------------------------------------------------------------------------
1 | import * as crypto from 'node:crypto';
2 | import { Buffer } from 'node:buffer';
3 | import type { Arguments as ParentArguments } from './index.js';
4 | import createDebug from '../../util/debug.js';
5 | import { Argv } from '../../util/yargs.js';
6 |
7 | const debug = createDebug('cli:util:captureid');
8 |
9 | export const command = 'captureid';
10 | export const desc = 'Encrypt/decrypt capture IDs';
11 |
12 | export function builder(yargs: Argv) {
13 | return yargs.demandCommand().command('encrypt ', 'Title ID to Capture ID', yargs => {
14 | return yargs.positional('titleid', {
15 | describe: 'Title ID',
16 | type: 'string',
17 | demandOption: true,
18 | });
19 | }, argv => {
20 | console.log(encrypt(argv.titleid));
21 | }).command('decrypt ', 'Capture ID to Title ID', yargs => {
22 | return yargs.positional('captureid', {
23 | describe: 'Capture ID',
24 | type: 'string',
25 | demandOption: true,
26 | });
27 | }, argv => {
28 | console.log(decrypt(argv.captureid));
29 | });
30 | }
31 |
32 | const key = Buffer.from('b7ed7a66c80b4b008baf7f0589c08224', 'hex');
33 |
34 | /**
35 | * @param {string} tid Hex-encoded 8-byte title ID
36 | * @return {string} Hex-encoded 16-byte capture ID
37 | */
38 | export function encrypt(tid: string) {
39 | if (typeof tid !== 'string' || !tid.match(/^[0-9A-Fa-f]{16}$/)) {
40 | throw new Error('tid must be a valid title ID');
41 | }
42 |
43 | const tidb = Buffer.from('0000000000000000' + tid, 'hex').reverse();
44 |
45 | const cipher = crypto.createCipheriv('aes-128-ecb', key, null);
46 | cipher.setAutoPadding(false);
47 |
48 | const cidb = Buffer.concat([
49 | cipher.update(tidb),
50 | cipher.final(),
51 | ]);
52 |
53 | const cid = cidb.toString('hex').toUpperCase();
54 |
55 | return cid;
56 | }
57 |
58 | /**
59 | * @param {string} cid Hex-encoded 16-byte capture ID
60 | * @return {string} Hex-encoded 8-byte title ID
61 | */
62 | export function decrypt(cid: string) {
63 | if (typeof cid !== 'string' || !cid.match(/^[0-9A-Fa-f]{32}$/)) {
64 | throw new Error('cid must be a valid capture ID');
65 | }
66 |
67 | const cidb = Buffer.from(cid, 'hex');
68 |
69 | const cipher = crypto.createDecipheriv('aes-128-ecb', key, null);
70 | cipher.setAutoPadding(false);
71 |
72 | const tidb = Buffer.concat([
73 | cipher.update(cidb),
74 | cipher.final(),
75 | ]).reverse();
76 |
77 | if (!Buffer.alloc(8).equals(tidb.slice(0, 8))) {
78 | throw new Error('Invalid title ID');
79 | }
80 |
81 | const tid = tidb.slice(8, 16).toString('hex');
82 |
83 | return tid;
84 | }
85 |
--------------------------------------------------------------------------------
/src/cli/util/commands.ts:
--------------------------------------------------------------------------------
1 | export * as captureid from './captureid.js';
2 | export * as validateDiscordTitles from './validate-discord-titles.js';
3 | export * as exportDiscordTitles from './export-discord-titles.js';
4 | export * as discordActivity from './discord-activity.js';
5 | export * as discordRpc from './discord-rpc.js';
6 | export * as remoteConfig from './remote-config.js';
7 | export * as storage from './storage.js';
8 | export * as presenceEmbedRender from './presence-embed-render.js';
9 | export * as presenceEmbedServer from './presence-embed-server.js';
10 | export * as logArchive from './log-archive.js';
11 | export * as decryptLogArchive from './decrypt-log-archive.js';
12 | export * as status from './status.js';
13 |
--------------------------------------------------------------------------------
/src/cli/util/decrypt-log-archive.ts:
--------------------------------------------------------------------------------
1 | import { Buffer } from 'node:buffer';
2 | import { DecryptStream } from '@samuelthomas2774/saltpack';
3 | import tweetnacl from 'tweetnacl';
4 | import type { Arguments as ParentArguments } from './index.js';
5 | import createDebug from '../../util/debug.js';
6 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
7 |
8 | const debug = createDebug('cli:util:decrypt-log-archive');
9 |
10 | export const command = 'decrypt-log-archive';
11 | export const desc = null;
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs;
15 | }
16 |
17 | type Arguments = YargsArguments>;
18 |
19 | export async function handler(argv: ArgumentsCamelCase) {
20 | if (!process.env.NXAPI_SUPPORT_SECRET_KEY) {
21 | throw new Error('Missing NXAPI_SUPPORT_SECRET_KEY environment variable');
22 | }
23 |
24 | const key = Buffer.from(process.env.NXAPI_SUPPORT_SECRET_KEY, 'base64url');
25 | const keypair = tweetnacl.box.keyPair.fromSecretKey(key);
26 |
27 | const decrypt = new DecryptStream(keypair);
28 |
29 | decrypt.pipe(process.stdout);
30 |
31 | debug('decrypting tar.gz to stdout');
32 |
33 | process.stdin.pipe(decrypt);
34 | }
35 |
--------------------------------------------------------------------------------
/src/cli/util/discord-rpc.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import { DiscordRpcClient, getAllIpcSockets } from '../../discord/rpc.js';
3 | import { defaultTitle } from '../../discord/titles.js';
4 | import createDebug from '../../util/debug.js';
5 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
6 |
7 | const debug = createDebug('cli:util:discord-rpc');
8 | debug.enabled = true;
9 |
10 | export const command = 'discord-rpc';
11 | export const desc = 'Search for Discord IPC sockets';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs;
15 | }
16 |
17 | type Arguments = YargsArguments>;
18 |
19 | const CLIENT_ID = defaultTitle.client;
20 |
21 | export async function handler(argv: ArgumentsCamelCase) {
22 | const sockets = await getAllIpcSockets();
23 |
24 | debug('Found %d Discord IPC sockets', sockets.length, sockets.map(s => s[0]));
25 |
26 | for (const [id, socket] of sockets) {
27 | const client = new DiscordRpcClient({ transport: 'ipc', ipc_socket: socket });
28 |
29 | await client.connect(CLIENT_ID);
30 | debug('[%d] Connected', id);
31 |
32 | if (client.application) {
33 | debug('[%d] Application', id, client.application);
34 | }
35 | if (client.user) {
36 | debug('[%d] User', id, client.user);
37 | debug('[%d] User avatar', id,
38 | 'https://cdn.discordapp.com/avatars/' + client.user.id + '/' + client.user.avatar + '.png');
39 | }
40 |
41 | await client.destroy();
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/cli/util/index.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from '../../cli.js';
2 | import createDebug from '../../util/debug.js';
3 | import { Argv, YargsArguments } from '../../util/yargs.js';
4 | import { dev } from '../../util/product.js';
5 | import * as commands from './commands.js';
6 |
7 | const debug = createDebug('cli:util');
8 |
9 | export const command = 'util ';
10 | export const desc = 'Utilities';
11 |
12 | export function builder(yargs: Argv) {
13 | for (const command of Object.values(commands)) {
14 | if (command.command === 'validate-discord-titles' && !dev) continue;
15 | if (command.command === 'export-discord-titles' && !dev) continue;
16 |
17 | // @ts-expect-error
18 | yargs.command(command);
19 | }
20 |
21 | return yargs;
22 | }
23 |
24 | export type Arguments = YargsArguments>;
25 |
--------------------------------------------------------------------------------
/src/cli/util/log-archive.ts:
--------------------------------------------------------------------------------
1 | import { Buffer } from 'node:buffer';
2 | import { createWriteStream, WriteStream } from 'node:fs';
3 | import type { Arguments as ParentArguments } from './index.js';
4 | import createDebug from '../../util/debug.js';
5 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
6 | import { generateEncryptedLogArchive } from '../../util/support.js';
7 |
8 | const debug = createDebug('cli:util:log-archive');
9 |
10 | export const command = 'log-archive [output]';
11 | export const desc = 'Create an encrypted log archive for support';
12 |
13 | export function builder(yargs: Argv) {
14 | return yargs.positional('output', {
15 | describe: 'Output path',
16 | type: 'string',
17 | });
18 | }
19 |
20 | type Arguments = YargsArguments>;
21 |
22 | export async function handler(argv: ArgumentsCamelCase) {
23 | const { default: config } = await import('../../common/remote-config.js');
24 |
25 | if (!config.log_encryption_key) {
26 | throw new Error('No log encryption key in remote configuration');
27 | }
28 |
29 | const out = await createOutputStream(argv.output);
30 |
31 | debug('creating log archive');
32 |
33 | const key = Buffer.from(config.log_encryption_key, 'base64url');
34 | const [encrypt] = await generateEncryptedLogArchive(key);
35 |
36 | encrypt.pipe(out);
37 |
38 | encrypt.on('end', () => {
39 | debug('done');
40 | });
41 | }
42 |
43 | async function createOutputStream(path?: string) {
44 | if (!path && process.stdout.isTTY) {
45 | console.error('No output path set but stdout is a TTY. Run `nxapi util log-archive -` to force output to a terminal.');
46 | process.exit(1);
47 | }
48 |
49 | if (!path || path === '-') {
50 | return process.stdout;
51 | }
52 |
53 | return new Promise((rs, rj) => {
54 | const out = createWriteStream(path);
55 |
56 | const onready = () => {
57 | out.removeListener('ready', onready);
58 | out.removeListener('error', onerror);
59 | rs(out);
60 | };
61 | const onerror = () => {
62 | out.removeListener('ready', onready);
63 | out.removeListener('error', onerror);
64 | rs(out);
65 | };
66 |
67 | out.on('ready', onready);
68 | out.on('error', onerror);
69 | });
70 | }
71 |
--------------------------------------------------------------------------------
/src/cli/util/remote-config.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 |
5 | const debug = createDebug('cli:util:remote-config');
6 |
7 | export const command = 'remote-config';
8 | export const desc = 'Show nxapi remote configuration';
9 |
10 | export function builder(yargs: Argv) {
11 | return yargs.option('json', {
12 | describe: 'Output raw JSON',
13 | type: 'boolean',
14 | }).option('json-pretty-print', {
15 | describe: 'Output pretty-printed JSON',
16 | type: 'boolean',
17 | });
18 | }
19 |
20 | type Arguments = YargsArguments>;
21 |
22 | export async function handler(argv: ArgumentsCamelCase) {
23 | const { default: config } = await import('../../common/remote-config.js');
24 |
25 | if (argv.jsonPrettyPrint) {
26 | console.log(JSON.stringify(config, null, 4));
27 | return;
28 | }
29 | if (argv.json) {
30 | console.log(JSON.stringify(config));
31 | return;
32 | }
33 |
34 | console.log('Remote config', config);
35 | }
36 |
--------------------------------------------------------------------------------
/src/cli/util/status.ts:
--------------------------------------------------------------------------------
1 | import type { Arguments as ParentArguments } from './index.js';
2 | import createDebug from '../../util/debug.js';
3 | import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
4 | import { StatusUpdateMonitor } from '../../common/status.js';
5 |
6 | const debug = createDebug('cli:util:status');
7 |
8 | export const command = 'status';
9 | export const desc = 'Show nxapi service status updates';
10 |
11 | export function builder(yargs: Argv) {
12 | return yargs.option('url', {
13 | describe: 'Additional status update source',
14 | type: 'array',
15 | }).option('use-config', {
16 | describe: 'Use the status update source from nxapi\'s remote configuration',
17 | type: 'boolean',
18 | default: true,
19 | }).option('json', {
20 | describe: 'Output raw JSON',
21 | type: 'boolean',
22 | }).option('json-pretty-print', {
23 | describe: 'Output pretty-printed JSON',
24 | type: 'boolean',
25 | });
26 | }
27 |
28 | type Arguments = YargsArguments>;
29 |
30 | export async function handler(argv: ArgumentsCamelCase) {
31 | const status = new StatusUpdateMonitor();
32 |
33 | if (argv.useConfig) {
34 | const { default: config } = await import('../../common/remote-config.js');
35 | if (config.status_update_url) status.addSource(config.status_update_url);
36 | }
37 |
38 | for (const url of argv.url ?? []) {
39 | status.addSource(url.toString());
40 | }
41 |
42 | const result = await status.checkStatusUpdates();
43 |
44 | if (argv.jsonPrettyPrint) {
45 | console.log(JSON.stringify(result, null, 4));
46 | return;
47 | }
48 | if (argv.json) {
49 | console.log(JSON.stringify(result));
50 | return;
51 | }
52 |
53 | console.log('Status updates', result);
54 | }
55 |
--------------------------------------------------------------------------------
/src/cli/util/validate-discord-titles.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import type { Arguments as ParentArguments } from './index.js';
3 | import createDebug from '../../util/debug.js';
4 | import { ArgumentsCamelCase } from '../../util/yargs.js';
5 | import * as publishers from '../../discord/titles/index.js';
6 |
7 | const debug = createDebug('cli:util:validate-discord-titles');
8 | debug.enabled = true;
9 |
10 | export const command = 'validate-discord-titles';
11 | export const desc = 'Validate Discord title configuration';
12 |
13 | type Arguments = ParentArguments;
14 |
15 | export async function handler(argv: ArgumentsCamelCase) {
16 | const titles = [];
17 | let errors = 0;
18 |
19 | for (const [publisher, m] of Object.entries(publishers)) {
20 | if (!('titles' in m)) continue;
21 |
22 | let index = 0;
23 | let filtered = 0;
24 | const clients = new Set();
25 | const clients_filtered = new Set();
26 | const title_ids = new Set();
27 |
28 | for (const title of m.titles) {
29 | clients.add(title.client);
30 | const i = index++;
31 |
32 | if (title.id && title_ids.has(title.id)) {
33 | debug('[%s#%d] Duplicate title ID', publisher, i, title.id);
34 | errors++;
35 | }
36 | title_ids.add(title.id);
37 |
38 | let has_errors = false;
39 | const warn = (msg: string, ...args: any[]) => {
40 | has_errors = true;
41 | errors++;
42 | debug('[%s#%d] ' + msg, publisher, i, ...args);
43 | };
44 |
45 | if (!title.id.match(/^0100([0-9a-f]{8})[02468ace]000$/)) {
46 | if (title.id.match(/^0100([0-9a-f]{8})[02468ace]000$/i))
47 | warn('Invalid title ID, must be lowercase hex', title.id);
48 | else warn('Invalid title ID', title.id);
49 | }
50 | if (!title.client.match(/^\d{16,}$/)) warn('Invalid Discord client ID', title.id, title.client);
51 |
52 | if (has_errors) continue;
53 |
54 | titles.push(title);
55 |
56 | clients_filtered.add(title.client);
57 | filtered++;
58 | }
59 |
60 | if (clients.size !== clients_filtered.size) {
61 | debug('[%s] Loaded %d titles, using %d Discord clients (%d clients including invalid titles)',
62 | publisher, filtered, clients_filtered.size, clients.size);
63 | } else {
64 | debug('[%s] Loaded %d titles, using %d Discord clients',
65 | publisher, filtered, clients_filtered.size);
66 | }
67 | }
68 |
69 | debug('Loaded %d titles from %d publishers', titles.length,
70 | Object.values(publishers).filter(p => 'titles' in p).length);
71 |
72 | if (errors) {
73 | debug('Found %d issue' + (errors === 1 ? '' : 's'), errors);
74 | process.exit(1);
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/client/storage/local.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'node:path';
2 | import { fileURLToPath } from 'node:url';
3 | import * as fs from 'node:fs/promises';
4 | import createDebug from '../../util/debug.js';
5 | import { StorageProvider } from './index.js';
6 |
7 | const debug = createDebug('nxapi:client:storage:local');
8 |
9 | export class LocalStorageProvider implements StorageProvider {
10 | protected constructor(readonly path: string) {}
11 |
12 | async getSessionToken(na_id: string, client_id: string) {
13 | await fs.mkdir(path.join(this.path, 'users', na_id), {recursive: true});
14 |
15 | try {
16 | debug('read', path.join('users', na_id, 'session-' + client_id));
17 | const token = await fs.readFile(path.join(this.path, 'users', na_id, 'session-' + client_id), 'utf-8');
18 |
19 | return token;
20 | } catch (err) {
21 | if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
22 | throw err;
23 | }
24 | }
25 |
26 | async setSessionToken(na_id: string, client_id: string, token: string) {
27 | await fs.mkdir(path.join(this.path, 'users', na_id), {recursive: true});
28 |
29 | debug('write', path.join('users', na_id, 'session-' + client_id));
30 | await fs.writeFile(path.join(this.path, 'users', na_id, 'session-' + client_id), token, 'utf-8');
31 | }
32 |
33 | async getSessionItem(na_id: string, session_id: string, key: string) {
34 | await fs.mkdir(path.join(this.path, 'sessions', na_id, session_id), {recursive: true});
35 |
36 | try {
37 | debug('read', path.join('sessions', na_id, session_id, key));
38 | return await fs.readFile(path.join(this.path, 'sessions', na_id, session_id, key), 'utf-8');
39 | } catch (err) {
40 | if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
41 | throw err;
42 | }
43 | }
44 |
45 | async setSessionItem(na_id: string, session_id: string, key: string, value: string) {
46 | await fs.mkdir(path.join(this.path, 'sessions', na_id, session_id), {recursive: true});
47 |
48 | debug('write', path.join('sessions', na_id, session_id, key));
49 | await fs.writeFile(path.join(this.path, 'sessions', na_id, session_id, key), value, 'utf-8');
50 | }
51 |
52 | static async create(path: string | URL) {
53 | if (path instanceof URL) path = fileURLToPath(path);
54 |
55 | await fs.mkdir(path, {recursive: true});
56 |
57 | return new LocalStorageProvider(path);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/client/users.ts:
--------------------------------------------------------------------------------
1 | import { NintendoAccountSession, Storage } from './storage/index.js';
2 |
3 | interface UserConstructor {
4 | createWithUserStore(users: Users, id: string, ...args: A): Promise;
5 | }
6 |
7 | interface User {
8 | expires_at: number;
9 | }
10 |
11 | export default class Users {
12 | private users = new Map, Map>();
13 | private user_promise = new Map, Map>>();
14 |
15 | constructor(
16 | readonly storage: Storage,
17 | readonly znc_proxy_url?: string,
18 | ) {}
19 |
20 | async get(type: UserConstructor, id: string, ...args: A): Promise {
21 | const existing = this.users.get(type)?.get(id);
22 |
23 | if (existing && existing.expires_at >= Date.now()) {
24 | return existing as T;
25 | }
26 |
27 | const promises = this.user_promise.get(type) ?? new Map>();
28 |
29 | const promise = promises.get(id) ?? type.createWithUserStore(this, id, ...args).then(client => {
30 | const users = this.users.get(type) ?? new Map();
31 | users.set(id, client);
32 | return client;
33 | }).finally(() => {
34 | promises.delete(id);
35 | if (!promises.size) this.user_promise.delete(type);
36 | });
37 |
38 | this.user_promise.set(type, promises);
39 | promises.set(id, promise);
40 |
41 | return promise as Promise;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/client/util.ts:
--------------------------------------------------------------------------------
1 | import createDebug from '../util/debug.js';
2 | import { LIMIT_PERIOD, LIMIT_REQUESTS } from '../common/auth/util.js';
3 | import { NintendoAccountSession } from './storage/index.js';
4 |
5 | const debug = createDebug('nxapi:client:util');
6 |
7 | export async function checkUseLimit(
8 | session: NintendoAccountSession,
9 | key: string,
10 | /** Set to false to count the attempt but ignore the limit */ ratelimit = true,
11 | /** [requests, period_ms] */ limits: [number, number] = [LIMIT_REQUESTS, LIMIT_PERIOD]
12 | ) {
13 | let attempts = await session.getRateLimitAttempts(key);
14 | attempts = attempts.filter(a => a >= Date.now() - limits[1]);
15 |
16 | if (ratelimit && attempts.length >= limits[0]) {
17 | throw new Error('Too many attempts to authenticate');
18 | }
19 |
20 | attempts.unshift(Date.now());
21 | await session.setRateLimitAttempts(key, attempts);
22 | }
23 |
--------------------------------------------------------------------------------
/src/common/constants.ts:
--------------------------------------------------------------------------------
1 | export const GITLAB_URL = 'https://gitlab.fancy.org.uk/samuel/nxapi';
2 | export const GITHUB_MIRROR_URL = 'https://github.com/samuelthomas2774/nxapi';
3 | export const ISSUES_URL = 'https://github.com/samuelthomas2774/nxapi/issues';
4 | export const ZNCA_API_USE_URL = 'https://gitlab.fancy.org.uk/samuel/nxapi#coral-client-authentication';
5 | export const USER_AGENT_INFO_URL = 'https://gitlab.fancy.org.uk/samuel/nxapi#user-agent-strings';
6 | export const CONFIG_URL = 'https://fancy.org.uk/api/nxapi/config';
7 |
8 | export const LICENCE_NOTICE = `
9 | Copyright (c) 2023 Samuel Elliott
10 |
11 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
12 |
13 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
14 |
15 | You should have received a copy of the GNU Affero General Public License along with this program. If not, see .
16 |
17 | This product is not affiliated with Nintendo, Discord and others. All product names, logos, and brands are property of their respective owners. Use of this program is at your own risk.
18 | `.trim();
19 |
20 | export const CREDITS_NOTICE = `
21 | This product uses services provided by Nintendo (https://nintendo.co.jp), Samuel Elliott (https://gitlab.fancy.org.uk/samuel/nxapi-znca-api) and Jone Wang (https://imink.app).
22 | `.trim();
23 |
24 | export const ZNCA_API_USE_TEXT = `
25 | To access the Nintendo Switch Online app API, nxapi must send some data to third-party APIs. This is required to generate some data to make Nintendo think you\'re using the real Nintendo Switch Online app.
26 |
27 | By default, this uses nxapi-znca-api.fancy.org.uk or api.imink.app, but another service can be used by setting an environment variable. The default API may change without notice if you do not force use of a specific service.
28 |
29 | The data sent includes:
30 |
31 | - Your Nintendo Account ID
32 | - When authenticating to the Nintendo Switch Online app: a Nintendo Account ID token, containing your Nintendo Account country, which is valid for 15 minutes
33 | - When authenticating to game-specific services: your Coral (Nintendo Switch Online app) user ID and a Coral ID token, containing your Nintendo Switch Online membership status, and Nintendo Account child restriction status, which is valid for 2 hours
34 | `.trim();
35 |
--------------------------------------------------------------------------------
/src/common/globals.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'node:path';
2 | import dotenv from 'dotenv';
3 | import dotenvExpand from 'dotenv-expand';
4 | import createDebug from '../util/debug.js';
5 | import { paths } from '../util/storage.js';
6 |
7 | let done = false;
8 |
9 | export function init() {
10 | if (done) {
11 | throw new Error('Attempted to initialise global data twice');
12 | }
13 |
14 | done = true;
15 |
16 | dotenvExpand.expand(dotenv.config({
17 | path: path.join(paths.data, '.env'),
18 | }));
19 | if (process.env.NXAPI_DATA_PATH) dotenvExpand.expand(dotenv.config({
20 | path: path.join(process.env.NXAPI_DATA_PATH, '.env'),
21 | }));
22 |
23 | if (process.env.DEBUG) createDebug.enable(process.env.DEBUG);
24 | }
25 |
--------------------------------------------------------------------------------
/src/discord/titles.ts:
--------------------------------------------------------------------------------
1 | import { Title } from './types.js';
2 | import * as publishers from './titles/index.js';
3 | import { PresencePlatform } from '../api/coral-types.js';
4 |
5 | export const defaultTitle: Title = {
6 | id: '0000000000000000',
7 | client: '950883021165330493',
8 | titleName: true,
9 | showPlayingOnline: true,
10 | showActiveEvent: true,
11 | };
12 |
13 | export const platform_clients: Record = {
14 | [PresencePlatform.NINTENDO_SWITCH]: '950883021165330493',
15 | // [PresencePlatform.NINTENDO_SWITCH_2]: '1358060657957928970',
16 | };
17 |
18 | export const titles: Title[] = [];
19 |
20 | for (const [publisher, m] of Object.entries(publishers)) {
21 | if (!('titles' in m)) continue;
22 |
23 | for (const title of m.titles) {
24 | titles.push(title);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/discord/titles/capcom.ts:
--------------------------------------------------------------------------------
1 | import { Title } from '../types.js';
2 |
3 | export const titles: Title[] = [
4 | {
5 | // Monster Hunter Rise
6 | id: '0100b04011742000',
7 | client: '986683336053370900',
8 | showPlayingOnline: true,
9 | showActiveEvent: true,
10 | },
11 | {
12 | // Monster Hunter Rise [Japan]
13 | id: '0100559011740000',
14 | client: '986683336053370900',
15 | showPlayingOnline: true,
16 | showActiveEvent: true,
17 | },
18 |
19 | {
20 | // Monster Hunter Rise Demo
21 | id: '010093a01305c000',
22 | client: '986683336053370900',
23 | largeImageText: 'Demo',
24 | showPlayingOnline: true,
25 | showActiveEvent: true,
26 | },
27 | {
28 | // Monster Hunter Rise Demo [Japan]
29 | id: '0100bef013050000',
30 | client: '986683336053370900',
31 | largeImageText: 'Demo',
32 | showPlayingOnline: true,
33 | showActiveEvent: true,
34 | },
35 |
36 | {
37 | // Monster Hunter Rise: Sunbreak Demo
38 | id: '0100db001724e000',
39 | client: '986683336053370900',
40 | largeImageText: 'Sunbreak Demo',
41 | showPlayingOnline: true,
42 | showActiveEvent: true,
43 | },
44 | {
45 | // Monster Hunter Rise: Sunbreak Demo [Japan]
46 | id: '01000ee01724c000',
47 | client: '986683336053370900',
48 | largeImageText: 'Sunbreak Demo',
49 | showPlayingOnline: true,
50 | showActiveEvent: true,
51 | },
52 | ];
53 |
--------------------------------------------------------------------------------
/src/discord/titles/concernedape.ts:
--------------------------------------------------------------------------------
1 | import { Title } from '../types.js';
2 |
3 | export const titles: Title[] = [
4 | {
5 | // Stardew Valley
6 | id: '0100e65002bb8000',
7 | client: '1125884657209196754',
8 | showPlayingOnline: true,
9 | showActiveEvent: true,
10 | },
11 | {
12 | // Stardew Valley [Japan]
13 | id: '0100dd400c468000',
14 | client: '1125884657209196754',
15 | showPlayingOnline: true,
16 | showActiveEvent: true,
17 | },
18 | ];
19 |
--------------------------------------------------------------------------------
/src/discord/titles/epicgames.ts:
--------------------------------------------------------------------------------
1 | import { Title } from '../types.js';
2 |
3 | export const titles: Title[] = [
4 | {
5 | // Fall Guys
6 | id: '0100c3c015738000',
7 | client: '1128807730463911937',
8 | showPlayingOnline: true,
9 | showActiveEvent: true,
10 | showDescription: false,
11 | },
12 | ];
13 |
--------------------------------------------------------------------------------
/src/discord/titles/index.ts:
--------------------------------------------------------------------------------
1 | export * as nintendo from './nintendo.js';
2 | export * as mojang from './mojang.js';
3 | export * as capcom from './capcom.js';
4 | export * as the_pokemon_company from './the-pokémon-company.js';
5 | export * as thatgamecompany from './thatgamecompany.js';
6 | export * as concernedape from './concernedape.js';
7 | export * as epicgames from './epicgames.js';
8 | export * as phoenix_labs from './phoenix-labs.js';
9 |
--------------------------------------------------------------------------------
/src/discord/titles/mojang.ts:
--------------------------------------------------------------------------------
1 | import { Title } from '../types.js';
2 |
3 | export const titles: Title[] = [
4 | {
5 | // Minecraft
6 | id: '0100d71004694000',
7 | client: '950906152391168020',
8 | showPlayingOnline: true,
9 | showActiveEvent: true,
10 | },
11 | {
12 | // Minecraft: Nintendo Switch Edition
13 | id: '01006bd001e06000',
14 | client: '950906152391168020',
15 | showPlayingOnline: true,
16 | showActiveEvent: true,
17 | },
18 | ];
19 |
--------------------------------------------------------------------------------
/src/discord/titles/phoenix-labs.ts:
--------------------------------------------------------------------------------
1 | import { Title } from '../types.js';
2 |
3 | export const titles: Title[] = [
4 | {
5 | // Fae Farm
6 | id: '010073f0189b6000',
7 | client: '1167207629249052712',
8 | showPlayingOnline: true,
9 | showActiveEvent: true,
10 | },
11 | ];
12 |
--------------------------------------------------------------------------------
/src/discord/titles/thatgamecompany.ts:
--------------------------------------------------------------------------------
1 | import { Title } from '../types.js';
2 |
3 | export const titles: Title[] = [
4 | {
5 | // Sky: Children of the Light
6 | id: '0100c52011460000',
7 | client: '1080136394288144465',
8 | showPlayingOnline: true,
9 | showActiveEvent: true,
10 | },
11 | ];
12 |
--------------------------------------------------------------------------------
/src/discord/titles/the-pokémon-company.ts:
--------------------------------------------------------------------------------
1 | import { Title } from '../types.js';
2 |
3 | export const titles: Title[] = [
4 | {
5 | // Pokémon UNITE
6 | id: '0100939011ed4000',
7 | client: '990631943332827186',
8 | showPlayingOnline: true,
9 | showActiveEvent: true,
10 | },
11 | ];
12 |
--------------------------------------------------------------------------------
/src/exports/coral.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | CoralApiInterface,
4 |
5 | CoralAuthData,
6 | PartialCoralAuthData,
7 |
8 | ResponseDataSymbol,
9 | CorrelationIdSymbol,
10 |
11 | CoralErrorResponse,
12 |
13 | NintendoAccountSessionAuthorisationCoral,
14 | } from '../api/coral.js';
15 |
16 | export * from '../api/coral-types.js';
17 |
18 | export { default as ZncProxyApi } from '../api/znc-proxy.js';
19 |
20 | export {
21 | ZncaApi,
22 | HashMethod,
23 | getPreferredZncaApiFromEnvironment,
24 | getDefaultZncaApi,
25 | f,
26 |
27 | ZncaApiFlapg,
28 | FlapgIid,
29 | FlapgApiResponse,
30 |
31 | ZncaApiImink,
32 | IminkFResponse,
33 | IminkFError,
34 |
35 | ZncaApiNxapi,
36 | AndroidZncaFResponse,
37 | AndroidZncaFError,
38 | } from '../api/f.js';
39 |
40 | export {
41 | default as Coral,
42 | } from '../client/coral.js';
43 |
--------------------------------------------------------------------------------
/src/exports/index.ts:
--------------------------------------------------------------------------------
1 | export { getTitleIdFromEcUrl } from '../util/misc.js';
2 | export { ErrorResponse, ResponseSymbol } from '../api/util.js';
3 | export { addUserAgent, addUserAgentFromPackageJson } from '../util/useragent.js';
4 |
5 | export { version, product } from '../util/product.js';
6 |
7 | export {
8 | default as Users,
9 | } from '../client/users.js';
10 | export {
11 | Storage,
12 | StorageProvider,
13 | } from '../client/storage/index.js';
14 | export {
15 | LocalStorageProvider,
16 | } from '../client/storage/local.js';
17 |
--------------------------------------------------------------------------------
/src/exports/moon.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | MoonAuthData,
4 | PartialMoonAuthData,
5 |
6 | MoonErrorResponse,
7 |
8 | NintendoAccountSessionAuthorisationMoon,
9 | } from '../api/moon.js';
10 |
11 | export * from '../api/moon-types.js';
12 |
--------------------------------------------------------------------------------
/src/exports/nintendo-account.ts:
--------------------------------------------------------------------------------
1 | export {
2 | NintendoAccountSessionAuthorisation,
3 | NintendoAccountSessionAuthorisationError,
4 |
5 | NintendoAccountAuthErrorResponse,
6 | NintendoAccountErrorResponse,
7 |
8 | NintendoAccountSessionToken,
9 | NintendoAccountToken,
10 | NintendoAccountAuthError,
11 |
12 | NintendoAccountUser,
13 | Mii,
14 | NintendoAccountError,
15 | } from '../api/na.js';
16 |
--------------------------------------------------------------------------------
/src/exports/nooklink.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as NooklinkApi,
3 | NooklinkAuthData,
4 |
5 | NooklinkErrorResponse,
6 |
7 | NooklinkUserApi,
8 | NooklinkUserAuthData,
9 | PartialNooklinkUserAuthData,
10 | NooklinkUserCliTokenData,
11 |
12 | MessageType,
13 | } from '../api/nooklink.js';
14 |
15 | export * from '../api/nooklink-types.js';
16 |
--------------------------------------------------------------------------------
/src/exports/splatnet2.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | SplatNet2AuthData,
4 | SplatNet2CliTokenData,
5 |
6 | SplatNet2ErrorResponse,
7 |
8 | LeagueType,
9 | LeagueRegion,
10 | ShareColour as ShareProfileColour,
11 | toLeagueId,
12 | } from '../api/splatnet2.js';
13 |
14 | export {
15 | Season as XRankSeason,
16 | Rule as XPowerRankingRule,
17 | getAllSeasons as getXRankSeasons,
18 | getSeason as getXRankSeason,
19 | getNextSeason as getNextXRankSeason,
20 | getPreviousSeason as getPreviousXRankSeason,
21 | toSeasonId as toXRankSeasonId,
22 | } from '../api/splatnet2-xrank.js';
23 |
24 | export * from '../api/splatnet2-types.js';
25 |
--------------------------------------------------------------------------------
/src/exports/splatnet3.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default,
3 | SplatNet3AuthData,
4 | SplatNet3CliTokenData,
5 |
6 | RequestIdSymbol,
7 | VariablesSymbol,
8 |
9 | SplatNet3ErrorResponse,
10 | SplatNet3AuthErrorResponse,
11 | SplatNet3GraphQLErrorResponse,
12 | SplatNet3GraphQLResourceNotFoundResponse,
13 |
14 | XRankingRegion,
15 | XRankingLeaderboardType,
16 | XRankingLeaderboardRule,
17 | } from '../api/splatnet3.js';
18 |
19 | // export * from '../api/splatnet3-types.js';
20 |
21 | export {
22 | default as SplatNet3,
23 | } from '../client/splatnet3.js';
24 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default as CoralApi } from './api/coral.js';
2 | export { /** @deprecated */ default as ZncApi } from './api/coral.js';
3 | export { default as ZncProxyApi } from './api/znc-proxy.js';
4 | export * as coral from './api/coral-types.js';
5 | /** @deprecated */
6 | export * as znc from './api/coral-types.js';
7 | export { default as MoonApi } from './api/moon.js';
8 | export * as moon from './api/moon-types.js';
9 | export * as na from './api/na.js';
10 | export * as f from './api/f.js';
11 |
12 | export {
13 | default as SplatNet2Api,
14 | LeagueType as SplatNet2LeagueType,
15 | LeagueRegion as SplatNet2LeagueRegion,
16 | ShareColour as SplatNet2ProfileColour,
17 | toLeagueId as toSplatNet2LeagueId,
18 | } from './api/splatnet2.js';
19 | export {
20 | Season as SplatNet2XRankSeason,
21 | Rule as SplatNet2XPowerRankingRule,
22 | getAllSeasons as getSplatNet2XRankSeasons,
23 | getSeason as getSplatNet2XRankSeason,
24 | getNextSeason as getSplatNet2NextXRankSeason,
25 | getPreviousSeason as getSplatNet2PreviousXRankSeason,
26 | toSeasonId as toSplatNet2XRankSeasonId,
27 | } from './api/splatnet2-xrank.js';
28 | export * as splatnet2 from './api/splatnet2-types.js';
29 | export {
30 | default as NooklinkApi,
31 | NooklinkUserApi,
32 | MessageType as NooklinkMessageType,
33 | } from './api/nooklink.js';
34 | export * as nooklink from './api/nooklink-types.js';
35 |
36 | export { getTitleIdFromEcUrl } from './util/misc.js';
37 | export { ErrorResponse } from './api/util.js';
38 | export { addUserAgent } from './util/useragent.js';
39 |
--------------------------------------------------------------------------------
/src/util/http.ts:
--------------------------------------------------------------------------------
1 | //
2 | // Parse HTTP Link headers
3 | //
4 | // Based on https://github.com/thlorenz/parse-link-header
5 | //
6 |
7 | function parseLink(link: string) {
8 | const match = link.match(/([^>]*)>((;.*)*)/);
9 | if (!match) return null;
10 |
11 | const uri = match[1];
12 | const parameters_str = match[2].split(';');
13 |
14 | // Reuse URLSearchParams for link parameters
15 | const parameters = new URLSearchParams();
16 |
17 | for (const parameter of parameters_str) {
18 | // rel="next" => 1: rel 2: next
19 | const match = parameter.match(/\s*(.+)\s*=\s*("([^"]*)"|[^\b]+)?/);
20 | if (!match) continue;
21 |
22 | const key = match[1];
23 | const value = match[3] ?? match[2];
24 |
25 | parameters.append(key, value);
26 | }
27 |
28 | const rel = (parameters.get('rel') ?? '').split(' ').filter(r => r);
29 |
30 | return {
31 | uri,
32 | parameters,
33 | rel,
34 | type: parameters.get('type'),
35 | };
36 | }
37 |
38 | export function parseLinkHeader(link_header: string) {
39 | const links = [];
40 |
41 | for (const link_str of link_header.split(/,\s*)) {
42 | const link = parseLink(link_str);
43 | if (link) links.push(link);
44 | }
45 |
46 | return links;
47 | }
48 |
--------------------------------------------------------------------------------
/src/util/misc.ts:
--------------------------------------------------------------------------------
1 |
2 | export const TemporaryErrorSymbol = Symbol('TemporaryError');
3 |
4 | export function getTitleIdFromEcUrl(url: string) {
5 | const match = url.match(/^https:\/\/ec\.nintendo\.com\/apps\/([0-9a-f]{16})\//);
6 | return match?.[1] ?? null;
7 | }
8 |
9 | export function hrduration(duration: number, short = false) {
10 | const hours = Math.floor(duration / 60);
11 | const minutes = duration - (hours * 60);
12 |
13 | const hour_str = short ? 'hr' : 'hour';
14 | const minute_str = short ? 'min' : 'minute';
15 |
16 | if (hours >= 1) {
17 | return hours + ' ' + hour_str + (hours === 1 ? '' : 's') +
18 | (minutes ? ', ' + minutes + ' ' + minute_str + (minutes === 1 ? '' : 's') : '');
19 | } else {
20 | return minutes + ' ' + minute_str + (minutes === 1 ? '' : 's');
21 | }
22 | }
23 |
24 | export function hrlist(items: string[]) {
25 | if (!items.length) throw new Error('list must not be empty');
26 | if (items.length === 1) return items[0];
27 |
28 | const last = items[items.length - 1];
29 | return items.slice(0, -1).join(', ') + ' and ' + last;
30 | }
31 |
32 | export function timeoutSignal(ms = 60 * 1000) {
33 | const controller = new AbortController();
34 |
35 | const timeout = setTimeout(() => {
36 | const err = new Error('Timeout');
37 | Object.defineProperty(err, TemporaryErrorSymbol, {value: true});
38 | controller.abort(err);
39 | }, ms);
40 |
41 | return [controller.signal, () => clearTimeout(timeout), controller] as const;
42 | }
43 |
44 | export const RawValueSymbol = Symbol('RawValue');
45 | export type RawValue = {[RawValueSymbol]: string};
46 |
47 | export function htmlentities(strings: TemplateStringsArray, ...args: (string | number | RawValue)[]): string {
48 | return strings.map((s, i) => s + (args[i] ? (
49 | typeof args[i] === 'object' && RawValueSymbol in (args[i] as object) ?
50 | (args[i] as RawValue)[RawValueSymbol] :
51 | args[i].toString().replace(/[\u00A0-\u9999<>\&]/gim, c => '' + c.charCodeAt(0) + ';')
52 | ) : '')).join('');
53 | }
54 |
--------------------------------------------------------------------------------
/src/util/net.ts:
--------------------------------------------------------------------------------
1 | import * as net from 'node:net';
2 |
3 | export function parseListenAddress(address: string | number) {
4 | const match = ('' + address).match(/^((?:((?:\d+\.){3}\d+)|\[(.*)\]):)?(\d+)$/);
5 | if (!match || (match[1] && !net.isIP(match[2] || match[3]))) throw new Error('Invalid address/port');
6 |
7 | const host = match[2] || match[3] || null;
8 | const port = parseInt(match[4]);
9 |
10 | return [host, port] as const;
11 | }
12 |
--------------------------------------------------------------------------------
/src/util/storage.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'node:path';
2 | import * as fs from 'node:fs/promises';
3 | import persist from 'node-persist';
4 | import createDebug from './debug.js';
5 |
6 | const debug = createDebug('nxapi:util:storage');
7 |
8 | export { paths } from './product.js';
9 |
10 | export async function initStorage(dir: string) {
11 | const storage = persist.create({
12 | dir: path.join(dir, 'persist'),
13 | stringify: data => JSON.stringify(data, null, 4) + '\n',
14 | expiredInterval: 0,
15 | });
16 | await storage.init();
17 | return storage;
18 | }
19 |
20 | export async function* iterateLocalStorage(storage: persist.LocalStorage) {
21 | const dir = (storage as unknown as {options: persist.InitOptions}).options.dir!;
22 |
23 | for await (const file of await fs.opendir(dir)) {
24 | if (!file.isFile()) continue;
25 |
26 | const datum = await storage.readFile(path.join(dir, file.name)) as persist.Datum;
27 | if (!datum || !datum.key) continue;
28 |
29 | yield datum;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/util/yargs.ts:
--------------------------------------------------------------------------------
1 | import * as yargs from 'yargs';
2 |
3 | //
4 | // Yargs types
5 | //
6 |
7 | export type YargsArguments = T extends yargs.Argv ? R : any;
8 | export type Argv = yargs.Argv;
9 | // export type ArgumentsCamelCase = yargstypes.ArgumentsCamelCase;
10 |
11 | /** Convert literal string types like 'foo-bar' to 'FooBar' */
12 | type PascalCase = string extends S ?
13 | string : S extends `${infer T}-${infer U}` ?
14 | `${Capitalize}${PascalCase}` : Capitalize;
15 |
16 | /** Convert literal string types like 'foo-bar' to 'fooBar' */
17 | type CamelCase = string extends S ?
18 | string : S extends `${infer T}-${infer U}` ?
19 | `${T}${PascalCase}` : S;
20 |
21 | /** Convert literal string types like 'foo-bar' to 'fooBar', allowing all `PropertyKey` types */
22 | type CamelCaseKey = K extends string ? Exclude, ''> : K;
23 |
24 | /** Arguments type, with camelcased keys */
25 | export type ArgumentsCamelCase = { [key in keyof T as key | CamelCaseKey]: T[key] } & {
26 | /** Non-option arguments */
27 | _: Array;
28 | /** The script name or node command */
29 | $0: string;
30 | /** All remaining options */
31 | [argName: string]: unknown;
32 | };
33 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "target": "es2021",
5 | "module": "node16",
6 | "jsx": "react",
7 | "moduleResolution": "node16",
8 | "allowSyntheticDefaultImports": true,
9 | "declaration": true,
10 | "importHelpers": true,
11 | "rootDir": "src",
12 | "outDir": "dist",
13 | "sourceMap": true,
14 | "lib": [
15 | "es2021",
16 | "dom"
17 | ],
18 | "stripInternal": true,
19 | "skipLibCheck": true,
20 | "allowJs": true
21 | },
22 | "include": [
23 | "src"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------