Merge branch 'main' into i18n

# Conflicts:
#	package-lock.json
#	package.json
#	src/app/browser/main/discord-setup.tsx
#	src/app/browser/main/discord.tsx
#	src/app/main/app-menu.ts
#	src/app/main/index.ts
#	src/app/main/ipc.ts
#	src/app/main/menu.ts
#	src/app/main/monitor.ts
#	src/app/main/na-auth.ts
#	src/app/main/util.ts
#	src/app/main/webservices.ts
#	src/app/main/windows.ts
This commit is contained in:
Samuel Elliott 2024-10-15 23:31:28 +01:00
commit 8276b70de7
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
179 changed files with 13955 additions and 6527 deletions

View File

@ -27,7 +27,9 @@ build:
build-docker:
stage: build
image: node:20-alpine
before_script:
- apk add docker git
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- |
[ "$DH_REGISTRY_IMAGE" != "" ] && docker login -u "$DH_REGISTRY_USER" -p "$DH_REGISTRY_PASSWORD" "$DH_REGISTRY"
@ -77,11 +79,8 @@ build-docker:
docker tag "$CI_REGISTRY_IMAGE:ref-$CI_COMMIT_REF_SLUG" "$GH_REGISTRY_IMAGE:latest"
docker push "$GH_REGISTRY_IMAGE:latest"
fi
tags:
- docker
only:
variables:
- $BUILD_DOCKER_IMAGE == "true"
rules:
- if: $BUILD_DOCKER_IMAGE == "true"
cache:
policy: pull
@ -101,9 +100,8 @@ build-app:
- app/mac/**/*
- app/mac-arm64/**/*
- app/linux-unpacked/**/*
only:
variables:
- $BUILD_APP == "true"
rules:
- if: $BUILD_APP == "true"
cache:
paths:
- node_modules/
@ -124,9 +122,8 @@ build-windows:
- app
exclude:
- app/win-unpacked/**/*
only:
variables:
- $BUILD_WINDOWS_APP == "true"
rules:
- if: $BUILD_WINDOWS_APP == "true"
cache:
paths:
- node_modules/
@ -141,13 +138,8 @@ publish-npm:
- npm --color="always" publish
needs:
- build
only:
refs:
- /^v.*$/
variables:
- $NPM_TOKEN
except:
- branches
rules:
- if: $CI_COMMIT_TAG =~ /^v/ && $NPM_TOKEN
cache:
paths:
- node_modules/
@ -163,14 +155,8 @@ publish-gitlab:
- npm --color="always" --registry=${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/npm/ publish
needs:
- build
only:
refs:
- /^v.*$/
variables:
- $GITLAB_NPM_PUBLISH == "true"
- $GITLAB_NPM_PACKAGE_NAME
except:
- branches
rules:
- if: $CI_COMMIT_TAG =~ /^v/ && $GITLAB_NPM_PUBLISH == "true" && $GITLAB_NPM_PACKAGE_NAME
cache:
paths:
- node_modules/
@ -186,15 +172,33 @@ publish-github:
- npm --color="always" --registry=https://npm.pkg.github.com/ publish
needs:
- build
only:
refs:
- /^v.*$/
variables:
- $GITHUB_REPOSITORY
- $GITHUB_NPM_PACKAGE_NAME
- $GITHUB_NPM_TOKEN
except:
- branches
rules:
- if: $CI_COMMIT_TAG =~ /^v/ && $GITHUB_NPM_REPOSITORY && $GITHUB_NPM_PACKAGE_NAME && $GITHUB_NPM_TOKEN
cache:
paths:
- node_modules/
policy: pull
publish-github-releases:
stage: deploy
image: alpine
before_script:
- apk add github-cli
script:
- gh release --repo "$GITHUB_REPOSITORY" view "$CI_COMMIT_TAG" --json id,url || gh release --repo "$GITHUB_REPOSITORY" create "$CI_COMMIT_TAG" --verify-tag --draft --generate-notes
- |
gh release --repo "$GITHUB_REPOSITORY" upload "$CI_COMMIT_TAG" \
"app/Nintendo\ Switch\ Online-*-mac.zip" \
"app/Nintendo\ Switch\ Online-*.AppImage" \
"app/nxapi-app_*.deb" \
"app/nxapi-app_*.snap" \
"app/Nintendo\ Switch\ Online\ Setup\ *.exe"
needs:
- build-app
- build-windows
rules:
- if: $CI_COMMIT_TAG =~ /^v/ && $GITLAB_REPOSITORY && $GITHUB_TOKEN && $GITHUB_UPLOAD_RELEASE_ASSETS == "true"
cache:
paths:
- node_modules/
@ -233,9 +237,8 @@ publish-next:
fi
needs:
- build
only:
refs:
- main
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
cache:
paths:
- node_modules/

View File

@ -1,4 +1,4 @@
FROM node:18 as build
FROM node:20 as build
WORKDIR /app
@ -13,7 +13,7 @@ ADD tsconfig.json /app
RUN npx tsc
FROM node:18
FROM node:20
WORKDIR /app
@ -24,6 +24,7 @@ RUN npm ci --production
COPY bin /app/bin
COPY resources /app/resources
COPY resources/cli/fonts /usr/local/share/fonts
COPY --from=build /app/dist /app/dist
RUN ln -s /app/bin/nxapi.js /usr/local/bin/nxapi

View File

@ -1,9 +1,9 @@
nxapi
===
JavaScript library and command line and Electron app for accessing the Nintendo Switch Online and Nintendo Switch Parental Controls app APIs. Show your Nintendo Switch presence in Discord, get friend notifications on desktop, and download and access SplatNet 2, NookLink, SplatNet 3 and Parental Controls data.
JavaScript library, command line tool and Electron app for accessing the Nintendo Switch Online and Nintendo Switch Parental Controls app APIs. Show your Nintendo Switch presence on Discord, get friend notifications on desktop, and download and access SplatNet 2, NookLink, SplatNet 3 and Parental Controls data.
[![Discord](https://img.shields.io/discord/998657768594608138?color=5865f2&label=Discord)](https://discord.com/invite/4D82rFkXRv)
[![Discord server](https://img.shields.io/discord/998657768594608138?color=5865f2&label=Discord)](https://discord.com/invite/4D82rFkXRv)
### Features
@ -64,17 +64,25 @@ nxapi includes an Electron app, which can be downloaded [here](https://github.co
![Screenshot of the menu bar app with SplatNet 2 and NookLink open in the background](resources/menu-app.png)
The app includes the nxapi command line at `dist/bundle/cli-bundle.js`. Node.js must be installed separately to use this.
The app includes the nxapi command line at `dist/bundle/cli-bundle.js`.
```sh
# macOS
node Nintendo\ Switch\ Online.app/Contents/Resources/app/dist/bundle/cli-bundle.js ...
# Windows
node 'Nintendo Switch Online/resources/app/dist/bundle/cli-bundle.js' ...
Nintendo\ Switch\ Online.app/Contents/bin/nxapi
# Linux, installed via dpkg
node /opt/Nintendo\ Switch\ Online/resources/app/dist/bundle/cli-bundle.js ...
# This is linked as /usr/bin/nxapi
/opt/Nintendo\ Switch\ Online/bin/nxapi
```
On Windows, Node.js must be installed separately.
```powershell
# PowerShell
node $env:LOCALAPPDATA\Programs\nxapi-app\resources\app\dist\bundle\cli-bundle.js ...
# Command Prompt
node %localappdata%\Programs\nxapi-app\resources\app\dist\bundle\cli-bundle.js ...
```
#### Do I need a Nintendo Switch Online membership?
@ -87,6 +95,16 @@ You will need to have an online membership (free trial is ok) to use any game-sp
For Parental Controls data, you don't need to have linked your account to a console. You will need to use Nintendo's app to add a console to your account though, as this isn't supported in nxapi and the Parental Controls API is a bit useless without doing this.
#### The Electron app does not connect to Discord on Linux
The Electron app, Discord, or both, may be sandboxed depending on how they're installed.
The dpkg and AppImage nxapi packages are not sandboxed. The official dpkg Discord package and tar release are not sandboxed.
The snap packages of nxapi and Discord are sandboxed and cannot support Discord Rich Presence.
The Flatpak Discord package is sandboxed, but can be used by linking the IPC socket outside of the app directory: https://github.com/flathub/com.discordapp.Discord/wiki/Rich-Precense-(discord-rpc).
#### Will my Nintendo Switch console be banned for using this?
No.
@ -101,6 +119,19 @@ It's extremely unlikely:
A secondary account is required for Discord Rich Presence; you don't need to sign in to your main account.
##### Update 08/09/2023
> Nintendo has banned a small number of users from accessing SplatNet 3. Nintendo has not sent any notification to affected users. This is only known to have affected users of one application unrelated to nxapi.
>
> SplatNet 3 returns `401 Unauthorized` (`ERROR_INVALID_GAME_WEB_TOKEN`... which causes the official app to retry repeatedly); there is no specific error message for banned users. No other Nintendo services are affected.
>
> If you only use nxapi for Discord Rich Presence, your main account is safe, because nxapi does not use it to fetch presence data. nxapi requires a secondary account to fetch your main account's presence data, so even if that account was banned you could just create another one without losing anything.
>
> More information:
>
> - https://tkgstrator.work/article/2023/09/announcement.html
> - https://github.com/frozenpandaman/s3s/issues/146
#### Why is a token sent to one/two different non-Nintendo servers?
It's required to generate some data to make Nintendo think you're using the real Nintendo Switch Online app, as currently it's too hard to do this locally. (This isn't required for Parental Controls data.) See the [Coral client authentication](#coral-client-authentication) section below for more information.

View File

@ -664,9 +664,46 @@ curl http://[::1]:12345/api/presence/0123456789abcdef
# Fetch presence data for a specific user including Splatoon 3 presence using curl
curl http://[::1]:12345/api/presence/0123456789abcdef?include-splatoon3=1
# Fetch Splatoon 3 fest voting history for a specific user using curl
curl http://[::1]:12345/api/presence/0123456789abcdef/splatoon3-fest-votes
# Fetch Splatoon 3 fest voting history including all prevotes for a specific user using curl
curl http://[::1]:12345/api/presence/0123456789abcdef/splatoon3-fest-votes?include-all=1
# Watch for presence events
curl --no-buffer http://[::1]:12345/api/presence/0123456789abcdef/events
curl --no-buffer http://[::1]:12345/api/presence/0123456789abcdef/events?include-splatoon3=1
# Save a user's current picture
curl -L http://[::1]:12345/api/presence/0123456789abcdef/image > image.jpeg
# Show the Nintendo eShop page for a user's current title
# http://[::1]:12345/api/presence/0123456789abcdef/title/redirect
# Redirect to a friend code URL if not playing
# http://[::1]:12345/api/presence/0123456789abcdef/title/redirect?friend-code=0000-0000-0000&friend-code-hash=0000000000
# Redirect to another URL if not playing
# http://[::1]:12345/api/presence/0123456789abcdef/title/redirect?fallback-url=https://example.com
# Signal to the browser to cancel navigation if not playing
# http://[::1]:12345/api/presence/0123456789abcdef/title/redirect?fallback-prevent-navigation=1
# Generate an SVG showing a user's presence
curl http://[::1]:12345/api/presence/0123456789abcdef/embed > embed.svg
# Generate a PNG/JPEG/WEBP showing a user's presence
curl http://[::1]:12345/api/presence/0123456789abcdef/embed.png > embed.png
curl http://[::1]:12345/api/presence/0123456789abcdef/embed.jpeg > embed.jpeg
curl http://[::1]:12345/api/presence/0123456789abcdef/embed.webp > embed.webp
# ... using a specific theme
curl http://[::1]:12345/api/presence/0123456789abcdef/embed?theme=light > embed.svg
curl http://[::1]:12345/api/presence/0123456789abcdef/embed?theme=dark > embed.svg
# ... including a friend code
curl http://[::1]:12345/api/presence/0123456789abcdef/embed?friend-code=0000-0000-0000 > embed.svg
# ... without a background and border
curl http://[::1]:12345/api/presence/0123456789abcdef/embed?transparent=1 > embed.svg
# ... with a custom width (500 to 1500, or 440 to 1440 with transparency)
curl http://[::1]:12345/api/presence/0123456789abcdef/embed?width=800 > embed.svg
# ... with Splatoon 3 presence
curl http://[::1]:12345/api/presence/0123456789abcdef/embed?include-splatoon3=1 > embed.svg
# ... with Splatoon 3 Splatfest team
curl 'http://[::1]:12345/api/presence/0123456789abcdef/embed?include-splatoon3=1&show-splatoon3-fest-team=1' > embed.svg
```
Example EventStream use:

14953
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@
],
"exports": {
".": "./dist/exports/index.js",
"./nintendo-account": "./dist/exports/nintendo-account.js",
"./coral": "./dist/exports/coral.js",
"./moon": "./dist/exports/moon.js",
"./splatnet2": "./dist/exports/splatnet2.js",
@ -35,64 +36,61 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"body-parser": "^1.20.1",
"body-parser": "^1.20.2",
"cli-table": "^0.3.11",
"debug": "^4.3.4",
"discord-rpc": "^4.0.1",
"dotenv": "^16.0.3",
"dotenv-expand": "^9.0.0",
"dotenv": "^16.4.5",
"dotenv-expand": "^11.0.6",
"env-paths": "^3.0.0",
"eventsource": "^2.0.2",
"express": "^4.18.2",
"mkdirp": "^1.0.4",
"node-fetch": "^3.3.0",
"express": "^4.19.2",
"node-notifier": "^10.0.1",
"node-persist": "^3.1.0",
"read": "^1.0.7",
"splatnet3-types": "^0.2.20230601143335",
"supports-color": "^8.1.1",
"tslib": "^2.4.1",
"uuid": "^8.3.2",
"yargs": "^17.6.2"
"node-persist": "^3.1.3",
"read": "^3.0.1",
"sharp": "^0.33.3",
"splatnet3-types": "^0.2.20231119210145",
"supports-color": "^9.4.0",
"tslib": "^2.6.2",
"undici": "^6.15.0",
"yargs": "^17.7.2"
},
"devDependencies": {
"@rollup/plugin-alias": "^3.1.9",
"@rollup/plugin-commonjs": "^22.0.2",
"@rollup/plugin-html": "^0.2.4",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^14.1.0",
"@rollup/plugin-replace": "^4.0.0",
"@types/body-parser": "^1.19.2",
"@types/cli-table": "^0.3.1",
"@types/debug": "^4.1.7",
"@types/discord-rpc": "^4.0.3",
"@types/eventsource": "^1.1.10",
"@types/express": "^4.17.14",
"@types/mkdirp": "^1.0.2",
"@types/node": "^18.11.9",
"@types/node-notifier": "^8.0.2",
"@types/node-persist": "^3.1.3",
"@types/react": "^17.0.45",
"@types/react-native": "^0.67.7",
"@types/read": "^0.0.29",
"@types/uuid": "^8.3.4",
"@types/yargs": "^17.0.14",
"electron": "^21.3.1",
"electron-builder": "^23.6.0",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-html": "^1.0.3",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-replace": "^5.0.5",
"@types/body-parser": "^1.19.5",
"@types/cli-table": "^0.3.4",
"@types/debug": "^4.1.12",
"@types/discord-rpc": "^4.0.8",
"@types/eventsource": "^1.1.15",
"@types/express": "^4.17.21",
"@types/mime-types": "^2.1.4",
"@types/node": "^20.12.7",
"@types/node-notifier": "^8.0.5",
"@types/node-persist": "^3.1.8",
"@types/react": "^18.3.1",
"@types/react-native": "^0.72.6",
"@types/yargs": "^17.0.32",
"electron": "^30.0.1",
"electron-builder": "^24.13.3",
"mime-types": "^2.1.35",
"i18next": "^22.4.6",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^12.1.1",
"react-native-web": "^0.17.7",
"rollup": "^2.79.1",
"rollup-plugin-polyfill-node": "^0.10.2",
"ts-json-schema-generator": "^1.1.2",
"typescript": "^4.9.3"
"react-native-web": "^0.19.11",
"rollup": "^4.17.2",
"rollup-plugin-polyfill-node": "^0.13.0",
"ts-json-schema-generator": "^2.1.1",
"typescript": "^5.4.5"
},
"build": {
"appId": "uk.org.fancy.nxapi.app",
"productName": "Nintendo Switch Online",
"copyright": "Copyright © 2022 Samuel Elliott",
"copyright": "Copyright © 2023 Samuel Elliott",
"npmRebuild": false,
"files": [
"dist/app/bundle",
@ -101,7 +99,8 @@
"!**/node_modules/**/*",
"resources/app",
"resources/common",
"!resources/common/remote-config.json"
"!resources/common/remote-config.json",
"resources/cli"
],
"asar": false,
"extraMetadata": {
@ -134,8 +133,28 @@
]
}
],
"publish": [],
"mac": {
"extraFiles": [
{
"from": "resources/build/app/cli-macos.sh",
"to": "bin/nxapi"
}
],
"identity": null
},
"linux": {
"category": "Utility",
"extraFiles": [
{
"from": "resources/build/app/cli-linux.sh",
"to": "nxapi"
}
]
},
"deb": {
"afterInstall": "resources/build/app/deb/postinst",
"afterRemove": "resources/build/app/deb/postrm"
}
}
}

View File

@ -0,0 +1,8 @@
#!/bin/bash
# Run as /opt/Nintendo Switch Online/nxapi
APP_BUNDLE_PATH="$(dirname "$0")"
export ELECTRON_RUN_AS_NODE=1
exec "$APP_BUNDLE_PATH/nxapi-app" "$APP_BUNDLE_PATH/resources/app/dist/bundle/cli-bundle.js" $@

View File

@ -0,0 +1,8 @@
#!/bin/sh
# Run as Nintendo Switch Online.app/Contents/bin/nxapi
APP_BUNDLE_PATH="$(dirname "$0")/../.."
export ELECTRON_RUN_AS_NODE=1
exec "$APP_BUNDLE_PATH/Contents/MacOS/Nintendo Switch Online" "$APP_BUNDLE_PATH/Contents/Resources/app/dist/bundle/cli-bundle.js" $@

View File

@ -0,0 +1,12 @@
#!/bin/bash
ln -sf '/opt/Nintendo Switch Online/nxapi' '/usr/bin/nxapi'
# Link to the binary
ln -sf '/opt/Nintendo Switch Online/nxapi-app' '/usr/bin/nxapi-app'
# SUID chrome-sandbox for Electron 5+
chmod 4755 '/opt/Nintendo Switch Online/chrome-sandbox' || true
update-mime-database /usr/share/mime || true
update-desktop-database /usr/share/applications || true

6
resources/build/app/deb/postrm Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
rm -f '/usr/bin/nxapi'
# Delete the link to the binary
rm -f '/usr/bin/nxapi-app'

Binary file not shown.

Binary file not shown.

View File

@ -1,12 +1,15 @@
{
"require_version": [],
"coral": {
"znca_version": "2.5.1"
"znca_version": "2.7.0"
},
"coral_auth": {
"default": "imink",
"default": [
"nxapi",
"https:\/\/nxapi-znca-api.fancy.org.uk\/api\/znca"
],
"splatnet2statink": null,
"flapg": {},
"flapg": null,
"imink": {}
},
"moon": {
@ -17,8 +20,8 @@
"blanco_version": "2.1.1"
},
"coral_gws_splatnet3": {
"app_ver": "4.0.0-e2ee936d",
"app_ver": "4.0.0-091d4283",
"version": "4.0.0",
"revision": "e2ee936dbecad1fd8582c2a35c2603c63767263f"
"revision": "091d428399dc86fd3a7fc43d64bd33b8bd1e875d"
}
}

View File

@ -106,6 +106,7 @@ const main = {
}),
],
external: [
'electron',
'node-notifier',
'register-scheme',
'bindings',
@ -151,10 +152,10 @@ const app_entry = {
],
external: [
'electron',
path.resolve(__dirname, 'dist/app/app-main-bundle.js'),
path.resolve(__dirname, 'dist/app/app-init-bundle.js'),
path.resolve(__dirname, 'dist/app/app-init.js'),
path.resolve(__dirname, 'dist/app/main/index.js'),
path.resolve(dir, 'dist/app/app-main-bundle.js'),
path.resolve(dir, 'dist/app/app-init-bundle.js'),
path.resolve(dir, 'dist/app/app-init.js'),
path.resolve(dir, 'dist/app/main/index.js'),
],
watch,
};
@ -243,8 +244,8 @@ const app_browser = {
// react-native-web has an ESM and CommonJS build. By default the ESM build is
// used when resolving react-native-web. For some reason this causes both versions
// to be included in the bundle, so here we explicitly use the CommonJS build.
{find: 'react-native', replacement: path.resolve(__dirname, 'node_modules', 'react-native-web', 'dist', 'cjs', 'index.js')},
{find: 'react-native-web', replacement: path.resolve(__dirname, 'node_modules', 'react-native-web', 'dist', 'cjs', 'index.js')},
{find: 'react-native', replacement: path.resolve(dir, 'node_modules', 'react-native-web', 'dist', 'cjs', 'index.js')},
{find: 'react-native-web', replacement: path.resolve(dir, 'node_modules', 'react-native-web', 'dist', 'cjs', 'index.js')},
// rollup-plugin-polyfill-node doesn't support node: module identifiers
{find: /^node:(.+)/, replacement: '$1'},

View File

@ -5,7 +5,7 @@ export interface CoralSuccessResponse<T = unknown> {
correlationId: string;
}
export interface CoralErrorResponse {
export interface CoralError {
status: CoralStatus | number;
errorMessage: string;
correlationId: string;
@ -53,7 +53,7 @@ export enum CoralStatus {
// UNKNOWN = -1,
}
export type CoralResponse<T = unknown> = CoralSuccessResponse<T> | CoralErrorResponse;
export type CoralResponse<T = unknown> = CoralSuccessResponse<T> | CoralError;
export interface AccountLoginParameter {
naIdToken: string;

View File

@ -1,13 +1,15 @@
import fetch, { Response } from 'node-fetch';
import { v4 as uuidgen } from 'uuid';
import { f, FResult, HashMethod } from './f.js';
import { AccountLogin, AccountToken, Announcements, CurrentUser, CurrentUserPermissions, Event, Friends, GetActiveEventResult, PresencePermissions, User, WebServices, WebServiceToken, CoralErrorResponse, CoralResponse, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl, AccountTokenParameter, AccountLoginParameter, WebServiceTokenParameter } from './coral-types.js';
import { getNintendoAccountToken, getNintendoAccountUser, NintendoAccountToken, NintendoAccountUser } from './na.js';
import { ErrorResponse, ResponseSymbol } from './util.js';
import { randomUUID } from 'node:crypto';
import { fetch, Response } from 'undici';
import createDebug from '../util/debug.js';
import { JwtPayload } from '../util/jwt.js';
import { getAdditionalUserAgents } from '../util/useragent.js';
import { timeoutSignal } from '../util/misc.js';
import { getAdditionalUserAgents } from '../util/useragent.js';
import type { CoralRemoteConfig } from '../common/remote-config.js';
import { AccountLogin, AccountLoginParameter, AccountToken, AccountTokenParameter, Announcements, CoralError, CoralResponse, CoralStatus, CoralSuccessResponse, CurrentUser, CurrentUserPermissions, Event, FriendCodeUrl, FriendCodeUser, Friends, GetActiveEventResult, PresencePermissions, User, WebServices, WebServiceToken, WebServiceTokenParameter } from './coral-types.js';
import { f, FResult, HashMethod } from './f.js';
import { generateAuthData, getNintendoAccountToken, getNintendoAccountUser, NintendoAccountSessionAuthorisation, NintendoAccountToken, NintendoAccountUser } from './na.js';
import { ErrorResponse, ResponseSymbol } from './util.js';
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../util/errors.js';
const debug = createDebug('nxapi:api:coral');
@ -40,8 +42,40 @@ export interface ResultData<T> {
correlationId: string;
}
export default class CoralApi {
onTokenExpired: ((data?: CoralErrorResponse, res?: Response) => Promise<CoralAuthData | void>) | null = null;
export interface CoralApiInterface {
getAnnouncements(): Promise<Result<Announcements>>;
getFriendList(): Promise<Result<Friends>>;
addFavouriteFriend(nsa_id: string): Promise<Result<{}>>;
removeFavouriteFriend(nsa_id: string): Promise<Result<{}>>;
getWebServices(): Promise<Result<WebServices>>;
getActiveEvent(): Promise<Result<GetActiveEventResult>>;
getEvent(id: number): Promise<Result<Event>>;
getUser(id: number): Promise<Result<User>>;
getUserByFriendCode(friend_code: string, hash?: string): Promise<Result<FriendCodeUser>>;
getCurrentUser(): Promise<Result<CurrentUser>>;
getFriendCodeUrl(): Promise<Result<FriendCodeUrl>>;
getCurrentUserPermissions(): Promise<Result<CurrentUserPermissions>>;
getWebServiceToken(id: number): Promise<Result<WebServiceToken>>;
}
export interface ClientInfo {
platform: string;
version: string;
useragent: string;
}
const RemoteConfigSymbol = Symbol('RemoteConfigSymbol');
const ClientInfoSymbol = Symbol('CoralClientInfo');
const CoralUserIdSymbol = Symbol('CoralUserId');
const NintendoAccountIdSymbol = Symbol('NintendoAccountId');
export default class CoralApi implements CoralApiInterface {
[RemoteConfigSymbol]!: CoralRemoteConfig | null;
[ClientInfoSymbol]: ClientInfo;
[CoralUserIdSymbol]: string;
[NintendoAccountIdSymbol]: string;
onTokenExpired: ((data?: CoralError, res?: Response) => Promise<CoralAuthData | void>) | null = null;
/** @internal */
_renewToken: Promise<void> | null = null;
/** @internal */
@ -50,11 +84,30 @@ export default class CoralApi {
protected constructor(
public token: string,
public useragent: string | null = getAdditionalUserAgents(),
public coral_user_id: string,
public na_id: string,
readonly znca_version = ZNCA_VERSION,
readonly znca_useragent = ZNCA_USER_AGENT,
) {}
coral_user_id: string,
na_id: string,
znca_version = ZNCA_VERSION,
znca_useragent = ZNCA_USER_AGENT,
config?: CoralRemoteConfig,
) {
this[ClientInfoSymbol] = {platform: ZNCA_PLATFORM, version: znca_version, useragent: znca_useragent};
this[CoralUserIdSymbol] = coral_user_id;
this[NintendoAccountIdSymbol] = na_id;
Object.defineProperty(this, RemoteConfigSymbol, {enumerable: false, value: config ?? null});
Object.defineProperty(this, 'token', {enumerable: false, value: this.token});
Object.defineProperty(this, '_renewToken', {enumerable: false, value: this._renewToken});
Object.defineProperty(this, '_token_expired', {enumerable: false, value: this._token_expired});
}
/** @internal */
get znca_version() {
return this[ClientInfoSymbol].version;
}
/** @internal */
get znca_useragent() {
return this[ClientInfoSymbol].useragent;
}
async fetch<T = unknown>(
url: string, method = 'GET', body?: string, headers?: object,
@ -79,24 +132,25 @@ export default class CoralApi {
const response = await fetch(ZNC_URL + url, {
method,
headers: Object.assign({
'X-Platform': ZNCA_PLATFORM,
'X-ProductVersion': this.znca_version,
'X-Platform': this[ClientInfoSymbol].platform,
'X-ProductVersion': this[ClientInfoSymbol].version,
'Authorization': 'Bearer ' + this.token,
'Content-Type': 'application/json; charset=utf-8',
'User-Agent': this.znca_useragent,
'User-Agent': this[ClientInfoSymbol].useragent,
}, headers),
body,
signal,
}).finally(cancel);
debug('fetch %s %s, response %s', method, url, response.status);
const data = await response.json().catch(err => null) as CoralResponse<T> | null;
if (response.status !== 200) {
throw new ErrorResponse('[znc] Non-200 status code', response, await response.text());
debug('fetch %s %s, response %s, status %d %s, correlationId %s', method, url, response.status,
data?.status, CoralStatus[data?.status!], data?.correlationId);
if (response.status !== 200 || !data) {
throw new CoralErrorResponse('[znc] Non-200 status code', response, data as CoralError);
}
const data = await response.json() as CoralResponse<T>;
if (data.status === CoralStatus.TOKEN_EXPIRED && _autoRenewToken && !_attempt && this.onTokenExpired) {
this._token_expired = true;
// _renewToken will be awaited when calling fetch
@ -109,10 +163,10 @@ export default class CoralApi {
}
if ('errorMessage' in data) {
throw new ErrorResponse('[znc] ' + data.errorMessage, response, data);
throw new CoralErrorResponse('[znc] ' + data.errorMessage, response, data);
}
if (data.status !== CoralStatus.OK) {
throw new ErrorResponse('[znc] Unknown error', response, data);
throw new CoralErrorResponse('[znc] Unknown error', response, data);
}
const result = data.result;
@ -132,7 +186,7 @@ export default class CoralApi {
url: string, parameter = {},
/** @internal */ _autoRenewToken = true
) {
const uuid = uuidgen();
const uuid = randomUUID();
return this.fetch<T>(url, 'POST', JSON.stringify({
parameter,
@ -148,15 +202,15 @@ export default class CoralApi {
return this.call<Friends>('/v3/Friend/List');
}
async addFavouriteFriend(nsaid: string) {
async addFavouriteFriend(nsa_id: string) {
return this.call<{}>('/v3/Friend/Favorite/Create', {
nsaId: nsaid,
nsaId: nsa_id,
});
}
async removeFavouriteFriend(nsaid: string) {
async removeFavouriteFriend(nsa_id: string) {
return this.call<{}>('/v3/Friend/Favorite/Delete', {
nsaId: nsaid,
nsaId: nsa_id,
});
}
@ -226,10 +280,10 @@ export default class CoralApi {
await this._renewToken;
const data = await f(this.token, HashMethod.WEB_SERVICE, {
platform: ZNCA_PLATFORM,
version: this.znca_version,
platform: this[ClientInfoSymbol].platform,
version: this[ClientInfoSymbol].version,
useragent: this.useragent ?? getAdditionalUserAgents(),
user: {na_id: this.na_id, coral_user_id: this.coral_user_id},
user: {na_id: this[NintendoAccountIdSymbol], coral_user_id: this[CoralUserIdSymbol]},
});
const req: WebServiceTokenParameter = {
@ -243,7 +297,7 @@ export default class CoralApi {
try {
return await this.call<WebServiceToken>('/v2/Game/GetWebServiceToken', req, false);
} catch (err) {
if (err instanceof ErrorResponse && err.data.status === CoralStatus.TOKEN_EXPIRED && !_attempt && this.onTokenExpired) {
if (err instanceof CoralErrorResponse && err.status === CoralStatus.TOKEN_EXPIRED && !_attempt && this.onTokenExpired) {
debug('Error getting web service token, renewing token before retrying', err);
// _renewToken will be awaited when calling getWebServiceToken
this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, err.data, err.response as Response).then(data => {
@ -259,14 +313,19 @@ export default class CoralApi {
}
async getToken(token: string, user: NintendoAccountUser): Promise<PartialCoralAuthData> {
// Nintendo Account token
const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID);
return this.getTokenWithNintendoAccountToken(nintendoAccountToken, user);
}
async getTokenWithNintendoAccountToken(
nintendoAccountToken: NintendoAccountToken, user: NintendoAccountUser,
): Promise<PartialCoralAuthData> {
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, {
platform: ZNCA_PLATFORM,
version: this.znca_version,
platform: this[ClientInfoSymbol].platform,
version: this[ClientInfoSymbol].version,
useragent: this.useragent ?? getAdditionalUserAgents(),
user: {na_id: user.id, coral_user_id: this.coral_user_id},
user: {na_id: user.id, coral_user_id: this[CoralUserIdSymbol]},
});
const req: AccountTokenParameter = {
@ -294,11 +353,16 @@ export default class CoralApi {
return data;
}
/** @private */
setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) {
async renewTokenWithNintendoAccountToken(token: NintendoAccountToken, user: NintendoAccountUser) {
const data = await this.getTokenWithNintendoAccountToken(token, user);
this.setTokenWithSavedToken(data);
return data;
}
protected setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) {
this.token = data.credential.accessToken;
this.coral_user_id = '' + data.nsoAccount.user.id;
if ('user' in data) this.na_id = data.user.id;
this[CoralUserIdSymbol] = '' + data.nsoAccount.user.id;
if ('user' in data) this[NintendoAccountIdSymbol] = data.user.id;
this._token_expired = false;
}
@ -386,16 +450,16 @@ export default class CoralApi {
debug('fetch %s %s, response %s', 'POST', '/v3/Account/Login', response.status);
if (response.status !== 200) {
throw new ErrorResponse('[znc] Non-200 status code', response, await response.text());
throw await CoralErrorResponse.fromResponse(response, '[znc] Non-200 status code');
}
const data = await response.json() as CoralResponse<AccountLogin>;
if ('errorMessage' in data) {
throw new ErrorResponse('[znc] ' + data.errorMessage, response, data);
throw new CoralErrorResponse('[znc] ' + data.errorMessage, response, data);
}
if (data.status !== CoralStatus.OK) {
throw new ErrorResponse('[znc] Unknown error', response, data);
throw new CoralErrorResponse('[znc] Unknown error', response, data);
}
debug('Got Nintendo Switch Online app token', data);
@ -412,6 +476,48 @@ export default class CoralApi {
}
}
export class CoralErrorResponse extends ErrorResponse<CoralError> implements HasErrorDescription {
get status(): CoralStatus | null {
return this.data?.status ?? null;
}
get [ErrorDescriptionSymbol]() {
if (this.status === CoralStatus.NSA_NOT_LINKED) {
return new ErrorDescription('coral.nsa_not_linked', 'Your Nintendo Account is not linked to a Network Service Account (Nintendo Switch user).\n\nMake sure you are using the Nintendo Account linked to your Nintendo Switch console.');
}
if (this.status === CoralStatus.UPGRADE_REQUIRED) {
return new ErrorDescription('coral.upgrade_required', 'The Coral (Nintendo Switch Online app) version used by nxapi is no longer supported by the Coral API.\n\nTry restarting nxapi and make sure nxapi is up to date.');
}
return null;
}
}
const na_client_settings = {
client_id: ZNCA_CLIENT_ID,
scope: 'openid user user.birthday user.mii user.screenName',
};
export class NintendoAccountSessionAuthorisationCoral extends NintendoAccountSessionAuthorisation {
protected constructor(
authorise_url: string,
state: string,
verifier: string,
redirect_uri?: string,
) {
const { client_id, scope } = na_client_settings;
super(client_id, scope, authorise_url, state, verifier, redirect_uri);
}
static create(/** @internal */ redirect_uri?: string) {
const { client_id, scope } = na_client_settings;
const auth_data = generateAuthData(client_id, scope, redirect_uri);
return new this(auth_data.url, auth_data.state, auth_data.verifier, redirect_uri);
}
}
export interface CoralAuthData {
nintendoAccountToken: NintendoAccountToken;
user: NintendoAccountUser;

View File

@ -1,6 +1,6 @@
import process from 'node:process';
import fetch, { Headers } from 'node-fetch';
import { v4 as uuidgen } from 'uuid';
import { randomUUID } from 'node:crypto';
import { fetch, Headers } from 'undici';
import { defineResponse, ErrorResponse } from './util.js';
import createDebug from '../util/debug.js';
import { timeoutSignal } from '../util/misc.js';
@ -61,7 +61,7 @@ export async function flapg(
}).finally(cancel);
if (response.status !== 200) {
throw new ErrorResponse<FlapgApiError>('[flapg] Non-200 status code', response, await response.text());
throw await ErrorResponse.fromResponse(response, '[flapg] Non-200 status code');
}
const data = await response.json() as FlapgApiResponse;
@ -91,7 +91,7 @@ export type FlapgApiError = IminkFError;
export class ZncaApiFlapg extends ZncaApi {
async genf(token: string, hash_method: HashMethod) {
const request_id = uuidgen();
const request_id = randomUUID();
const result = await flapg(hash_method, token, undefined, request_id, this.useragent);
@ -142,13 +142,13 @@ export async function iminkf(
}).finally(cancel);
if (response.status !== 200) {
throw new ErrorResponse<IminkFError>('[imink] Non-200 status code', response, await response.text());
throw await ErrorResponse.fromResponse(response, '[imink] Non-200 status code');
}
const data = await response.json() as IminkFResponse | IminkFError;
if ('error' in data) {
throw new ErrorResponse<IminkFError>('[imink] ' + data.reason, response, data);
throw new ErrorResponse('[imink] ' + data.reason, response, data);
}
debugImink('Got f parameter "%s"', data.f);
@ -174,7 +174,7 @@ export interface IminkFError {
export class ZncaApiImink extends ZncaApi {
async genf(token: string, hash_method: HashMethod, user?: {na_id: string; coral_user_id?: string;}) {
const request_id = uuidgen();
const request_id = randomUUID();
const result = await iminkf(hash_method, token, undefined, request_id, user, this.useragent);
@ -229,7 +229,7 @@ export async function genf(
}).finally(cancel);
if (response.status !== 200) {
throw new ErrorResponse<AndroidZncaFError>('[znca-api] Non-200 status code', response, await response.text());
throw await ErrorResponse.fromResponse(response, '[znca-api] Non-200 status code');
}
const data = await response.json() as AndroidZncaFResponse | AndroidZncaFError;
@ -271,7 +271,7 @@ export class ZncaApiNxapi extends ZncaApi {
}
async genf(token: string, hash_method: HashMethod, user?: {na_id: string; coral_user_id?: string}) {
const request_id = uuidgen();
const request_id = randomUUID();
const result = await genf(this.url + '/f', hash_method, token, undefined, request_id,
user, this.app, this.useragent);

View File

@ -1,5 +1,5 @@
import fetch, { Response } from 'node-fetch';
import { getNintendoAccountToken, getNintendoAccountUser, NintendoAccountToken, NintendoAccountUser } from './na.js';
import { fetch, Response } from 'undici';
import { generateAuthData, getNintendoAccountToken, getNintendoAccountUser, NintendoAccountSessionAuthorisation, NintendoAccountToken, NintendoAccountUser } from './na.js';
import { defineResponse, ErrorResponse, HasResponse } from './util.js';
import { DailySummaries, Devices, MonthlySummaries, MonthlySummary, MoonError, ParentalControlSettingState, SmartDevices, User } from './moon-types.js';
import createDebug from '../util/debug.js';
@ -86,13 +86,13 @@ export default class MoonApi {
}
if (response.status !== 200) {
throw new ErrorResponse('[moon] Non-200 status code', response, await response.text());
throw await MoonErrorResponse.fromResponse(response, '[moon] Non-200 status code');
}
const data = await response.json() as T | MoonError;
if ('errorCode' in data) {
throw new ErrorResponse('[moon] ' + data.title, response, data);
throw new MoonErrorResponse('[moon] ' + data.title, response, data);
}
return defineResponse(data, response);
@ -177,6 +177,47 @@ export default class MoonApi {
}
}
export class MoonErrorResponse extends ErrorResponse<MoonError> {}
const na_client_settings = {
client_id: ZNMA_CLIENT_ID,
scope: [
'openid',
'user',
'user.mii',
'moonUser:administration',
'moonDevice:create',
'moonOwnedDevice:administration',
'moonParentalControlSetting',
'moonParentalControlSetting:update',
'moonParentalControlSettingState',
'moonPairingState',
'moonSmartDevice:administration',
'moonDailySummary',
'moonMonthlySummary',
].join(' '),
};
export class NintendoAccountSessionAuthorisationMoon extends NintendoAccountSessionAuthorisation {
protected constructor(
authorise_url: string,
state: string,
verifier: string,
redirect_uri?: string,
) {
const { client_id, scope } = na_client_settings;
super(client_id, scope, authorise_url, state, verifier, redirect_uri);
}
static create(/** @internal */ redirect_uri?: string) {
const { client_id, scope } = na_client_settings;
const auth_data = generateAuthData(client_id, scope, redirect_uri);
return new this(auth_data.url, auth_data.state, auth_data.verifier, redirect_uri);
}
}
export interface MoonAuthData {
nintendoAccountToken: NintendoAccountToken;
user: NintendoAccountUser;

View File

@ -1,11 +1,112 @@
import fetch from 'node-fetch';
import { defineResponse, ErrorResponse } from './util.js';
import * as crypto from 'node:crypto';
import { fetch, Response } from 'undici';
import { defineResponse, ErrorResponse, HasResponse } from './util.js';
import createDebug from '../util/debug.js';
import { JwtPayload } from '../util/jwt.js';
import { timeoutSignal } from '../util/misc.js';
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../util/errors.js';
const debug = createDebug('nxapi:api:na');
export class NintendoAccountSessionAuthorisation {
readonly scope: string;
protected constructor(
readonly client_id: string,
scope: string | string[],
readonly authorise_url: string,
readonly state: string,
readonly verifier: string,
readonly redirect_uri = 'npf' + client_id + '://auth',
) {
this.scope = typeof scope === 'string' ? scope : scope.join(' ');
}
async getSessionToken(code: string, state?: string): Promise<HasResponse<NintendoAccountSessionToken, Response>>
async getSessionToken(params: URLSearchParams): Promise<HasResponse<NintendoAccountSessionToken, Response>>
async getSessionToken(code: string | URLSearchParams | null, state?: string | null) {
if (code instanceof URLSearchParams) {
if (code.get('state') !== this.state) {
throw new TypeError('Invalid state');
}
if (code.has('error')) {
throw NintendoAccountSessionAuthorisationError.fromSearchParams(code);
}
code = code.get('session_token_code');
state = undefined;
}
if (typeof state !== 'undefined' && state !== this.state) {
throw new TypeError('Invalid state');
}
if (typeof code !== 'string' || !code) {
throw new TypeError('Invalid code');
}
return getNintendoAccountSessionToken(code, this.verifier, this.client_id);
}
static create(
client_id: string,
scope: string | string[],
/** @internal */ redirect_uri = 'npf' + client_id + '://auth',
) {
if (typeof scope !== 'string') scope = scope.join(' ');
const auth_data = generateAuthData(client_id, scope, redirect_uri);
return new NintendoAccountSessionAuthorisation(client_id, scope,
auth_data.url, auth_data.state, auth_data.verifier, redirect_uri);
}
}
export class NintendoAccountSessionAuthorisationError extends Error {
constructor(readonly code: string, message?: string) {
super(message);
}
static fromSearchParams(qs: URLSearchParams) {
const code = qs.get('error') ?? 'unknown_error';
const message = qs.get('error_description') ?? code;
return new NintendoAccountSessionAuthorisationError(code, message);
}
}
export function generateAuthData(
client_id: string,
scope: string | string[],
redirect_uri = 'npf' + client_id + '://auth',
) {
const state = crypto.randomBytes(36).toString('base64url');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest().toString('base64url');
const params = {
state,
redirect_uri,
client_id,
scope: typeof scope === 'string' ? scope : scope.join(' '),
response_type: 'session_token_code',
session_token_code_challenge: challenge,
session_token_code_challenge_method: 'S256',
theme: 'login_form',
};
const url = 'https://accounts.nintendo.com/connect/1.0.0/authorize?' +
new URLSearchParams(params).toString();
return {
url,
state,
verifier,
challenge,
};
}
export async function getNintendoAccountSessionToken(code: string, verifier: string, client_id: string) {
debug('Getting Nintendo Account session token');
@ -26,16 +127,16 @@ export async function getNintendoAccountSessionToken(code: string, verifier: str
}).finally(cancel);
if (response.status !== 200) {
throw new ErrorResponse('[na] Non-200 status code', response, await response.text());
throw await NintendoAccountAuthErrorResponse.fromResponse(response, '[na] Non-200 status code');
}
const token = await response.json() as NintendoAccountSessionToken | NintendoAccountAuthError | NintendoAccountError;
if ('errorCode' in token) {
throw new ErrorResponse<NintendoAccountError>('[na] ' + token.detail, response, token);
}
if ('error' in token) {
throw new ErrorResponse<NintendoAccountAuthError>('[na] ' + token.error_description ?? token.error, response, token);
throw new NintendoAccountAuthErrorResponse('[na] ' + token.error_description ?? token.error, response, token);
}
if ('errorCode' in token) {
throw new NintendoAccountErrorResponse('[na] ' + token.detail, response, token);
}
debug('Got Nintendo Account session token', token);
@ -63,16 +164,17 @@ export async function getNintendoAccountToken(token: string, client_id: string)
}).finally(cancel);
if (response.status !== 200) {
throw new ErrorResponse('[na] Non-200 status code', response, await response.text());
throw await NintendoAccountAuthErrorResponse.fromResponse(response, '[na] Non-200 status code');
}
const nintendoAccountToken = await response.json() as NintendoAccountToken | NintendoAccountAuthError | NintendoAccountError;
if ('errorCode' in nintendoAccountToken) {
throw new ErrorResponse<NintendoAccountError>('[na] ' + nintendoAccountToken.detail, response, nintendoAccountToken);
}
if ('error' in nintendoAccountToken) {
throw new ErrorResponse<NintendoAccountAuthError>('[na] ' + nintendoAccountToken.error_description ?? nintendoAccountToken.error, response, nintendoAccountToken);
throw new NintendoAccountAuthErrorResponse('[na] ' + nintendoAccountToken.error_description ??
nintendoAccountToken.error, response, nintendoAccountToken);
}
if ('errorCode' in nintendoAccountToken) {
throw new NintendoAccountErrorResponse('[na] ' + nintendoAccountToken.detail, response, nintendoAccountToken);
}
debug('Got Nintendo Account token', nintendoAccountToken);
@ -96,13 +198,13 @@ export async function getNintendoAccountUser(token: NintendoAccountToken) {
}).finally(cancel);
if (response.status !== 200) {
throw new ErrorResponse('[na] Non-200 status code', response, await response.text());
throw await NintendoAccountErrorResponse.fromResponse(response, '[na] Non-200 status code');
}
const user = await response.json() as NintendoAccountUser | NintendoAccountError;
if ('errorCode' in user) {
throw new ErrorResponse<NintendoAccountError>('[na] ' + user.detail, response, user);
throw new NintendoAccountErrorResponse('[na] ' + user.detail, response, user);
}
debug('Got Nintendo Account user info', user);
@ -195,10 +297,12 @@ export enum NintendoAccountScope {
}
export enum NintendoAccountJwtScope {
'openid' = 0,
'offline' = 1,
'user' = 8,
'user.birthday' = 9,
'user.mii' = 17,
'user.screenName' = 23,
'user.links.nintendoNetwork.id' = 31,
'moonUser:administration' = 320,
'moonDevice:create' = 321,
'moonOwnedDevice:administration' = 325,
@ -223,10 +327,6 @@ export enum NintendoAccountJwtScope {
// 'pointWallet' = -1,
// 'userNotificationMessage:anyClients' = -1,
// 'userNotificationMessage:anyClients:write' = -1,
// 1, 31
// 'offline' = -1,
// 'user.links.nintendoNetwork.id' = -1,
}
export interface NintendoAccountUser {
@ -308,3 +408,15 @@ export interface NintendoAccountError {
status: number;
type: string;
}
export class NintendoAccountAuthErrorResponse extends ErrorResponse<NintendoAccountAuthError> implements HasErrorDescription {
get [ErrorDescriptionSymbol]() {
if (this.data?.error === 'invalid_grant') {
return new ErrorDescription('na.invalid_grant', 'Your Nintendo Account session token has expired or was revoked.\n\nYou need to sign in again.');
}
return null;
}
}
export class NintendoAccountErrorResponse extends ErrorResponse<NintendoAccountError> {}

View File

@ -1,8 +1,8 @@
import fetch, { Response } from 'node-fetch';
import { fetch, FormData, Response } from 'undici';
import { WebServiceToken } from './coral-types.js';
import { NintendoAccountUser } from './na.js';
import { defineResponse, ErrorResponse, HasResponse } from './util.js';
import CoralApi from './coral.js';
import { CoralApiInterface } from './coral.js';
import { WebServiceError, Users, AuthToken, UserProfile, Newspapers, Newspaper, Emoticons, Reaction, IslandProfile } from './nooklink-types.js';
import createDebug from '../util/debug.js';
import { timeoutSignal } from '../util/misc.js';
@ -80,14 +80,14 @@ export default class NooklinkApi {
return this.fetch(url, method, body, headers, _autoRenewToken, _attempt + 1);
}
if (response.status !== 200 && response.status !== 201) {
throw new ErrorResponse('[nooklink] Non-200/201 status code', response, await response.text());
if (!response.ok) {
throw await NooklinkErrorResponse.fromResponse(response, '[nooklink] Non-2xx status code');
}
const data = await response.json() as T | WebServiceError;
if ('code' in data) {
throw new ErrorResponse<WebServiceError>('[nooklink] Error ' + data.code, response, data);
throw new NooklinkErrorResponse('[nooklink] Error ' + data.code, response, data);
}
return defineResponse(data, response);
@ -107,8 +107,8 @@ export default class NooklinkApi {
return NooklinkUserApi._createWithNooklinkApi(this, user_id);
}
async renewTokenWithCoral(nso: CoralApi, user: NintendoAccountUser) {
const data = await NooklinkApi.loginWithCoral(nso, user);
async renewTokenWithCoral(coral: CoralApiInterface, user: NintendoAccountUser) {
const data = await NooklinkApi.loginWithCoral(coral, user);
this.setTokenWithSavedToken(data);
return data;
}
@ -124,8 +124,8 @@ export default class NooklinkApi {
this._token_expired = false;
}
static async createWithCoral(nso: CoralApi, user: NintendoAccountUser) {
const data = await this.loginWithCoral(nso, user);
static async createWithCoral(coral: CoralApiInterface, user: NintendoAccountUser) {
const data = await this.loginWithCoral(coral, user);
return {nooklink: this.createWithSavedToken(data), data};
}
@ -133,11 +133,11 @@ export default class NooklinkApi {
return new this(data.gtoken, data.useragent);
}
static async loginWithCoral(nso: CoralApi, user: NintendoAccountUser) {
static async loginWithCoral(coral: CoralApiInterface, user: NintendoAccountUser) {
const { default: { coral_gws_nooklink: config } } = await import('../common/remote-config.js');
if (!config) throw new Error('Remote configuration prevents NookLink authentication');
const webserviceToken = await nso.getWebServiceToken(NOOKLINK_WEBSERVICE_ID);
const webserviceToken = await coral.getWebServiceToken(NOOKLINK_WEBSERVICE_ID);
return this.loginWithWebServiceToken(webserviceToken, user);
}
@ -172,17 +172,17 @@ export default class NooklinkApi {
debug('fetch %s %s, response %s', 'GET', url, response.status);
const body = await response.text();
if (response.status !== 200) {
throw new ErrorResponse('[nooklink] Non-200 status code', response, body);
throw await NooklinkErrorResponse.fromResponse(response, '[nooklink] Non-200 status code');
}
const body = await response.text();
const cookies = response.headers.get('Set-Cookie');
const match = cookies?.match(/\b_gtoken=([^;]*)(;(\s*((?!expires)[a-z]+=([^;]*));?)*(\s*(expires=([^;]*));?)?|$)/i);
if (!match) {
throw new ErrorResponse('[nooklink] Response didn\'t include _gtoken cookie', response, body);
throw new NooklinkErrorResponse('[nooklink] Response didn\'t include _gtoken cookie', response, body);
}
const gtoken = decodeURIComponent(match[1]);
@ -275,14 +275,14 @@ export class NooklinkUserApi {
return this.fetch(url, method, body, headers, _autoRenewToken, _attempt + 1);
}
if (response.status !== 200 && response.status !== 201) {
throw new ErrorResponse('[nooklink] Non-200/201 status code', response, await response.text());
if (!response.ok) {
throw new NooklinkErrorResponse('[nooklink] Non-2xx status code', response, await response.text());
}
const data = await response.json() as T | WebServiceError;
if ('code' in data) {
throw new ErrorResponse<WebServiceError>('[nooklink] Error ' + data.code, response, data);
throw new NooklinkErrorResponse('[nooklink] Error ' + data.code, response, data);
}
return defineResponse(data, response);
@ -391,6 +391,8 @@ export class NooklinkUserApi {
}
}
export class NooklinkErrorResponse extends ErrorResponse<WebServiceError> {}
export interface NooklinkAuthData {
webserviceToken: WebServiceToken;
url: string;

View File

@ -1,9 +1,9 @@
import fetch from 'node-fetch';
import { v4 as uuidgen } from 'uuid';
import { randomUUID } from 'node:crypto';
import { Cookie, fetch, FormData, getSetCookies } from 'undici';
import { WebServiceToken } from './coral-types.js';
import { NintendoAccountUser } from './na.js';
import { defineResponse, ErrorResponse } from './util.js';
import CoralApi from './coral.js';
import { CoralApiInterface } from './coral.js';
import { ActiveFestivals, CoopResult, CoopResults, CoopSchedules, HeroRecords, LeagueMatchRankings, NicknameAndIcons, PastFestivals, Records, Result, Results, Schedules, ShareResponse, ShopMerchandises, Stages, Timeline, WebServiceError, XPowerRankingRecords, XPowerRankingSummary } from './splatnet2-types.js';
import createDebug from '../util/debug.js';
import { timeoutSignal } from '../util/misc.js';
@ -63,7 +63,7 @@ export default class SplatNet2Api {
}
if (response.status !== 200) {
throw new ErrorResponse('[splatnet2] Non-200 status code', response, await response.text());
throw await SplatNet2ErrorResponse.fromResponse(response, '[splatnet2] Non-200 status code');
}
updateIksmSessionLastUsed.handler?.call(null, this.iksm_session);
@ -71,7 +71,7 @@ export default class SplatNet2Api {
const data = await response.json() as T | WebServiceError;
if ('code' in data) {
throw new ErrorResponse<WebServiceError>('[splatnet2] ' + data.message, response, data);
throw new SplatNet2ErrorResponse('[splatnet2] ' + data.message, response, data);
}
return defineResponse(data, response);
@ -193,7 +193,7 @@ export default class SplatNet2Api {
}
async shareProfile(stage: string, colour: ShareColour) {
const boundary = uuidgen();
const boundary = randomUUID();
const data = `--${boundary}
Content-Disposition: form-data; name="stage"
@ -240,8 +240,8 @@ ${colour}
});
}
static async createWithCoral(nso: CoralApi, user: NintendoAccountUser) {
const data = await this.loginWithCoral(nso, user);
static async createWithCoral(coral: CoralApiInterface, user: NintendoAccountUser) {
const data = await this.loginWithCoral(coral, user);
return {splatnet: this.createWithSavedToken(data), data};
}
@ -269,8 +269,8 @@ ${colour}
);
}
static async loginWithCoral(nso: CoralApi, user: NintendoAccountUser) {
const webserviceToken = await nso.getWebServiceToken(SPLATNET2_WEBSERVICE_ID);
static async loginWithCoral(coral: CoralApiInterface, user: NintendoAccountUser) {
const webserviceToken = await coral.getWebServiceToken(SPLATNET2_WEBSERVICE_ID);
return this.loginWithWebServiceToken(webserviceToken, user);
}
@ -302,27 +302,22 @@ ${colour}
debug('fetch %s %s, response %s', 'GET', url, response.status);
if (response.status !== 200) {
throw await SplatNet2ErrorResponse.fromResponse(response, '[splatnet2] Non-200 status code');
}
const body = await response.text();
if (response.status !== 200) {
throw new ErrorResponse('[splatnet2] Non-200 status code', response, body);
const cookies = getSetCookies(response.headers);
const iksm_session = cookies.find(c => c.name === 'iksm_session');
if (!iksm_session) {
throw new SplatNet2ErrorResponse('[splatnet2] Response didn\'t include iksm_session cookie', response, body);
}
const cookies = response.headers.get('Set-Cookie');
const match = cookies?.match(/\biksm_session=([^;]*)(;(\s*((?!expires)[a-z]+=([^;]*));?)*(\s*(expires=([^;]*));?)?|$)/i);
const expires_at: number = (iksm_session.expires as Date)?.getTime() ?? Date.now() + 24 * 60 * 60 * 1000;
if (!match) {
throw new ErrorResponse('[splatnet2] Response didn\'t include iksm_session cookie', response, body);
}
const iksm_session = decodeURIComponent(match[1]);
// Nintendo sets the expires field to an invalid timestamp - browsers don't care but Data.parse does
const expires = decodeURIComponent(match[8] || '')
.replace(/(\b)(\d{1,2})-([a-z]{3})-(\d{4})(\b)/gi, '$1$2 $3 $4$5');
debug('iksm_session %s, expires %s', iksm_session.replace(/^(.{6}).*/, '$1****'), expires);
const expires_at = expires ? Date.parse(expires) : Date.now() + 24 * 60 * 60 * 1000;
debug('iksm_session %s, expires %s', iksm_session.value.replace(/^(.{6}).*/, '$1****'), iksm_session.expires);
const ml = body.match(/<html(?:\s+[a-z0-9-]+(?:=(?:"[^"]*"|[^\s>]*))?)*\s+lang=(?:"([^"]*)"|([^\s>]*))/i);
const mr = body.match(/<html(?:\s+[a-z0-9-]+(?:=(?:"[^"]*"|[^\s>]*))?)*\s+data-region=(?:"([^"]*)"|([^\s>]*))/i);
@ -330,10 +325,10 @@ ${colour}
const mn = body.match(/<html(?:\s+[a-z0-9-]+(?:=(?:"[^"]*"|[^\s>]*))?)*\s+data-nsa-id=(?:"([^"]*)"|([^\s>]*))/i);
const [language, region, user_id, nsa_id] = [ml, mr, mu, mn].map(m => m?.[1] || m?.[2] || null);
if (!language) throw new Error('[splatnet2] Invalid language in response');
if (!region) throw new Error('[splatnet2] Invalid region in response');
if (!user_id) throw new Error('[splatnet2] Invalid unique player ID in response');
if (!nsa_id) throw new Error('[splatnet2] Invalid NSA ID in response');
if (!language) throw new ErrorResponse('[splatnet2] Invalid language in response', response, body);
if (!region) throw new ErrorResponse('[splatnet2] Invalid region in response', response, body);
if (!user_id) throw new ErrorResponse('[splatnet2] Invalid unique player ID in response', response, body);
if (!nsa_id) throw new ErrorResponse('[splatnet2] Invalid NSA ID in response', response, body);
debug('SplatNet 2 user', {
language,
@ -345,24 +340,26 @@ ${colour}
return {
webserviceToken,
url: url.toString(),
cookies: cookies!,
cookies,
body,
language,
region,
user_id,
nsa_id,
iksm_session,
iksm_session: iksm_session.value,
expires_at,
useragent: SPLATNET2_WEBSERVICE_USERAGENT,
};
}
}
export class SplatNet2ErrorResponse extends ErrorResponse<WebServiceError> {}
export interface SplatNet2AuthData {
webserviceToken: WebServiceToken;
url: string;
cookies: string;
cookies: string | Cookie[];
body: string;
language: string;

View File

@ -1,7 +1,7 @@
import fetch, { Response } from 'node-fetch';
import { BankaraBattleHistoriesRefetchResult, BankaraBattleHistoriesRefetchVariables, GraphQLRequest, GraphQLResponse, GraphQLSuccessResponse, KnownRequestId, LatestBattleHistoriesRefetchResult, LatestBattleHistoriesRefetchVariables, MyOutfitInput, PagerUpdateBattleHistoriesByVsModeResult, PagerUpdateBattleHistoriesByVsModeVariables, PrivateBattleHistoriesRefetchResult, PrivateBattleHistoriesRefetchVariables, RegularBattleHistoriesRefetchResult, RegularBattleHistoriesRefetchVariables, RequestId, ResultTypes, VariablesTypes, XBattleHistoriesRefetchResult, XBattleHistoriesRefetchVariables } from 'splatnet3-types/splatnet3';
import { fetch, Response } from 'undici';
import { BankaraBattleHistoriesRefetchResult, BankaraBattleHistoriesRefetchVariables, GraphQLError, GraphQLErrorResponse, GraphQLRequest, GraphQLResponse, GraphQLSuccessResponse, KnownRequestId, LatestBattleHistoriesRefetchResult, LatestBattleHistoriesRefetchVariables, MyOutfitInput, PagerUpdateBattleHistoriesByVsModeResult, PagerUpdateBattleHistoriesByVsModeVariables, PrivateBattleHistoriesRefetchResult, PrivateBattleHistoriesRefetchVariables, RegularBattleHistoriesRefetchResult, RegularBattleHistoriesRefetchVariables, RequestId, ResultTypes, VariablesTypes, XBattleHistoriesRefetchResult, XBattleHistoriesRefetchVariables } from 'splatnet3-types/splatnet3';
import { WebServiceToken } from './coral-types.js';
import CoralApi from './coral.js';
import { CoralApiInterface } from './coral.js';
import { NintendoAccountUser } from './na.js';
import { BulletToken } from './splatnet3-types.js';
import { defineResponse, ErrorResponse, HasResponse, ResponseSymbol } from './util.js';
@ -27,15 +27,25 @@ const SPLATNET3_URL = SPLATNET3_WEBSERVICE_URL + '/api';
const SHOULD_RENEW_TOKEN_AT = 300; // 5 minutes in seconds
const TOKEN_EXPIRES_IN = 2 * 60 * 60 * 1000; // 2 hours in milliseconds
export enum SplatNet3AuthErrorCode {
USER_NOT_REGISTERED = 'USER_NOT_REGISTERED',
ERROR_INVALID_PARAMETERS = 'ERROR_INVALID_PARAMETERS',
ERROR_INVALID_GAME_WEB_TOKEN = 'ERROR_INVALID_GAME_WEB_TOKEN',
ERROR_OBSOLETE_VERSION = 'ERROR_OBSOLETE_VERSION',
ERROR_RATE_LIMIT = 'ERROR_RATE_LIMIT',
ERROR_SERVER = 'ERROR_SERVER',
ERROR_SERVER_MAINTENANCE = 'ERROR_SERVER_MAINTENANCE',
}
const AUTH_ERROR_CODES = {
204: 'USER_NOT_REGISTERED',
400: 'ERROR_INVALID_PARAMETERS',
401: 'ERROR_INVALID_GAME_WEB_TOKEN',
403: 'ERROR_OBSOLETE_VERSION',
429: 'ERROR_RATE_LIMIT',
500: 'ERROR_SERVER',
503: 'ERROR_SERVER_MAINTENANCE',
599: 'ERROR_SERVER',
204: SplatNet3AuthErrorCode.USER_NOT_REGISTERED,
400: SplatNet3AuthErrorCode.ERROR_INVALID_PARAMETERS,
401: SplatNet3AuthErrorCode.ERROR_INVALID_GAME_WEB_TOKEN,
403: SplatNet3AuthErrorCode.ERROR_OBSOLETE_VERSION,
429: SplatNet3AuthErrorCode.ERROR_RATE_LIMIT,
500: SplatNet3AuthErrorCode.ERROR_SERVER,
503: SplatNet3AuthErrorCode.ERROR_SERVER_MAINTENANCE,
599: SplatNet3AuthErrorCode.ERROR_SERVER,
} as const;
const REPLAY_CODE_REGEX = /^[A-Z0-9]{16}$/;
@ -92,7 +102,7 @@ export default class SplatNet3Api {
) {}
async fetch<T = unknown>(
url: string, method = 'GET', body?: string | FormData, headers?: object,
url: string, method = 'GET', body?: string, headers?: object,
/** @internal */ _log?: string,
/** @internal */ _attempt = 0,
): Promise<HasResponse<T, Response>> {
@ -143,7 +153,7 @@ export default class SplatNet3Api {
}
if (response.status !== 200) {
throw new ErrorResponse('[splatnet3] Non-200 status code', response, await response.text());
throw await SplatNet3ErrorResponse.fromResponse(response, '[splatnet3] Non-200 status code');
}
const remaining = parseInt(response.headers.get('x-bullettoken-remaining') ?? '0');
@ -189,9 +199,9 @@ export default class SplatNet3Api {
const data = await this.fetch<GraphQLResponse<_Result>>('/graphql', 'POST', JSON.stringify(req), undefined,
'graphql query ' + id);
if (!('data' in data) || (this.graphql_strict && data.errors?.length)) {
throw new ErrorResponse('[splatnet3] GraphQL error: ' + data.errors!.map(e => e.message).join(', '),
data[ResponseSymbol], data);
if (data.errors && (!('data' in data) || this.graphql_strict)) {
throw SplatNet3GraphQLErrorResponse.from(data[ResponseSymbol], data as GraphQLResponseWithErrors,
id, variables);
}
for (const error of data.errors ?? []) {
@ -377,7 +387,7 @@ export default class SplatNet3Api {
});
if (!result.data.journey) {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Journey not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'journey'>;
@ -390,7 +400,7 @@ export default class SplatNet3Api {
});
if (!result.data.journey) {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Journey not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'journey'>;
@ -403,7 +413,7 @@ export default class SplatNet3Api {
});
if (!result.data.journey) {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Journey not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'journey'>;
@ -416,7 +426,7 @@ export default class SplatNet3Api {
});
if (!result.data.journey) {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Journey not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'journey'>;
@ -450,7 +460,7 @@ export default class SplatNet3Api {
});
if (!result.data.fest) {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Fest not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'fest'>;
@ -463,7 +473,7 @@ export default class SplatNet3Api {
});
if (!result.data.fest) {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Fest not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'fest'>;
@ -476,7 +486,7 @@ export default class SplatNet3Api {
});
if (!result.data.fest) {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Fest not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'fest'>;
@ -489,7 +499,7 @@ export default class SplatNet3Api {
});
if (!result.data.fest) {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Fest not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'fest'>;
@ -509,7 +519,7 @@ export default class SplatNet3Api {
});
if (!result.data.fest) {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Fest not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'fest'>;
@ -528,7 +538,7 @@ export default class SplatNet3Api {
});
if (!result.data.node) {
throw new ErrorResponse('[splatnet3] FestTeam not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] FestTeam not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'node'>;
@ -589,7 +599,7 @@ export default class SplatNet3Api {
null :
null;
if (!query) throw new Error('Invalid leaderboard');
if (!query) throw new TypeError('Invalid leaderboard');
return this.persistedQuery<{
[XRankingLeaderboardType.X_RANKING]: {
@ -633,7 +643,7 @@ export default class SplatNet3Api {
});
if (!result.data.saleGear) {
throw new ErrorResponse('[splatnet3] Sale gear not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Sale gear not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'saleGear'>;
@ -670,7 +680,7 @@ export default class SplatNet3Api {
});
if (!result.data.myOutfit) {
throw new ErrorResponse('[splatnet3] My outfit not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] My outfit not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'myOutfit'>;
@ -741,7 +751,7 @@ export default class SplatNet3Api {
});
if (!result.data.replay) {
throw new ErrorResponse('[splatnet3] Replay not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Replay not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'replay'>;
@ -842,7 +852,7 @@ export default class SplatNet3Api {
});
if (!result.data.vsHistoryDetail) {
throw new ErrorResponse('[splatnet3] Battle history not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Battle history not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'vsHistoryDetail'>;
@ -855,7 +865,7 @@ export default class SplatNet3Api {
});
if (!result.data.vsHistoryDetail) {
throw new ErrorResponse('[splatnet3] Battle history not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Battle history not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'vsHistoryDetail'>;
@ -901,7 +911,7 @@ export default class SplatNet3Api {
});
if (!result.data.coopHistoryDetail) {
throw new ErrorResponse('[splatnet3] Co-op history not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Co-op history not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'coopHistoryDetail'>;
@ -914,7 +924,7 @@ export default class SplatNet3Api {
});
if (!result.data.node) {
throw new ErrorResponse('[splatnet3] Co-op history not found', result[ResponseSymbol], result);
throw SplatNet3GraphQLResourceNotFoundResponse.from('[splatnet3] Co-op history not found', result);
}
return result as NotNullPersistedQueryResult<typeof result, 'node'>;
@ -928,8 +938,8 @@ export default class SplatNet3Api {
//
//
async renewTokenWithCoral(nso: CoralApi, user: NintendoAccountUser) {
const data = await SplatNet3Api.loginWithCoral(nso, user);
async renewTokenWithCoral(coral: CoralApiInterface, user: NintendoAccountUser) {
const data = await SplatNet3Api.loginWithCoral(coral, user);
this.setTokenWithSavedToken(data);
return data;
}
@ -948,8 +958,8 @@ export default class SplatNet3Api {
this._token_expired = false;
}
static async createWithCoral(nso: CoralApi, user: NintendoAccountUser) {
const data = await this.loginWithCoral(nso, user);
static async createWithCoral(coral: CoralApiInterface, user: NintendoAccountUser) {
const data = await this.loginWithCoral(coral, user);
return {splatnet: this.createWithSavedToken(data), data};
}
@ -977,11 +987,11 @@ export default class SplatNet3Api {
);
}
static async loginWithCoral(nso: CoralApi, user: NintendoAccountUser) {
static async loginWithCoral(coral: CoralApiInterface, user: NintendoAccountUser) {
const { default: { coral_gws_splatnet3: config } } = await import('../common/remote-config.js');
if (!config) throw new Error('Remote configuration prevents SplatNet 3 authentication');
const webserviceToken = await nso.getWebServiceToken(SPLATNET3_WEBSERVICE_ID);
const webserviceToken = await coral.getWebServiceToken(SPLATNET3_WEBSERVICE_ID);
return this.loginWithWebServiceToken(webserviceToken, user);
}
@ -1019,12 +1029,12 @@ export default class SplatNet3Api {
debug('fetch %s %s, response %s', 'GET', url, response.status);
const body = await response.text();
if (response.status !== 200) {
throw new ErrorResponse('[splatnet3] Non-200 status code', response, body);
throw await SplatNet3ErrorResponse.fromResponse(response, '[splatnet3] Non-200 status code');
}
const body = await response.text();
const cookies = response.headers.get('Set-Cookie');
const [signal2, cancel2] = timeoutSignal();
@ -1047,9 +1057,9 @@ export default class SplatNet3Api {
debug('fetch %s %s, response %s', 'POST', '/bullet_tokens', response.status);
const error: string | undefined = AUTH_ERROR_CODES[tr.status as keyof typeof AUTH_ERROR_CODES];
if (error) throw new ErrorResponse('[splatnet3] ' + error, tr, await tr.text());
if (tr.status !== 201) throw new ErrorResponse('[splatnet3] Non-201 status code', tr, await tr.text());
const error: SplatNet3AuthErrorCode | undefined = AUTH_ERROR_CODES[tr.status as keyof typeof AUTH_ERROR_CODES];
if (error) throw await SplatNet3AuthErrorResponse.fromResponse(tr, '[splatnet3] ' + error);
if (tr.status !== 201) throw await SplatNet3ErrorResponse.fromResponse(tr, '[splatnet3] Non-201 status code');
const bullet_token = await tr.json() as BulletToken;
const created_at = Date.now();
@ -1083,6 +1093,66 @@ function getMapPersistedQueriesModeFromEnvironment(): MapQueriesMode {
return MapQueriesMode.ALL;
}
export class SplatNet3ErrorResponse<T = unknown> extends ErrorResponse<T> {}
export class SplatNet3AuthErrorResponse extends SplatNet3ErrorResponse {
constructor(
message: string, response: Response | globalThis.Response,
body?: string | unknown | undefined,
readonly code = AUTH_ERROR_CODES[response.status as keyof typeof AUTH_ERROR_CODES] ??
SplatNet3AuthErrorCode.ERROR_SERVER,
) {
super(message, response, body);
}
}
type GraphQLResponseWithErrors = (GraphQLSuccessResponse & {errors: GraphQLError[]}) | GraphQLErrorResponse;
export class SplatNet3GraphQLErrorResponse<
Id extends string = string,
/** @private */
_Variables extends Id extends KnownRequestId ? VariablesTypes[Id] : unknown =
Id extends KnownRequestId ? VariablesTypes[Id] : unknown,
> extends SplatNet3ErrorResponse<GraphQLResponseWithErrors> {
constructor(
message: string, response: Response | globalThis.Response,
body?: string | GraphQLResponseWithErrors | undefined,
readonly request_id?: Id | string,
readonly variables?: _Variables,
) {
super(message, response, body);
}
static from(response: Response, data: GraphQLResponseWithErrors, id: string, variables: unknown) {
return new SplatNet3GraphQLErrorResponse('[splatnet3] GraphQL error: ' +
data.errors.map(e => e.message).join(', '), response, data, id, variables);
}
}
export class SplatNet3GraphQLResourceNotFoundResponse<
Id extends string = string,
/** @private */
_Result extends Id extends KnownRequestId ? ResultTypes[Id] : unknown =
Id extends KnownRequestId ? ResultTypes[Id] : unknown,
/** @private */
_Variables extends Id extends KnownRequestId ? VariablesTypes[Id] : unknown =
Id extends KnownRequestId ? VariablesTypes[Id] : unknown,
> extends SplatNet3ErrorResponse<PersistedQueryResult<_Result>> {
constructor(
message: string, response: Response | globalThis.Response,
body?: string | PersistedQueryResult<_Result> | undefined,
readonly request_id?: Id | string,
readonly variables?: _Variables,
) {
super(message, response, body);
}
static from(message: string, data: PersistedQueryResult<any>) {
return new SplatNet3GraphQLResourceNotFoundResponse<any, any>(
message, data[ResponseSymbol], data, data[RequestIdSymbol], data[VariablesSymbol]);
}
}
export interface SplatNet3AuthData {
webserviceToken: WebServiceToken;
url: string;

View File

@ -1,5 +1,5 @@
import * as util from 'node:util';
import { Response as NodeFetchResponse } from 'node-fetch';
import { Response as UndiciResponse } from 'undici';
export const ResponseSymbol = Symbol('Response');
const ErrorResponseSymbol = Symbol('IsErrorResponse');
@ -20,13 +20,17 @@ export class ErrorResponse<T = unknown> extends Error {
constructor(
message: string,
readonly response: Response | NodeFetchResponse,
body?: string | T
readonly response: Response | UndiciResponse,
body?: string | ArrayBuffer | T
) {
super(message);
Object.defineProperty(this, ErrorResponseSymbol, {enumerable: false, value: ErrorResponseSymbol});
if (body instanceof ArrayBuffer) {
body = (new TextDecoder()).decode(body);
}
if (typeof body === 'string') {
this.body = body;
try {
@ -50,6 +54,12 @@ export class ErrorResponse<T = unknown> extends Error {
(lines.length ? '\n' + lines.join('\n') : ''),
});
}
static async fromResponse(response: UndiciResponse, message: string) {
const body = await response.arrayBuffer();
return new this(message, response, body);
}
}
Object.defineProperty(ErrorResponse, Symbol.hasInstance, {

View File

@ -1,8 +1,8 @@
import fetch, { Response } from 'node-fetch';
import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, CoralErrorResponse, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl } from './coral-types.js';
import { fetch, Response } from 'undici';
import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl } from './coral-types.js';
import { defineResponse, ErrorResponse, ResponseSymbol } from './util.js';
import CoralApi, { CoralAuthData, CorrelationIdSymbol, PartialCoralAuthData, ResponseDataSymbol, Result } from './coral.js';
import { NintendoAccountUser } from './na.js';
import { CoralApiInterface, CoralAuthData, CorrelationIdSymbol, PartialCoralAuthData, ResponseDataSymbol, Result } from './coral.js';
import { NintendoAccountToken, NintendoAccountUser } from './na.js';
import { SavedToken } from '../common/auth/coral.js';
import createDebug from '../util/debug.js';
import { timeoutSignal } from '../util/misc.js';
@ -10,21 +10,7 @@ import { getAdditionalUserAgents, getUserAgent } from '../util/useragent.js';
const debug = createDebug('nxapi:api:znc-proxy');
export default class ZncProxyApi implements CoralApi {
// Not used by ZncProxyApi
onTokenExpired: ((data?: CoralErrorResponse, res?: Response) => Promise<CoralAuthData | void>) | null = null;
/** @internal */
_renewToken: Promise<void> | null = null;
/** @internal */
_token_expired = false;
/** @internal */
na_id = '';
/** @internal */
coral_user_id = '';
readonly znca_version = '';
readonly znca_useragent = '';
export default class ZncProxyApi implements CoralApiInterface {
constructor(
private url: string,
// ZncApi uses the NSO token (valid for a few hours)
@ -47,8 +33,8 @@ export default class ZncProxyApi implements CoralApi {
debug('fetch %s %s, response %s', method, url, response.status);
if (response.status !== 200 && response.status !== 204) {
throw new ErrorResponse('[zncproxy] Non-200/204 status code', response, await response.text());
if (!response.ok) {
throw await ZncProxyErrorResponse.fromResponse(response, '[zncproxy] Non-2xx status code');
}
const data = (response.status === 204 ? {} : await response.json()) as T;
@ -70,15 +56,15 @@ export default class ZncProxyApi implements CoralApi {
return createResult(result, result);
}
async addFavouriteFriend(nsaid: string) {
const result = await this.fetch('/friend/' + nsaid, 'POST', JSON.stringify({
async addFavouriteFriend(nsa_id: string) {
const result = await this.fetch('/friend/' + nsa_id, 'POST', JSON.stringify({
isFavoriteFriend: true,
}));
return createResult(result, {});
}
async removeFavouriteFriend(nsaid: string) {
const result = await this.fetch('/friend/' + nsaid, 'POST', JSON.stringify({
async removeFavouriteFriend(nsa_id: string) {
const result = await this.fetch('/friend/' + nsa_id, 'POST', JSON.stringify({
isFavoriteFriend: false,
}));
return createResult(result, {});
@ -143,7 +129,13 @@ export default class ZncProxyApi implements CoralApi {
return createResult(result, result.token);
}
async getToken(token: string, user: NintendoAccountUser): ReturnType<CoralApi['getToken']> {
async getToken(token: string, user: NintendoAccountUser): Promise<PartialCoralAuthData> {
throw new Error('Not supported in ZncProxyApi');
}
getTokenWithNintendoAccountToken(
token: NintendoAccountToken, user: NintendoAccountUser,
): Promise<PartialCoralAuthData> {
throw new Error('Not supported in ZncProxyApi');
}
@ -153,8 +145,13 @@ export default class ZncProxyApi implements CoralApi {
return data;
}
/** @private */
setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) {
renewTokenWithNintendoAccountToken(
token: NintendoAccountToken, user: NintendoAccountUser,
): Promise<PartialCoralAuthData> {
throw new Error('Not supported in ZncProxyApi');
}
protected setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) {
throw new Error('Not supported in ZncProxyApi');
}
@ -185,6 +182,8 @@ function createResult<T extends {}, R>(data: R & {[ResponseSymbol]: Response}, r
return result as Result<T>;
}
export class ZncProxyErrorResponse extends ErrorResponse {}
export interface AuthToken {
user: string;
policy?: AuthPolicy;
@ -230,12 +229,12 @@ export async function getPresenceFromUrl(presence_url: string, useragent?: strin
debug('fetch %s %s, response %s', 'GET', presence_url, response.status);
if (response.status !== 200) {
throw new ErrorResponse('[zncproxy] Unknown error', response, await response.text());
throw await ZncProxyErrorResponse.fromResponse(response, '[zncproxy] Non-200 status code');
}
if (!response.headers.get('Content-Type')?.match(/^application\/json(;|$)$/)) {
controller.abort();
throw new ErrorResponse('[zncproxy] Unacceptable content type', response);
response.body?.cancel();
throw new ZncProxyErrorResponse('[zncproxy] Unacceptable content type', response);
}
const data = await response.json() as PresenceUrlResponse;

View File

@ -34,6 +34,6 @@ export function NintendoSwitchUsers(props: {
const styles = StyleSheet.create({
userImage: {
borderRadius: 8,
textAlignVertical: -3,
verticalAlign: -3,
},
});

View File

@ -1,4 +1,4 @@
import { EventEmitter } from 'events';
import { EventEmitter } from 'node:events';
import createDebug from 'debug';
import type { NxapiElectronIpc } from '../preload/index.js';

View File

@ -1,25 +1,41 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Trans, useTranslation } from 'react-i18next';
import { User } from 'discord-rpc';
import ipc, { events } from '../ipc.js';
import { RequestState, useAsync, useEventListener } from '../util.js';
import { DiscordPresenceSource, DiscordPresenceSourceUrl, DiscordPresenceSourceCoral } from '../../common/types.js';
import { DiscordPresenceSource, DiscordPresenceSourceUrl, DiscordPresenceSourceCoral, DiscordStatus } from '../../common/types.js';
import { DiscordPresence } from '../../../discord/types.js';
import { DISCORD_COLOUR, TEXT_COLOUR_DARK } from '../constants.js';
import { NintendoSwitchUser } from '../components/index.js';
import Warning from '../components/icons/warning.js';
export default function DiscordPresenceSource(props: {
source: DiscordPresenceSource | null;
presence: DiscordPresence | null;
user: User | null;
}) {
const [status, setStatus] = useState<DiscordStatus | null>(null);
useEffect(() => {
ipc.getDiscordStatus().then(setStatus);
}, [ipc]);
useEventListener(events, 'update-discord-status', setStatus, []);
const showErrorDetails = useCallback(() => {
ipc.showDiscordLastUpdateError();
}, [ipc]);
if (!props.source) return null;
return <TouchableOpacity onPress={() => ipc.showDiscordModal()}>
<View style={[styles.discord, !props.source ? styles.discordInactive : null]}>
{renderDiscordPresenceSource(props.source)}
{props.presence || props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
{status?.error_message ?
<DiscordPresenceError message={status?.error_message} onPress={showErrorDetails} /> : null}
</View>
</TouchableOpacity>;
}
@ -99,7 +115,7 @@ function DiscordPresence(props: {
const user_image_url = props.user ?
props.user.avatar ? 'https://cdn.discordapp.com/avatars/' + props.user.id + '/' + props.user.avatar + '.png' :
!props.user.discriminator || props.user.discriminator === '0' ?
'https://cdn.discordapp.com/embed/avatars/' + ((parseInt(props.user.id) >> 22) % 5) + '.png' :
'https://cdn.discordapp.com/embed/avatars/' + ((parseInt(props.user.id) >> 22) % 6) + '.png' :
'https://cdn.discordapp.com/embed/avatars/' + (parseInt(props.user.discriminator) % 5) + '.png' : undefined;
return <View style={styles.discordPresenceContainer}>
@ -111,7 +127,9 @@ function DiscordPresence(props: {
{props.user ? <View style={styles.discordUser}>
<Image source={{uri: user_image_url, width: 18, height: 18}} style={styles.discordUserImage} />
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">
{props.user.username}<Text style={styles.discordUserDiscriminator}>#{props.user.discriminator}</Text>
{props.user.username}
{props.user.discriminator && props.user.discriminator !== '0' ?
<Text style={styles.discordUserDiscriminator}>#{props.user.discriminator}</Text> : null}
</Text>
</View> : <View style={styles.discordUser}>
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">{t('discord_not_connected')}</Text>
@ -119,6 +137,18 @@ function DiscordPresence(props: {
</View>;
}
function DiscordPresenceError(props: {
message: string;
onPress?: () => void;
}) {
return <TouchableOpacity onPress={props.onPress} style={styles.errorTouchable}>
<View style={styles.error}>
<Text style={styles.icon}><Warning /></Text>
<Text style={styles.errorText} numberOfLines={1} ellipsizeMode="tail">{props.message}</Text>
</View>
</TouchableOpacity>;
}
const styles = StyleSheet.create({
discord: {
backgroundColor: DISCORD_COLOUR,
@ -172,4 +202,23 @@ const styles = StyleSheet.create({
discordUserDiscriminator: {
opacity: 0.7,
},
errorTouchable: {
marginVertical: -16,
marginHorizontal: -20,
marginTop: 6,
paddingVertical: 16,
paddingHorizontal: 20,
paddingTop: 10,
},
error: {
flexDirection: 'row',
},
icon: {
marginRight: 10,
color: TEXT_COLOUR_DARK,
},
errorText: {
color: TEXT_COLOUR_DARK,
},
});

View File

@ -116,7 +116,8 @@ function _Preferences(props: {
value={discord_options.user} />);
}
for (const user of discord_users ?? []) {
discord_user_picker.push(<Picker.Item key={user.id} label={user.username + '#' + user.discriminator}
discord_user_picker.push(<Picker.Item key={user.id}
label={user.username + (user.discriminator && user.discriminator !== '0' ? '#' + user.discriminator : '')}
value={user.id} />);
}

View File

@ -146,9 +146,11 @@ function WindowTitle(props: {
const styles = StyleSheet.create({
app: {
// @ts-expect-error vh unit only supported on web
height: Platform.OS === 'web' ? '100vh' : '100%',
},
appScrollable: {
// @ts-expect-error vh unit only supported on web
minHeight: Platform.OS === 'web' ? '100vh' : '100%',
},
});

View File

@ -48,6 +48,10 @@ export interface DiscordPresenceExternalMonitorsConfiguration {
enable_splatnet3_monitoring?: boolean;
}
export interface DiscordStatus {
error_message: string | null;
}
export interface LoginItem {
supported: boolean;
startup_enabled: boolean;

View File

@ -1,6 +1,6 @@
import { i18n } from 'i18next';
import { GITHUB_MIRROR_URL, GITLAB_URL, ISSUES_URL } from '../../common/constants.js';
import { app, BrowserWindow, Menu, MenuItem, shell } from './electron.js';
import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron';
import { App } from './index.js';
let appinstance: App | null;

View File

@ -1,37 +0,0 @@
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const electron = require('electron');
export const app = electron.app;
export const BrowserWindow = electron.BrowserWindow;
export const clipboard = electron.clipboard;
export const dialog = electron.dialog;
export const ipcMain = electron.ipcMain;
export const Menu = electron.Menu;
export const MenuItem = electron.MenuItem;
export const nativeImage = electron.nativeImage;
export const nativeTheme = electron.nativeTheme;
export const Notification = electron.Notification;
export const session = electron.session;
export const ShareMenu = electron.ShareMenu;
export const shell = electron.shell;
export const systemPreferences = electron.systemPreferences;
export const Tray = electron.Tray;
export type BrowserWindow = import('electron').BrowserWindow;
export type BrowserWindowConstructorOptions = import('electron').BrowserWindowConstructorOptions;
export type IpcMain = import('electron').IpcMain;
export type IpcMainInvokeEvent = import('electron').IpcMainInvokeEvent;
export type KeyboardEvent = import('electron').KeyboardEvent;
export type LoginItemSettings = import('electron').LoginItemSettings;
export type LoginItemSettingsOptions = import('electron').LoginItemSettingsOptions;
export type Menu = import('electron').Menu;
export type MenuItem = import('electron').MenuItem;
export type MessageBoxOptions = import('electron').MessageBoxOptions;
export type Notification = import('electron').Notification;
export type Settings = import('electron').Settings;
export type ShareMenu = import('electron').ShareMenu;
export type SharingItem = import('electron').SharingItem;
export type Tray = import('electron').Tray;
export type WebContents = import('electron').WebContents;

View File

@ -1,7 +1,8 @@
import { app, BrowserWindow, dialog, ipcMain, LoginItemSettingsOptions, Menu } from './electron.js';
import { app, BrowserWindow, ipcMain, session, Settings } from 'electron';
import process from 'node:process';
import * as path from 'node:path';
import { EventEmitter } from 'node:events';
import { setGlobalDispatcher } from 'undici';
import * as persist from 'node-persist';
import { i18n } from 'i18next';
import MenuApp from './menu.js';
@ -9,7 +10,7 @@ import { handleOpenWebServiceUri } from './webservices.js';
import { EmbeddedPresenceMonitor, PresenceMonitorManager } from './monitor.js';
import { createModalWindow, createWindow } from './windows.js';
import { sendToAllWindows, setupIpc } from './ipc.js';
import { askUserForUri } from './util.js';
import { askUserForUri, buildElectronProxyAgent, showErrorDialog } from './util.js';
import { setAppInstance, updateMenuLanguage } from './app-menu.js';
import { handleAuthUri } from './na-auth.js';
import { DiscordPresenceConfiguration, LoginItem, LoginItemOptions, WindowType } from '../common/types.js';
@ -22,6 +23,7 @@ import { dev, dir, git, release, version } from '../../util/product.js';
import { addUserAgent } from '../../util/useragent.js';
import { initStorage, paths } from '../../util/storage.js';
import createI18n, { languages } from '../i18n/index.js';
import { CoralApiInterface } from '../../api/coral.js';
const debug = createDebug('app:main');
@ -31,7 +33,7 @@ export const protocol_registration_options = dev && process.platform === 'win32'
path.join(dir, 'dist', 'app', 'app-entry.cjs'),
],
} : null;
export const login_item_options: LoginItemSettingsOptions = {
export const login_item_options: Settings = {
path: process.execPath,
args: dev ? [
path.join(dir, 'dist', 'app', 'app-entry.cjs'),
@ -164,6 +166,12 @@ export async function init() {
addUserAgent('nxapi-app (Chromium ' + process.versions.chrome + '; Electron ' + process.versions.electron + ')');
setAboutPanelOptions();
const agent = buildElectronProxyAgent({
session: session.defaultSession,
});
setGlobalDispatcher(agent);
app.configureHostResolver({enableBuiltInResolver: false});
const [storage, i18n] = await Promise.all([
@ -319,7 +327,7 @@ interface SavedMonitorState {
}
export class Store extends EventEmitter {
readonly users: Users<CoralUser>;
readonly users: Users<CoralUser<CoralApiInterface>>;
constructor(
readonly app: App,
@ -471,10 +479,9 @@ export class Store extends EventEmitter {
} catch (err) {
debug('Error restoring monitor for user %s', user.id, err);
const {response} = await dialog.showMessageBox({
const {response} = await showErrorDialog({
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for user ' + user.id,
detail: err instanceof Error ? err.stack ?? err.message : err as any,
type: 'error',
error: err,
buttons: ['OK', 'Retry'],
defaultId: 1,
});
@ -500,10 +507,10 @@ export class Store extends EventEmitter {
} catch (err) {
debug('Error restoring monitor for presence URL %s', state.discord_presence.source.url, err);
const {response} = await dialog.showMessageBox({
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for presence URL ' + state.discord_presence.source.url,
detail: err instanceof Error ? err.stack ?? err.message : err as any,
type: 'error',
const {response} = await showErrorDialog({
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for presence URL ' +
state.discord_presence.source.url,
error: err,
buttons: ['OK', 'Retry'],
defaultId: 1,
});

View File

@ -1,12 +1,11 @@
import { BrowserWindow, clipboard, dialog, IpcMain, KeyboardEvent, Menu, MenuItem, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
import * as util from 'node:util';
import { BrowserWindow, clipboard, IpcMain, IpcMainInvokeEvent, KeyboardEvent, Menu, MenuItem, ShareMenu, SharingItem, shell, systemPreferences } from 'electron';
import { User } from 'discord-rpc';
import openWebService, { QrCodeReaderOptions, WebServiceIpc, WebServiceValidationError } from './webservices.js';
import { createModalWindow, createWindow, getWindowConfiguration, setWindowHeight } from './windows.js';
import openWebService, { handleOpenWebServiceError, QrCodeReaderOptions, WebServiceIpc, WebServiceValidationError } from './webservices.js';
import { createModalWindow, getWindowConfiguration, setWindowHeight } from './windows.js';
import { askAddNsoAccount, askAddPctlAccount } from './na-auth.js';
import { App } from './index.js';
import { EmbeddedPresenceMonitor } from './monitor.js';
import { DiscordPresenceConfiguration, DiscordPresenceSource, LoginItemOptions, WindowType } from '../common/types.js';
import { DiscordPresenceConfiguration, DiscordPresenceSource, DiscordStatus, LoginItemOptions, WindowType } from '../common/types.js';
import { CurrentUser, Friend, Game, PresenceState, WebService } from '../../api/coral-types.js';
import { NintendoAccountUser } from '../../api/na.js';
import createDebug from '../../util/debug.js';
@ -16,6 +15,8 @@ import { defaultTitle } from '../../discord/titles.js';
import type { FriendProps } from '../browser/friend/index.js';
import type { DiscordSetupProps } from '../browser/discord/index.js';
import type { AddFriendProps } from '../browser/add-friend/index.js';
import { MembershipRequiredError } from '../../common/auth/util.js';
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../../util/errors.js';
const debug = createDebug('app:main:ipc');
@ -39,70 +40,80 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
sendToAllWindows('nxapi:systemPreferences:accent-colour', accent_colour);
});
ipcMain.handle('nxapi:systemPreferences:getloginitem', () => appinstance.store.getLoginItem());
ipcMain.handle('nxapi:systemPreferences:setloginitem', (e, settings: LoginItemOptions) => appinstance.store.setLoginItem(settings));
const handle = (channel: string, listener: (event: IpcMainInvokeEvent, ...args: any[]) => unknown) => ipcMain.handle('nxapi:' + channel, async (event, ...args) => {
try {
return {result: await listener.call(null, event, ...args)};
} catch (err) {
debug('Error invoking IPC method', channel, err);
ipcMain.handle('nxapi:update:get', () => appinstance.updater.cache ?? appinstance.updater.check());
ipcMain.handle('nxapi:update:check', () => appinstance.updater.check());
if (!(err instanceof Error)) err = new Error(ErrorDescription.getErrorDescription(err));
const description = err instanceof HasErrorDescription ? err[ErrorDescriptionSymbol] : null;
return {
error_type: (err as Error).constructor.name,
message: (err as Error).message,
type: description?.type,
description: ErrorDescription.getErrorDescription(err),
data: err,
};
}
});
handle('systemPreferences:getloginitem', () => appinstance.store.getLoginItem());
handle('systemPreferences:setloginitem', (e, settings: LoginItemOptions) => appinstance.store.setLoginItem(settings));
handle('update:get', () => appinstance.updater.cache ?? appinstance.updater.check());
handle('update:check', () => appinstance.updater.check());
setTimeout(async () => {
const update = await appinstance.updater.check();
if (update) sendToAllWindows('nxapi:update:latest', update);
}, 60 * 60 * 1000);
ipcMain.handle('nxapi:accounts:list', () => storage.getItem('NintendoAccountIds'));
ipcMain.handle('nxapi:accounts:add-coral', () => askAddNsoAccount(appinstance).then(u => u?.data.user.id));
ipcMain.handle('nxapi:accounts:add-moon', () => askAddPctlAccount(appinstance).then(u => u?.data.user.id));
handle('accounts:list', () => storage.getItem('NintendoAccountIds'));
handle('accounts:add-coral', () => askAddNsoAccount(appinstance).then(u => u?.data.user.id));
handle('accounts:add-moon', () => askAddPctlAccount(appinstance).then(u => u?.data.user.id));
ipcMain.handle('nxapi:coral:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id));
ipcMain.handle('nxapi:coral:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token));
ipcMain.handle('nxapi:coral:announcements', (e, token: string) => store.users.get(token).then(u => u.announcements.result));
ipcMain.handle('nxapi:coral:friends', (e, token: string) => store.users.get(token).then(u => u.getFriends()));
ipcMain.handle('nxapi:coral:webservices', (e, token: string) => store.users.get(token).then(u => u.getWebServices()));
ipcMain.handle('nxapi:coral:openwebservice', (e, webservice: WebService, token: string, qs?: string) =>
handle('coral:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id));
handle('coral:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token));
handle('coral:announcements', (e, token: string) => store.users.get(token).then(u => u.announcements.result));
handle('coral:friends', (e, token: string) => store.users.get(token).then(u => u.getFriends()));
handle('coral:webservices', (e, token: string) => store.users.get(token).then(u => u.getWebServices()));
handle('coral:openwebservice', (e, webservice: WebService, token: string, qs?: string) =>
store.users.get(token).then(u => openWebService(store, token, u.nso, u.data, webservice, qs)
.catch(err => err instanceof WebServiceValidationError ? dialog.showMessageBox(BrowserWindow.fromWebContents(e.sender)!, {
type: 'error',
message: (err instanceof Error ? err.name : 'Error') + ' opening web service',
detail: (err instanceof Error ? err.stack ?? err.message : err) + '\n\n' + util.inspect({
webservice: {
id: webservice.id,
name: webservice.name,
uri: webservice.uri,
},
qs,
user_na_id: u.data.user.id,
user_nsa_id: u.data.nsoAccount.user.nsaId,
user_coral_id: u.data.nsoAccount.user.id,
}, {compact: true}),
}) : null)));
ipcMain.handle('nxapi:coral:activeevent', (e, token: string) => store.users.get(token).then(u => u.getActiveEvent()));
ipcMain.handle('nxapi:coral:friendcodeurl', (e, token: string) => store.users.get(token).then(u => u.nso.getFriendCodeUrl()));
ipcMain.handle('nxapi:coral:friendcode', (e, token: string, friendcode: string, hash?: string) => store.users.get(token).then(u => u.nso.getUserByFriendCode(friendcode, hash)));
ipcMain.handle('nxapi:coral:addfriend', (e, token: string, nsaid: string) => store.users.get(token).then(u => u.addFriend(nsaid)));
.catch(err => err instanceof WebServiceValidationError || err instanceof MembershipRequiredError ?
handleOpenWebServiceError(err, webservice, qs, u.data, BrowserWindow.fromWebContents(e.sender)!) :
null)));
handle('coral:activeevent', (e, token: string) => store.users.get(token).then(u => u.getActiveEvent()));
handle('coral:friendcodeurl', (e, token: string) => store.users.get(token).then(u => u.nso.getFriendCodeUrl()));
handle('coral:friendcode', (e, token: string, friendcode: string, hash?: string) => store.users.get(token).then(u => u.nso.getUserByFriendCode(friendcode, hash)));
handle('coral:addfriend', (e, token: string, nsaid: string) => store.users.get(token).then(u => u.addFriend(nsaid)));
ipcMain.handle('nxapi:window:showpreferences', () => appinstance.showPreferencesWindow().id);
ipcMain.handle('nxapi:window:showfriend', (e, props: FriendProps) =>
handle('window:showpreferences', () => appinstance.showPreferencesWindow().id);
handle('window:showfriend', (e, props: FriendProps) =>
createModalWindow(WindowType.FRIEND, props, e.sender).id);
ipcMain.handle('nxapi:window:discord', (e, props: DiscordSetupProps) =>
handle('window:discord', (e, props: DiscordSetupProps) =>
createModalWindow(WindowType.DISCORD_PRESENCE, props).id);
ipcMain.handle('nxapi:window:addfriend', (e, props: AddFriendProps) =>
handle('window:addfriend', (e, props: AddFriendProps) =>
createModalWindow(WindowType.ADD_FRIEND, props, e.sender).id);
ipcMain.handle('nxapi:window:setheight', (e, height: number) => {
handle('window:setheight', (e, height: number) => {
const window = BrowserWindow.fromWebContents(e.sender)!;
setWindowHeight(window, height);
});
ipcMain.handle('nxapi:discord:config', () => appinstance.monitors.getDiscordPresenceConfiguration());
ipcMain.handle('nxapi:discord:setconfig', (e, config: DiscordPresenceConfiguration | null) => appinstance.monitors.setDiscordPresenceConfiguration(config));
ipcMain.handle('nxapi:discord:options', () => appinstance.monitors.getActiveDiscordPresenceOptions() ?? appinstance.store.getSavedDiscordPresenceOptions());
ipcMain.handle('nxapi:discord:savedoptions', () => appinstance.store.getSavedDiscordPresenceOptions());
ipcMain.handle('nxapi:discord:setoptions', (e, options: Omit<DiscordPresenceConfiguration, 'source'>) => appinstance.monitors.setDiscordPresenceOptions(options));
ipcMain.handle('nxapi:discord:source', () => appinstance.monitors.getDiscordPresenceSource());
ipcMain.handle('nxapi:discord:setsource', (e, source: DiscordPresenceSource | null) => appinstance.monitors.setDiscordPresenceSource(source));
ipcMain.handle('nxapi:discord:presence', () => appinstance.monitors.getDiscordPresence());
ipcMain.handle('nxapi:discord:user', () => appinstance.monitors.getActiveDiscordPresenceMonitor()?.discord.rpc?.client.user ?? null);
ipcMain.handle('nxapi:discord:users', async () => {
handle('discord:config', () => appinstance.monitors.getDiscordPresenceConfiguration());
handle('discord:setconfig', (e, config: DiscordPresenceConfiguration | null) => appinstance.monitors.setDiscordPresenceConfiguration(config));
handle('discord:options', () => appinstance.monitors.getActiveDiscordPresenceOptions() ?? appinstance.store.getSavedDiscordPresenceOptions());
handle('discord:savedoptions', () => appinstance.store.getSavedDiscordPresenceOptions());
handle('discord:setoptions', (e, options: Omit<DiscordPresenceConfiguration, 'source'>) => appinstance.monitors.setDiscordPresenceOptions(options));
handle('discord:source', () => appinstance.monitors.getDiscordPresenceSource());
handle('discord:setsource', (e, source: DiscordPresenceSource | null) => appinstance.monitors.setDiscordPresenceSource(source));
handle('discord:presence', () => appinstance.monitors.getDiscordPresence());
handle('discord:status', () => appinstance.monitors.getDiscordStatus());
handle('discord:showerror', () => appinstance.monitors.showDiscordPresenceLastUpdateError());
handle('discord:user', () => appinstance.monitors.getActiveDiscordPresenceMonitor()?.discord.rpc?.client.user ?? null);
handle('discord:users', async () => {
const users: User[] = [];
for (const client of await getDiscordRpcClients()) {
@ -117,17 +128,17 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
return users;
});
ipcMain.handle('nxapi:moon:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken-pctl.' + id));
ipcMain.handle('nxapi:moon:getcachedtoken', (e, token: string) => storage.getItem('MoonToken.' + token));
handle('moon:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken-pctl.' + id));
handle('moon:getcachedtoken', (e, token: string) => storage.getItem('MoonToken.' + token));
ipcMain.handle('nxapi:misc:open-url', (e, url: string) => shell.openExternal(url));
ipcMain.handle('nxapi:misc:share', (e, item: SharingItem) =>
handle('misc:open-url', (e, url: string) => shell.openExternal(url));
handle('misc:share', (e, item: SharingItem) =>
new ShareMenu(item).popup({window: BrowserWindow.fromWebContents(e.sender)!}));
ipcMain.handle('nxapi:menu:user', (e, user: NintendoAccountUser, nso?: CurrentUser, moon?: boolean) =>
handle('menu:user', (e, user: NintendoAccountUser, nso?: CurrentUser, moon?: boolean) =>
(buildUserMenu(appinstance, user, nso, moon, BrowserWindow.fromWebContents(e.sender) ?? undefined)
.popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:add-user', e => (Menu.buildFromTemplate([
handle('menu:add-user', e => (Menu.buildFromTemplate([
new MenuItem({label: t('add_account.add_account_coral')!, click:
(item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddNsoAccount(appinstance, !event.shiftKey)}),
@ -135,7 +146,7 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
(item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddPctlAccount(appinstance, !event.shiftKey)}),
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:friend-code', (e, fc: CurrentUser['links']['friendCode']) => (Menu.buildFromTemplate([
handle('menu:friend-code', (e, fc: CurrentUser['links']['friendCode']) => (Menu.buildFromTemplate([
new MenuItem({label: 'SW-' + fc.id, enabled: false}),
new MenuItem({label: t('friend_code.share')!, role: 'shareMenu', sharingItem: {texts: ['SW-' + fc.id]}}),
new MenuItem({label: t('friend_code.copy')!, click: () => clipboard.writeText('SW-' + fc.id)}),
@ -146,7 +157,7 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
})!, enabled: false}),
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:friend', (e, user: NintendoAccountUser, nso: CurrentUser, friend: Friend) =>
handle('menu:friend', (e, user: NintendoAccountUser, nso: CurrentUser, friend: Friend) =>
(buildFriendMenu(appinstance, user, nso, friend)
.popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
@ -163,11 +174,13 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
ipcMain.handle('nxapi:webserviceapi:copyToClipboard', (e, data: string) => webserviceipc.copyToClipboard(e, data));
ipcMain.handle('nxapi:webserviceapi:downloadImages', (e, data: string) => webserviceipc.downloadImages(e, data));
ipcMain.handle('nxapi:webserviceapi:completeLoading', e => webserviceipc.completeLoading(e));
ipcMain.handle('nxapi:webserviceapi:clearUnreadFlag', e => webserviceipc.clearUnreadFlag(e));
store.on('update-nintendo-accounts', () => sendToAllWindows('nxapi:accounts:shouldrefresh'));
store.on('update-discord-presence-source', () => sendToAllWindows('nxapi:discord:shouldrefresh'));
store.on('update-discord-presence', (p: DiscordPresence) => sendToAllWindows('nxapi:discord:presence', p));
store.on('update-discord-user', (u: User) => sendToAllWindows('nxapi:discord:user', u));
store.on('update-discord-status', (s: DiscordStatus | null) => sendToAllWindows('nxapi:discord:status', s));
}
export function sendToAllWindows(channel: string, ...args: any[]) {

View File

@ -1,19 +1,19 @@
import { app, dialog, Menu, Tray, nativeImage, MenuItem, BrowserWindow, KeyboardEvent } from './electron.js';
import { app, Menu, Tray, nativeImage, MenuItem, BrowserWindow, KeyboardEvent } from 'electron';
import path from 'node:path';
import * as util from 'node:util';
import { askAddNsoAccount, askAddPctlAccount } from './na-auth.js';
import { App } from './index.js';
import openWebService, { WebServiceValidationError } from './webservices.js';
import openWebService, { handleOpenWebServiceError, WebServiceValidationError } from './webservices.js';
import { EmbeddedPresenceMonitor, EmbeddedProxyPresenceMonitor } from './monitor.js';
import { createModalWindow, createWindow } from './windows.js';
import { createModalWindow } from './windows.js';
import { WindowType } from '../common/types.js';
import CoralApi from '../../api/coral.js';
import { CoralApiInterface } from '../../api/coral.js';
import { WebService } from '../../api/coral-types.js';
import { SavedToken } from '../../common/auth/coral.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import { CachedWebServicesList } from '../../common/users.js';
import createDebug from '../../util/debug.js';
import { dev, dir, git } from '../../util/product.js';
import { MembershipRequiredError } from '../../common/auth/util.js';
import { languages } from '../i18n/index.js';
const debug = createDebug('app:main:menu');
@ -171,11 +171,7 @@ export default class MenuApp {
await this.openWebService(token, nso, data, webservice);
} catch (err) {
dialog.showMessageBox({
type: 'error',
message: (err instanceof Error ? err.name : 'Error') + ' opening web service',
detail: '' + (err instanceof Error ? err.stack ?? err.message : err),
});
handleOpenWebServiceError(err, webservice);
}
},
}));
@ -184,26 +180,13 @@ export default class MenuApp {
return items;
}
async openWebService(token: string, nso: CoralApi, data: SavedToken, webservice: WebService) {
async openWebService(token: string, coral: CoralApiInterface, data: SavedToken, webservice: WebService) {
try {
await openWebService(this.app.store, token, nso, data, webservice);
await openWebService(this.app.store, token, coral, data, webservice);
} catch (err) {
if (!(err instanceof WebServiceValidationError)) return;
if (!(err instanceof WebServiceValidationError) && !(err instanceof MembershipRequiredError)) return;
dialog.showMessageBox({
type: 'error',
message: (err instanceof Error ? err.name : 'Error') + ' opening web service',
detail: (err instanceof Error ? err.stack ?? err.message : err) + '\n\n' + util.inspect({
webservice: {
id: webservice.id,
name: webservice.name,
uri: webservice.uri,
},
user_na_id: data.user.id,
user_nsa_id: data.nsoAccount.user.nsaId,
user_coral_id: data.nsoAccount.user.id,
}, {compact: true}),
});
handleOpenWebServiceError(err, webservice, undefined, data);
}
}

View File

@ -1,9 +1,9 @@
import { dialog, Notification } from './electron.js';
import { Notification } from 'electron';
import { i18n } from 'i18next';
import { App } from './index.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { DiscordPresenceConfiguration, DiscordPresenceExternalMonitorsConfiguration, DiscordPresenceSource } from '../common/types.js';
import { CurrentUser, Friend, Game, CoralErrorResponse } from '../../api/coral-types.js';
import { showErrorDialog, tryGetNativeImageFromUrl } from './util.js';
import { DiscordPresenceConfiguration, DiscordPresenceExternalMonitorsConfiguration, DiscordPresenceSource, DiscordStatus } from '../common/types.js';
import { CurrentUser, Friend, Game, CoralError } from '../../api/coral-types.js';
import { ErrorResponse } from '../../api/util.js';
import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../../common/presence.js';
import { NotificationManager } from '../../common/notify.js';
@ -12,6 +12,10 @@ import { LoopResult } from '../../util/loop.js';
import { DiscordPresence, DiscordPresencePlayTime, ErrorResult } from '../../discord/types.js';
import { DiscordRpcClient } from '../../discord/rpc.js';
import SplatNet3Monitor, { getConfigFromAppConfig as getSplatNet3MonitorConfigFromAppConfig } from '../../discord/monitor/splatoon3.js';
import { ErrorDescription } from '../../util/errors.js';
import { CoralErrorResponse } from '../../api/coral.js';
import { NintendoAccountAuthErrorResponse, NintendoAccountErrorResponse } from '../../api/na.js';
import { InvalidNintendoAccountTokenError } from '../../common/auth/na.js';
const debug = createDebug('app:main:monitor');
@ -54,10 +58,9 @@ export class PresenceMonitorManager {
this.app.store.emit('update-discord-user', client?.user ?? null);
};
i.discord.onMonitorError = async (monitor, instance, err) => {
const {response} = await dialog.showMessageBox({
const {response} = await showErrorDialog({
message: err.name + ' in external monitor ' + monitor.name,
detail: err.stack ?? err.message,
type: 'error',
error: err,
buttons: ['OK', 'Retry', 'Stop'],
defaultId: 0,
});
@ -72,6 +75,19 @@ export class PresenceMonitorManager {
return ErrorResult.IGNORE;
};
i.discord.onUpdateError = err => {
const status: DiscordStatus = {
error_message: err instanceof Error ?
err.name + ': ' + err.message :
ErrorDescription.getErrorDescription(err),
};
this.app.store.emit('update-discord-status', status);
};
i.discord.onUpdateSuccess = () => {
const status: DiscordStatus = {error_message: null};
this.app.store.emit('update-discord-status', status);
};
i.onError = err => this.handleError(i, err);
this.monitors.push(i);
@ -101,6 +117,19 @@ export class PresenceMonitorManager {
this.app.store.emit('update-discord-user', client?.user ?? null);
};
i.discord.onUpdateError = err => {
const status: DiscordStatus = {
error_message: err instanceof Error ?
err.name + ': ' + err.message :
ErrorDescription.getErrorDescription(err),
};
this.app.store.emit('update-discord-status', status);
};
i.discord.onUpdateSuccess = () => {
const status: DiscordStatus = {error_message: null};
this.app.store.emit('update-discord-status', status);
};
i.onError = err => this.handleError(i, err);
this.monitors.push(i);
@ -321,6 +350,8 @@ export class PresenceMonitorManager {
this.app.store.saveMonitorState(this);
this.app.menu?.updateMenu();
this.app.store.emit('update-discord-presence-source', source);
} else {
this.app.store.emit('update-discord-status', null);
}
}
@ -349,12 +380,15 @@ export class PresenceMonitorManager {
async handleError(
monitor: EmbeddedPresenceMonitor | EmbeddedProxyPresenceMonitor,
err: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException
err: ErrorResponse<CoralError> | NodeJS.ErrnoException
): Promise<LoopResult> {
const {response} = await dialog.showMessageBox({
if (monitor instanceof EmbeddedProxyPresenceMonitor || checkShouldIgnorePresenceMonitorError(err)) {
return LoopResult.OK;
}
const {response} = await showErrorDialog({
message: err.name + ' updating presence monitor',
detail: err.stack ?? err.message,
type: 'error',
error: err,
buttons: ['OK', 'Retry'],
defaultId: 0,
});
@ -365,10 +399,33 @@ export class PresenceMonitorManager {
return LoopResult.OK;
}
async getDiscordStatus(): Promise<DiscordStatus | null> {
const monitor = this.getActiveDiscordPresenceMonitor();
if (!monitor) return null;
return {
error_message: monitor.discord.last_update_error ?
monitor.discord.last_update_error instanceof Error ?
monitor.discord.last_update_error.name + ': ' + monitor.discord.last_update_error.message :
ErrorDescription.getErrorDescription(monitor.discord.last_update_error) : null,
};
}
async showDiscordPresenceLastUpdateError() {
const monitor = this.getActiveDiscordPresenceMonitor();
const error = monitor?.discord.last_update_error;
if (!error) return;
await showErrorDialog({
message: error.name + ' updating presence monitor',
error,
});
}
}
export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
onError?: (error: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException) =>
onError?: (error: ErrorResponse<CoralError> | NodeJS.ErrnoException) =>
Promise<LoopResult | void> | LoopResult | void = undefined;
enable() {
@ -411,7 +468,7 @@ export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
}
}
async handleError(err: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
async handleError(err: ErrorResponse<CoralError> | NodeJS.ErrnoException): Promise<LoopResult> {
try {
return await super.handleError(err);
} catch (err: any) {
@ -427,7 +484,7 @@ export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
export class EmbeddedProxyPresenceMonitor extends ZncProxyDiscordPresence {
notifications: NotificationManager | null = null;
onError?: (error: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException) =>
onError?: (error: ErrorResponse<CoralError> | NodeJS.ErrnoException) =>
Promise<LoopResult | void> | LoopResult | void = undefined;
enable() {
@ -470,7 +527,7 @@ export class EmbeddedProxyPresenceMonitor extends ZncProxyDiscordPresence {
}
}
async handleError(err: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
async handleError(err: ErrorResponse<CoralError> | NodeJS.ErrnoException): Promise<LoopResult> {
try {
return await super.handleError(err);
} catch (err: any) {
@ -534,3 +591,31 @@ export class ElectronNotificationManager extends NotificationManager {
}).show();
}
}
function checkShouldIgnorePresenceMonitorError(err: Error): boolean {
// Invalid session token, the user needs to sign in again
if (err instanceof InvalidNintendoAccountTokenError) {
return false;
}
// Received error getting a Nintendo Account token; usually this means
// the session token is invalid and the user needs to sign in again
if (err instanceof NintendoAccountAuthErrorResponse && err.data) {
return false;
}
// Received error getting Nintendo Account user data
// This can only happen once when the app starts and there isn't a cached token
if (err instanceof NintendoAccountErrorResponse && err.data) {
return false;
}
// Received error from Coral (see CoralStatus in src/api/coral-types.ts)
// This usually should either not happen (e.g. BAD_REQUEST), is something the
// user needs to do (e.g. NSA_NOT_LINKED or UPGRADE_REQUIRED), or is permanent
if (err instanceof CoralErrorResponse && err.data) {
return false;
}
return true;
}

View File

@ -1,52 +1,23 @@
import { app, BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell } from './electron.js';
import { app, BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell } from 'electron';
import process from 'node:process';
import * as crypto from 'node:crypto';
import * as persist from 'node-persist';
import { App, protocol_registration_options } from './index.js';
import { createModalWindow, createWindow } from './windows.js';
import { createModalWindow } from './windows.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { WindowType } from '../common/types.js';
import { getNintendoAccountSessionToken, NintendoAccountAuthError, NintendoAccountSessionToken } from '../../api/na.js';
import { ZNCA_CLIENT_ID } from '../../api/coral.js';
import { ZNMA_CLIENT_ID } from '../../api/moon.js';
import { ErrorResponse } from '../../api/util.js';
import { NintendoAccountAuthErrorResponse, NintendoAccountSessionAuthorisation, NintendoAccountSessionAuthorisationError, NintendoAccountSessionToken } from '../../api/na.js';
import { NintendoAccountSessionAuthorisationCoral } from '../../api/coral.js';
import { NintendoAccountSessionAuthorisationMoon } from '../../api/moon.js';
import { getToken } from '../../common/auth/coral.js';
import { getPctlToken } from '../../common/auth/moon.js';
import createDebug from '../../util/debug.js';
import { Jwt } from '../../util/jwt.js';
import { ZNCA_API_USE_TEXT, ZNCA_API_USE_URL } from '../../common/constants.js';
import { InvalidNintendoAccountTokenError } from '../../common/auth/na.js';
const debug = createDebug('app:main:na-auth');
export type NintendoAccountAuthResult = NintendoAccountSessionToken;
export function getAuthUrl(client_id: string, scope: string | string[]) {
const state = crypto.randomBytes(36).toString('base64url');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest().toString('base64url');
const params = {
state,
redirect_uri: 'npf' + client_id + '://auth',
client_id,
scope: typeof scope === 'string' ? scope : scope.join(' '),
response_type: 'session_token_code',
session_token_code_challenge: challenge,
session_token_code_challenge_method: 'S256',
theme: 'login_form',
};
const url = 'https://accounts.nintendo.com/connect/1.0.0/authorize?' +
new URLSearchParams(params).toString();
return {
url,
state,
verifier,
challenge,
};
}
const css = `
html {
overflow-x: hidden;
@ -79,57 +50,45 @@ export function createAuthWindow(app: App) {
}
export interface NintendoAccountSessionTokenCode {
authenticator: NintendoAccountSessionAuthorisation;
code: string;
verifier: string;
window?: BrowserWindow;
}
export class AuthoriseError extends Error {
constructor(readonly code: string, message?: string) {
super(message);
}
static fromSearchParams(qs: URLSearchParams) {
const code = qs.get('error') ?? 'unknown_error';
return new AuthoriseError(code, qs.get('error_description') ?? code);
}
}
export class AuthoriseCancelError extends AuthoriseError {
export class AuthoriseCancelError extends NintendoAccountSessionAuthorisationError {
constructor(message?: string) {
super('access_denied', message);
}
}
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window: false,
app: App, authenticator: NintendoAccountSessionAuthorisation, close_window: false,
): Promise<NintendoAccountSessionTokenCode & {window: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window: true,
app: App, authenticator: NintendoAccountSessionAuthorisation, close_window: true,
): Promise<NintendoAccountSessionTokenCode & {window?: never}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window?: boolean,
app: App, authenticator: NintendoAccountSessionAuthorisation, close_window?: boolean,
): Promise<NintendoAccountSessionTokenCode & {window?: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window = true,
app: App, authenticator: NintendoAccountSessionAuthorisation, close_window = true,
) {
return new Promise<NintendoAccountSessionTokenCode>((rs, rj) => {
const {url: authoriseurl, state, verifier, challenge} = getAuthUrl(client_id, scope);
const window = createAuthWindow(app);
const handleAuthUrl = (url: URL) => {
const authorisedparams = new URLSearchParams(url.hash.substr(1));
debug('Redirect URL parameters', [...authorisedparams.entries()]);
if (authorisedparams.get('state') !== state) {
if (authorisedparams.get('state') !== authenticator.state) {
rj(new Error('Invalid state'));
window.close();
return;
}
if (authorisedparams.has('error')) {
rj(AuthoriseError.fromSearchParams(authorisedparams));
rj(NintendoAccountSessionAuthorisationError.fromSearchParams(authorisedparams));
window.close();
return;
}
@ -147,15 +106,15 @@ export function getSessionTokenCodeByInAppBrowser(
if (close_window) {
rs({
authenticator,
code,
verifier,
});
window.close();
} else {
rs({
authenticator,
code,
verifier,
window,
});
}
@ -166,7 +125,7 @@ export function getSessionTokenCodeByInAppBrowser(
debug('will navigate', url);
if (url.protocol === 'npf' + client_id + ':' && url.host === 'auth') {
if (url.protocol === 'npf' + authenticator.client_id + ':' && url.host === 'auth') {
handleAuthUrl(url);
event.preventDefault();
} else if (url.origin === 'https://accounts.nintendo.com') {
@ -189,7 +148,7 @@ export function getSessionTokenCodeByInAppBrowser(
debug('open', details);
if (url.protocol === 'npf' + client_id + ':' && url.host === 'auth') {
if (url.protocol === 'npf' + authenticator.client_id + ':' && url.host === 'auth') {
handleAuthUrl(url);
} else {
shell.openExternal(details.url);
@ -198,40 +157,34 @@ export function getSessionTokenCodeByInAppBrowser(
return {action: 'deny'};
});
debug('Loading Nintendo Account authorisation', {
authoriseurl,
state,
verifier,
challenge,
});
debug('Loading Nintendo Account authorisation', authenticator);
window.loadURL(authoriseurl);
window.loadURL(authenticator.authorise_url);
});
}
const FORCE_MANUAL_AUTH_URI_ENTRY = process.env.NXAPI_FORCE_MANUAL_AUTH === '1';
export function getSessionTokenCodeByDefaultBrowser(
client_id: string, scope: string | string[],
authenticator: NintendoAccountSessionAuthorisation,
close_window = true,
force_manual = FORCE_MANUAL_AUTH_URI_ENTRY
) {
return new Promise<NintendoAccountSessionTokenCode>((rs, rj) => {
const {url: authoriseurl, state, verifier, challenge} = getAuthUrl(client_id, scope);
let window: BrowserWindow | undefined = undefined;
const handleAuthUrl = (url: URL) => {
const authorisedparams = new URLSearchParams(url.hash.substr(1));
debug('Redirect URL parameters', [...authorisedparams.entries()]);
if (authorisedparams.get('state') !== state) {
if (authorisedparams.get('state') !== authenticator.state) {
rj(new Error('Invalid state'));
window?.close();
return;
}
if (authorisedparams.has('error')) {
rj(AuthoriseError.fromSearchParams(authorisedparams));
rj(NintendoAccountSessionAuthorisationError.fromSearchParams(authorisedparams));
window?.close();
return;
}
@ -248,28 +201,23 @@ export function getSessionTokenCodeByDefaultBrowser(
debug('code', code, jwt, sig);
if (window && close_window) window.close();
else if (window) rs({code, verifier, window});
else rs({code, verifier});
else if (window) rs({authenticator, code, window});
else rs({authenticator, code});
};
debug('Prompting user for Nintendo Account authorisation', {
authoriseurl,
state,
verifier,
challenge,
});
debug('Prompting user for Nintendo Account authorisation', authenticator);
const protocol = 'npf' + client_id;
const protocol = 'npf' + authenticator.client_id;
if (force_manual) {
debug('Manual entry forced, prompting for redirect URI');
window = askUserForRedirectUri(authoriseurl, client_id, handleAuthUrl, rj);
window = askUserForRedirectUri(authenticator.authorise_url, authenticator.client_id, handleAuthUrl, rj);
} else if (app.isDefaultProtocolClient(protocol,
protocol_registration_options?.path, protocol_registration_options?.argv
)) {
debug('App is already default protocol handler, opening browser');
auth_state.set(state, [handleAuthUrl, rj, protocol]);
shell.openExternal(authoriseurl);
auth_state.set(authenticator.state, [handleAuthUrl, rj, protocol]);
shell.openExternal(authenticator.authorise_url);
} else {
const registered_app = app.getApplicationNameForProtocol(protocol);
@ -277,11 +225,11 @@ export function getSessionTokenCodeByDefaultBrowser(
protocol_registration_options?.path, protocol_registration_options?.argv
)) {
debug('Another app is using the auth protocol or registration failed, prompting for redirect URI');
window = askUserForRedirectUri(authoriseurl, client_id, handleAuthUrl, rj);
window = askUserForRedirectUri(authenticator.authorise_url, authenticator.client_id, handleAuthUrl, rj);
} else {
debug('App is now default protocol handler, opening browser');
auth_state.set(state, [handleAuthUrl, rj, protocol]);
shell.openExternal(authoriseurl);
auth_state.set(authenticator.state, [handleAuthUrl, rj, protocol]);
shell.openExternal(authenticator.authorise_url);
}
}
});
@ -352,9 +300,10 @@ const NSO_SCOPE = [
];
export async function addNsoAccount(app: App, use_in_app_browser = true) {
const {code, verifier, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(app, ZNCA_CLIENT_ID, NSO_SCOPE, false) :
await getSessionTokenCodeByDefaultBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false);
const authenticator = NintendoAccountSessionAuthorisationCoral.create();
const {code, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(app, authenticator, false) :
await getSessionTokenCodeByDefaultBrowser(authenticator, false);
window?.setFocusable(false);
window?.blurWebView();
@ -383,13 +332,12 @@ export async function addNsoAccount(app: App, use_in_app_browser = true) {
return {nso, data};
} catch (err) {
if (err instanceof ErrorResponse && err.response.url.startsWith('https://accounts.nintendo.com/')) {
const data: NintendoAccountAuthError = err.data;
if (data.error === 'invalid_grant') {
// The session token has expired/was revoked
return authenticateCoralSessionToken(app, code, verifier, true);
}
if (
(err instanceof InvalidNintendoAccountTokenError) ||
(err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant')
) {
// The session token has expired/was revoked
return authenticateCoralSessionToken(app, authenticator, code, true);
}
throw err;
@ -398,7 +346,7 @@ export async function addNsoAccount(app: App, use_in_app_browser = true) {
await checkZncaApiUseAllowed(app, window);
return authenticateCoralSessionToken(app, code, verifier);
return authenticateCoralSessionToken(app, authenticator, code);
} finally {
window?.close();
}
@ -406,10 +354,10 @@ export async function addNsoAccount(app: App, use_in_app_browser = true) {
async function authenticateCoralSessionToken(
app: App,
code: string, verifier: string,
authenticator: NintendoAccountSessionAuthorisation, code: string,
reauthenticate = false,
) {
const token = await getNintendoAccountSessionToken(code, verifier, ZNCA_CLIENT_ID);
const token = await authenticator.getSessionToken(code);
debug('session token', token);
@ -440,7 +388,7 @@ export async function askAddNsoAccount(app: App, iab = true) {
try {
return await addNsoAccount(app, iab);
} catch (err: any) {
if (err instanceof AuthoriseError && err.code === 'access_denied') return;
if (err instanceof NintendoAccountSessionAuthorisationError && err.code === 'access_denied') return;
dialog.showErrorBox(app.i18n.t('na_auth:error.title') ?? 'Error adding account',
err.stack || err.message);
@ -521,9 +469,10 @@ const MOON_SCOPE = [
];
export async function addPctlAccount(app: App, use_in_app_browser = true) {
const {code, verifier, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(app, ZNMA_CLIENT_ID, MOON_SCOPE, false) :
await getSessionTokenCodeByDefaultBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false);
const authenticator = NintendoAccountSessionAuthorisationMoon.create();
const {code, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(app, authenticator, false) :
await getSessionTokenCodeByDefaultBrowser(authenticator, false);
window?.setFocusable(false);
window?.blurWebView();
@ -549,20 +498,19 @@ export async function addPctlAccount(app: App, use_in_app_browser = true) {
return {moon, data};
} catch (err) {
if (err instanceof ErrorResponse && err.response.url.startsWith('https://accounts.nintendo.com/')) {
const data: NintendoAccountAuthError = err.data;
if (data.error === 'invalid_grant') {
// The session token has expired/was revoked
return authenticateMoonSessionToken(app, code, verifier, true);
}
if (
(err instanceof InvalidNintendoAccountTokenError) ||
(err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant')
) {
// The session token has expired/was revoked
return authenticateMoonSessionToken(app, authenticator, code, true);
}
throw err;
}
}
return authenticateMoonSessionToken(app, code, verifier);
return authenticateMoonSessionToken(app, authenticator, code);
} finally {
window?.close();
}
@ -570,10 +518,10 @@ export async function addPctlAccount(app: App, use_in_app_browser = true) {
async function authenticateMoonSessionToken(
app: App,
code: string, verifier: string,
authenticator: NintendoAccountSessionAuthorisation, code: string,
reauthenticate = false,
) {
const token = await getNintendoAccountSessionToken(code, verifier, ZNMA_CLIENT_ID);
const token = await authenticator.getSessionToken(code);
debug('session token', token);
@ -600,7 +548,7 @@ export async function askAddPctlAccount(app: App, iab = true) {
try {
return await addPctlAccount(app, iab);
} catch (err: any) {
if (err instanceof AuthoriseError && err.code === 'access_denied') return;
if (err instanceof NintendoAccountSessionAuthorisationError && err.code === 'access_denied') return;
dialog.showErrorBox(app.i18n.t('na_auth:error.title') ?? 'Error adding account',
err.stack || err.message);

View File

@ -1,10 +1,15 @@
import { BrowserWindow, Menu, MenuItem, nativeImage } from './electron.js';
import { BrowserWindow, dialog, Menu, MenuItem, MessageBoxOptions, nativeImage, Session } from 'electron';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import fetch from 'node-fetch';
import createDebug from '../../util/debug.js';
import { fetch } from 'undici';
import { dir } from '../../util/product.js';
import { App } from './index.js';
import { SavedToken } from '../../common/auth/coral.js';
import { ErrorDescription } from '../../util/errors.js';
import { buildProxyAgent, ProxyAgentOptions } from '../../util/undici-proxy.js';
const debug = createDebug('app:main:util');
export const bundlepath = path.resolve(dir, 'dist', 'app', 'bundle');
@ -65,3 +70,56 @@ export async function askUserForUri(app: App, uri: string, prompt: string): Prom
return selected_user;
}
interface ErrorBoxOptions extends MessageBoxOptions {
error: Error | unknown;
app?: App;
window?: BrowserWindow;
}
export function showErrorDialog(options: ErrorBoxOptions) {
const {error, app, window, ...message_box_options} = options;
const detail = ErrorDescription.getErrorDescription(error);
message_box_options.detail = message_box_options.detail ?
detail + '\n\n' + message_box_options.detail :
detail;
if (!message_box_options.type) message_box_options.type = 'error';
return window ?
dialog.showMessageBox(window, message_box_options) :
dialog.showMessageBox(message_box_options);
}
export function buildElectronProxyAgent(options: ProxyAgentOptions & {
session: Session;
}) {
let warned_proxy_unsupported: string | null = null;
return buildProxyAgent({
...options,
resolveProxy: async origin => {
// https://chromium.googlesource.com/chromium/src/+/HEAD/net/docs/proxy.md
const proxies = await options.session.resolveProxy(origin);
const proxy = proxies.split(';')[0].trim();
if (proxy === 'DIRECT') return null;
if (proxy.startsWith('PROXY ')) {
return new URL('http://' + proxy.substr(6));
}
if (proxy.startsWith('HTTPS ')) {
return new URL('https://' + proxy.substr(6));
}
if (warned_proxy_unsupported !== proxy) {
warned_proxy_unsupported = proxy;
debug('Unsupported proxy', proxy);
}
return null;
},
});
}

View File

@ -1,27 +1,28 @@
import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, nativeImage, nativeTheme, Notification, ShareMenu, shell, WebContents } from './electron.js';
import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, nativeImage, nativeTheme, Notification, ShareMenu, shell, WebContents } from 'electron';
import * as path from 'node:path';
import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import { Buffer } from 'node:buffer';
import * as util from 'node:util';
import fetch from 'node-fetch';
import { fetch } from 'undici';
import mimetypes from 'mime-types';
import { App, Store } from './index.js';
import { createWebServiceWindow } from './windows.js';
import { askUserForUri } from './util.js';
import { askUserForUri, showErrorDialog } from './util.js';
import type { DownloadImagesRequest, NativeShareRequest, NativeShareUrlRequest, QrCodeReaderCameraOptions, QrCodeReaderCheckinOptions, QrCodeReaderCheckinResult, QrCodeReaderPhotoLibraryOptions, SendMessageOptions } from '../preload-webservice/znca-js-api.js';
import createDebug from '../../util/debug.js';
import CoralApi from '../../api/coral.js';
import { CurrentUser, WebService, WebServiceToken } from '../../api/coral-types.js';
import { NintendoAccountUser } from '../../api/na.js';
import { CoralApiInterface, CoralAuthData } from '../../api/coral.js';
import { WebService, WebServiceToken } from '../../api/coral-types.js';
import { SavedToken } from '../../common/auth/coral.js';
import { checkMembershipActive } from '../../common/auth/util.js';
const debug = createDebug('app:main:webservices');
const windows = new Map<string, BrowserWindow>();
const windowapi = new WeakMap<WebContents, [Store, string, CoralApi, SavedToken, WebService]>();
const windowapi = new WeakMap<WebContents, [Store, string, CoralApiInterface, SavedToken, WebService]>();
export default async function openWebService(
store: Store, token: string, nso: CoralApi, data: SavedToken,
store: Store, token: string, coral: CoralApiInterface, data: SavedToken,
webservice: WebService, qs?: string
) {
const windowid = data.nsoAccount.user.nsaId + ':' + webservice.id;
@ -41,12 +42,7 @@ export default async function openWebService(
}
const verifymembership = webservice.customAttributes.find(a => a.attrKey === 'verifyMembership');
if (verifymembership?.attrValue === 'true') {
const membership = data.nsoAccount.user.links.nintendoAccount.membership;
const active = typeof membership.active === 'object' ? membership.active.active : membership.active;
if (!active) throw new WebServiceValidationError('Nintendo Switch Online membership required');
}
if (verifymembership?.attrValue === 'true') checkMembershipActive(data);
const user_title_prefix = '[' + data.user.nickname +
(data.nsoAccount.user.name !== data.user.nickname ? '/' + data.nsoAccount.user.name : '') + '] ';
@ -54,7 +50,7 @@ export default async function openWebService(
const window = createWebServiceWindow(data.nsoAccount.user.nsaId, webservice, user_title_prefix);
windows.set(windowid, window);
windowapi.set(window.webContents, [store, token, nso, data, webservice]);
windowapi.set(window.webContents, [store, token, coral, data, webservice]);
window.on('closed', () => {
windows.delete(windowid);
@ -85,7 +81,7 @@ export default async function openWebService(
return {action: 'deny'};
});
const webserviceToken = await getWebServiceToken(nso, webservice, data.user, data.nsoAccount.user, window);
const webserviceToken = await getWebServiceToken(coral, webservice, qs, data, window);
const url = new URL(webservice.uri);
url.search = new URLSearchParams({
@ -120,31 +116,19 @@ export default async function openWebService(
export class WebServiceValidationError extends Error {}
async function getWebServiceToken(
nso: CoralApi, webservice: WebService,
user: NintendoAccountUser, nsoAccount: CurrentUser,
window: BrowserWindow
coral: CoralApiInterface,
webservice: WebService, qs: string | undefined,
auth_data: CoralAuthData,
window: BrowserWindow,
): Promise<WebServiceToken> {
try {
return await nso.getWebServiceToken(webservice.id);
return await coral.getWebServiceToken(webservice.id);
} catch (err) {
const result = await dialog.showMessageBox(window, {
type: 'error',
message: (err instanceof Error ? err.name : 'Error') + ' requesting web service token',
detail: (err instanceof Error ? err.stack ?? err.message : err) + '\n\n' + util.inspect({
webservice: {
id: webservice.id,
name: webservice.name,
uri: webservice.uri,
},
user_na_id: user.id,
user_nsa_id: nsoAccount.nsaId,
user_coral_id: nsoAccount.id,
}, {compact: true}),
buttons: ['Retry', 'Close ' + webservice.name, 'Ignore'],
});
const result = await handleOpenWebServiceError(err, webservice, qs, auth_data, window,
['Retry', 'Close ' + webservice.name, 'Ignore']);
if (result.response === 0) {
return getWebServiceToken(nso, webservice, user, nsoAccount, window);
return getWebServiceToken(coral, webservice, qs, auth_data, window);
}
if (result.response === 1) {
window.close();
@ -160,13 +144,19 @@ function isWebServiceUrlAllowed(webservice: WebService, url: string | URL) {
if (typeof url === 'string') url = new URL(url);
for (const host of webservice.whiteList) {
for (const allowed of webservice.whiteList) {
const host = allowed.includes('/') ? allowed.substr(0, allowed.indexOf('/')) : allowed;
const path = allowed.includes('/') ? allowed.substr(allowed.indexOf('/')) : null;
if (path && url.pathname !== path && !url.pathname.startsWith(path + '/')) continue;
if (host.startsWith('*.')) {
return url.hostname === host.substr(2) ||
url.hostname.endsWith(host.substr(1));
if (url.hostname === host.substr(2) ||
url.hostname.endsWith(host.substr(1))
) return true;
}
return url.hostname === host;
if (url.hostname === host) return true;
}
return false;
@ -199,6 +189,32 @@ function askUserForWebServiceUri(app: App, uri: string) {
return askUserForUri(app, uri, app.i18n.t('handle_uri:web_service_select'));
}
export async function handleOpenWebServiceError(
err: unknown,
webservice: WebService, qs?: string, auth_data?: CoralAuthData,
window?: BrowserWindow, buttons?: string[],
) {
const data = {
webservice: {
id: webservice.id,
name: webservice.name,
uri: webservice.uri,
},
qs,
user_na_id: auth_data?.user.id,
user_nsa_id: auth_data?.nsoAccount.user.nsaId,
user_coral_id: auth_data?.nsoAccount.user.id,
};
return showErrorDialog({
message: (err instanceof Error ? err.name : 'Error') + ' opening web service',
error: err,
detail: util.inspect(data, {compact: true}),
buttons,
window,
});
}
export interface WebServiceData {
webservice: WebService;
url: string;
@ -225,6 +241,7 @@ export class WebServiceIpc {
store: data[0],
token: data[1],
nso: data[2],
data: data[3],
nintendoAccountToken: data[3].nintendoAccountToken,
user: data[3].user,
nsoAccount: data[3].nsoAccount,
@ -273,16 +290,8 @@ export class WebServiceIpc {
const dir = app.getPath('downloads');
const basename = path.basename(new URL(image_url).pathname);
const extname = path.extname(basename);
let filename;
let i = 0;
do {
i++;
filename = i === 1 ? basename : basename.substr(0, basename.length - extname.length) + ' ' + i + extname;
} while (await this.pathExists(path.join(dir, filename)));
debug('Downloading image %s to %s as %s', image_url, dir, filename);
debug('Downloading image %s to %s', image_url, dir);
const response = await fetch(image_url, {
headers: {
@ -290,6 +299,22 @@ export class WebServiceIpc {
},
});
const image = await response.arrayBuffer();
const type = response.headers.get('Content-Type');
const ext = type ? mimetypes.extension(type) : null;
let filename;
let i = 0;
do {
i++;
filename = basename.substr(0, basename.length - extname.length) +
(i === 1 ? basename : ' ' + i) +
(ext ? '.' + ext : extname);
} while (await this.pathExists(path.join(dir, filename)));
debug('Writing image %s to %s as %s', image_url, dir, filename);
await fs.writeFile(path.join(dir, filename), Buffer.from(image));
return path.join(dir, filename);
@ -321,12 +346,12 @@ export class WebServiceIpc {
}
async requestGameWebToken(event: IpcMainInvokeEvent): Promise<string> {
const {nso, user, nsoAccount, webservice} = this.getWindowData(event.sender);
const {nso, data, nsoAccount, webservice} = this.getWindowData(event.sender);
const window = BrowserWindow.fromWebContents(event.sender)!;
debug('Web service %s, user %s, called requestGameWebToken', webservice.name, nsoAccount.user.name);
const webserviceToken = await getWebServiceToken(nso, webservice, user, nsoAccount.user, window);
const webserviceToken = await getWebServiceToken(nso, webservice, undefined, data, window);
return webserviceToken.accessToken;
}
@ -433,4 +458,10 @@ export class WebServiceIpc {
debug('Web service %s, user %s, called completeLoading', webservice.name, nsoAccount.user.name);
}
async clearUnreadFlag(event: IpcMainInvokeEvent): Promise<void> {
const {nsoAccount, webservice} = this.getWindowData(event.sender);
debug('Web service %s, user %s, called clearUnreadFlag', webservice.name, nsoAccount.user.name);
}
}

View File

@ -1,4 +1,4 @@
import { BrowserWindow, BrowserWindowConstructorOptions, nativeTheme, session, WebContents } from './electron.js';
import { BrowserWindow, BrowserWindowConstructorOptions, nativeTheme, session, WebContents } from 'electron';
import * as path from 'node:path';
import { dev } from '../../util/product.js';
import { WindowConfiguration, WindowType } from '../common/types.js';

View File

@ -1,5 +1,5 @@
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { EventEmitter } from 'events';
import { EventEmitter } from 'node:events';
import createDebug from 'debug';
import { QrCodeReaderOptions, WebServiceData } from '../main/webservices.js';
@ -20,6 +20,7 @@ const ipc = {
copyToClipboard: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:copyToClipboard', data) as Promise<void>,
downloadImages: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:downloadImages', data) as Promise<void>,
completeLoading: () => ipcRenderer.invoke('nxapi:webserviceapi:completeLoading') as Promise<void>,
clearUnreadFlag: () => ipcRenderer.invoke('nxapi:webserviceapi:clearUnreadFlag') as Promise<void>,
};
export default ipc;

View File

@ -5,7 +5,7 @@ const debug = createDebug('app:preload-webservice:quirks:splatnet3');
const SPLATNET3_WEBSERVICE_ID = 4834290508791808;
if (webservice.id === SPLATNET3_WEBSERVICE_ID) {
if (webservice.id === SPLATNET3_WEBSERVICE_ID && location.hostname.endsWith('.av5ja.srv.nintendo.net')) {
const style = window.document.createElement('style');
style.textContent = `
@ -57,3 +57,17 @@ if (webservice.id === SPLATNET3_WEBSERVICE_ID) {
}
});
}
if (webservice.id === SPLATNET3_WEBSERVICE_ID && location.hostname === 'c.nintendo.com' && location.pathname.match(/^\/splatoon3-tournament(\/|$)/i)) {
const style = window.document.createElement('style');
style.textContent = `
[class*=AppHeader_closeWebView] {
display: none;
}
`;
document.addEventListener('DOMContentLoaded', () => {
window.document.head.appendChild(style);
});
}

View File

@ -142,6 +142,16 @@ interface WebServiceJsApi {
* Used by SplatNet 3.
*/
reloadExtension(): void;
/**
* Clears the unread notifications flag.
*/
clearUnreadFlag(): void;
/**
* Opens a URL in the default browser.
*/
openExternalBrowser(url: string): void;
}
//
@ -339,6 +349,16 @@ function reloadExtension() {
debug('reloadExtension called');
}
function clearUnreadFlag() {
debug('clearUnreadFlag called');
ipc.clearUnreadFlag();
}
function openExternalBrowser(url: string) {
debug('openExternalBrowser called', url);
window.open(url);
}
const api: WebServiceJsApi = {
invokeNativeShare,
invokeNativeShareUrl,
@ -356,6 +376,8 @@ const api: WebServiceJsApi = {
completeLoading,
closeWebView,
reloadExtension,
clearUnreadFlag,
openExternalBrowser,
};
window.jsBridge = api;

View File

@ -1,9 +1,8 @@
import { contextBridge, ipcRenderer } from 'electron';
import { EventEmitter } from 'events';
import { contextBridge, ipcRenderer, SharingItem } from 'electron';
import { EventEmitter } from 'node:events';
import createDebug from 'debug';
import type { User } from 'discord-rpc';
import type { SharingItem } from '../main/electron.js';
import type { DiscordPresenceConfiguration, DiscordPresenceSource, LoginItem, LoginItemOptions, WindowConfiguration } from '../common/types.js';
import type { DiscordPresenceConfiguration, DiscordPresenceSource, DiscordStatus, LoginItem, LoginItemOptions, WindowConfiguration } from '../common/types.js';
import type { SavedToken } from '../../common/auth/coral.js';
import type { SavedMoonToken } from '../../common/auth/moon.js';
import type { UpdateCacheData } from '../../common/update.js';
@ -19,8 +18,23 @@ import type { AddFriendProps } from '../browser/add-friend/index.js';
const debug = createDebug('app:preload');
const inv = <T = void>(channel: string, ...args: any[]) =>
ipcRenderer.invoke('nxapi:' + channel, ...args) as Promise<T>;
const inv = async <T = void>(channel: string, ...args: any[]) => {
const data: {
result: T;
} | {
error_type: string;
message: string;
type?: string;
description: string;
data: unknown;
} = await ipcRenderer.invoke('nxapi:' + channel, ...args);
if ('result' in data) return data.result;
// Context isolation removes all other properties of Error objects
throw new Error(data.description.replace(/^Error\: /, ''));
};
const invSync = <T = void>(channel: string, ...args: any[]) =>
ipcRenderer.sendSync('nxapi:' + channel, ...args) as T;
@ -59,6 +73,8 @@ const ipc = {
getDiscordPresenceSource: () => inv<DiscordPresenceSource | null>('discord:source'),
setDiscordPresenceSource: (source: DiscordPresenceSource | null) => inv<void>('discord:setsource', source),
getDiscordPresence: () => inv<DiscordPresence | null>('discord:presence'),
getDiscordStatus: () => inv<DiscordStatus | null>('discord:status'),
showDiscordLastUpdateError: () => inv('discord:showerror'),
getDiscordUser: () => inv<User | null>('discord:user'),
getDiscordUsers: () => inv<User[]>('discord:users'),
@ -95,6 +111,7 @@ ipcRenderer.on('nxapi:accounts:shouldrefresh', () => events.emit('update-nintend
ipcRenderer.on('nxapi:discord:shouldrefresh', () => events.emit('update-discord-presence-source'));
ipcRenderer.on('nxapi:discord:presence', (e, p: DiscordPresence) => events.emit('update-discord-presence', p));
ipcRenderer.on('nxapi:discord:user', (e, u: User) => events.emit('update-discord-user', u));
ipcRenderer.on('nxapi:discord:status', (e, s: DiscordStatus | null) => events.emit('update-discord-status', s));
let language: string | undefined = invSync('app:language');
ipcRenderer.on('nxapi:app:update-language', (event, l: string) => {

View File

@ -1,6 +1,7 @@
import process from 'node:process';
import Yargs from 'yargs';
import * as commands from './cli/index.js';
import { setGlobalDispatcher } from 'undici';
import * as commands from './cli/commands.js';
import { checkUpdates } from './common/update.js';
import createDebug from './util/debug.js';
import { dev } from './util/product.js';
@ -9,11 +10,15 @@ import { YargsArguments } from './util/yargs.js';
import { addUserAgent } from './util/useragent.js';
import { USER_AGENT_INFO_URL } from './common/constants.js';
import { init as initGlobals } from './common/globals.js';
import { buildEnvironmentProxyAgent } from './util/undici-proxy.js';
const debug = createDebug('cli');
initGlobals();
const agent = buildEnvironmentProxyAgent();
setGlobalDispatcher(agent);
export function createYargs(argv: string[]) {
const yargs = Yargs(argv).option('data-path', {
describe: 'Data storage path',

10
src/cli/commands.ts Normal file
View File

@ -0,0 +1,10 @@
export * as users from './users.js';
export * as nso from './nso/index.js';
export * as splatnet2 from './splatnet2/index.js';
export * as nooklink from './nooklink/index.js';
export * as splatnet3 from './splatnet3/index.js';
export * as pctl from './pctl/index.js';
export * as androidZncaApiServerFrida from './android-znca-api-server-frida.js';
export * as presenceServer from './presence-server.js';
export * as util from './util/index.js';
export * as app from './app.js';

View File

@ -1,10 +0,0 @@
export * as users from './users.js';
export * as nso from './nso.js';
export * as splatnet2 from './splatnet2.js';
export * as nooklink from './nooklink.js';
export * as splatnet3 from './splatnet3.js';
export * as pctl from './pctl.js';
export * as androidZncaApiServerFrida from './android-znca-api-server-frida.js';
export * as presenceServer from './presence-server.js';
export * as util from './util.js';
export * as app from './app.js';

View File

@ -1,29 +0,0 @@
import process from 'node:process';
import type { Arguments as ParentArguments } from '../cli.js';
import createDebug from '../util/debug.js';
import { Argv, YargsArguments } from '../util/yargs.js';
import * as commands from './nooklink/index.js';
const debug = createDebug('cli:nooklink');
export const command = 'nooklink <command>';
export const desc = 'NookLink';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
return yargs.option('znc-proxy-url', {
describe: 'URL of Nintendo Switch Online app API proxy server to use',
type: 'string',
default: process.env.ZNC_PROXY_URL,
}).option('auto-update-session', {
describe: 'Automatically obtain and refresh the NookLink game web token and user token',
type: 'boolean',
default: true,
});
}
export type Arguments = YargsArguments<ReturnType<typeof builder>>;

View File

@ -0,0 +1,10 @@
export * as users from './users.js';
export * as user from './user.js';
export * as island from './island.js';
export * as newspapers from './newspapers.js';
export * as newspaper from './newspaper.js';
export * as dumpNewspapers from './dump-newspapers.js';
export * as keyboard from './keyboard.js';
export * as reactions from './reactions.js';
export * as postReaction from './post-reaction.js';
export * as userToken from './user-token.js';

View File

@ -1,7 +1,6 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../nooklink.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
@ -40,7 +39,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const directory = argv.directory ?? path.join(argv.dataPath, 'nooklink');
await mkdirp(directory);
await fs.mkdir(directory, {recursive: true});
const latest = await nooklinkuser.getLatestNewspaper();
const newspapers = await nooklinkuser.getNewspapers();

View File

@ -1,10 +1,29 @@
export * as users from './users.js';
export * as user from './user.js';
export * as island from './island.js';
export * as newspapers from './newspapers.js';
export * as newspaper from './newspaper.js';
export * as dumpNewspapers from './dump-newspapers.js';
export * as keyboard from './keyboard.js';
export * as reactions from './reactions.js';
export * as postReaction from './post-reaction.js';
export * as userToken from './user-token.js';
import process from 'node:process';
import type { Arguments as ParentArguments } from '../../cli.js';
import createDebug from '../../util/debug.js';
import { Argv, YargsArguments } from '../../util/yargs.js';
import * as commands from './commands.js';
const debug = createDebug('cli:nooklink');
export const command = 'nooklink <command>';
export const desc = 'NookLink';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
return yargs.option('znc-proxy-url', {
describe: 'URL of Nintendo Switch Online app API proxy server to use',
type: 'string',
default: process.env.ZNC_PROXY_URL,
}).option('auto-update-session', {
describe: 'Automatically obtain and refresh the NookLink game web token and user token',
type: 'boolean',
default: true,
});
}
export type Arguments = YargsArguments<ReturnType<typeof builder>>;

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nooklink.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,9 +1,9 @@
import type { Arguments as ParentArguments } from '../nooklink.js';
import { read } from 'read';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getUserToken } from '../../common/auth/nooklink.js';
import prompt from '../util/prompt.js';
const debug = createDebug('cli:nooklink:keyboard');
@ -30,7 +30,8 @@ type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
if (!argv.message) {
argv.message = await prompt({
argv.message = await read<string>({
output: process.stderr,
prompt: 'Message: ',
});
}

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nooklink.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nooklink.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,4 +1,4 @@
import type { Arguments as ParentArguments } from '../nooklink.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nooklink.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,4 +1,4 @@
import type { Arguments as ParentArguments } from '../nooklink.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,4 +1,4 @@
import type { Arguments as ParentArguments } from '../nooklink.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nooklink.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,25 +0,0 @@
import process from 'node:process';
import type { Arguments as ParentArguments } from '../cli.js';
import createDebug from '../util/debug.js';
import { Argv, YargsArguments } from '../util/yargs.js';
import * as commands from './nso/index.js';
const debug = createDebug('cli:nso');
export const command = 'nso <command>';
export const desc = 'Nintendo Switch Online';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
return yargs.option('znc-proxy-url', {
describe: 'URL of Nintendo Switch Online app API proxy server to use',
type: 'string',
default: process.env.ZNC_PROXY_URL,
});
}
export type Arguments = YargsArguments<ReturnType<typeof builder>>;

View File

@ -1,4 +1,4 @@
import type { Arguments as ParentArguments } from '../nso.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,4 +1,4 @@
import type { Arguments as ParentArguments } from '../nso.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nso.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,12 +1,10 @@
import * as crypto from 'node:crypto';
import type { Arguments as ParentArguments } from '../nso.js';
import { read } from 'read';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/coral.js';
import { getNintendoAccountSessionToken } from '../../api/na.js';
import { ZNCA_CLIENT_ID } from '../../api/coral.js';
import prompt from '../util/prompt.js';
import { NintendoAccountSessionAuthorisationCoral } from '../../api/coral.js';
const debug = createDebug('cli:nso:auth');
@ -27,39 +25,20 @@ export function builder(yargs: Argv<ParentArguments>) {
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const state = crypto.randomBytes(36).toString('base64url');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest().toString('base64url');
const authenticator = NintendoAccountSessionAuthorisationCoral.create();
const params = {
state,
redirect_uri: 'npf71b963c1b7b6d119://auth',
client_id: ZNCA_CLIENT_ID,
scope: 'openid user user.birthday user.mii user.screenName',
response_type: 'session_token_code',
session_token_code_challenge: challenge,
session_token_code_challenge_method: 'S256',
theme: 'login_form',
};
const authoriseurl = 'https://accounts.nintendo.com/connect/1.0.0/authorize?' +
new URLSearchParams(params).toString();
debug('Authentication parameters', {
state,
verifier,
challenge,
}, params);
debug('Authentication parameters', authenticator);
console.log('1. Open this URL and login to your Nintendo Account:');
console.log('');
console.log(authoriseurl);
console.log(authenticator.authorise_url);
console.log('');
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".');
console.log('');
const applink = await prompt({
const applink = await read<string>({
output: process.stderr,
prompt: `Paste the link: `,
});
@ -69,8 +48,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const authorisedparams = new URLSearchParams(authorisedurl.hash.substr(1));
debug('Redirect URL parameters', [...authorisedparams.entries()]);
const code = authorisedparams.get('session_token_code')!;
const token = await getNintendoAccountSessionToken(code, verifier, ZNCA_CLIENT_ID);
const token = await authenticator.getSessionToken(authorisedparams);
console.log('Session token', token);

16
src/cli/nso/commands.ts Normal file
View File

@ -0,0 +1,16 @@
export * as token from './token.js';
export * as auth from './auth.js';
export * as user from './user.js';
export * as permissions from './permissions.js';
export * as announcements from './announcements.js';
export * as webservices from './webservices.js';
export * as webservicetoken from './webservicetoken.js';
export * as friends from './friends.js';
export * as activeEvent from './active-event.js';
export * as presence from './presence.js';
export * as notify from './notify.js';
export * as httpServer from './http-server.js';
export * as zncProxyTokens from './znc-proxy-tokens.js';
export * as friendcode from './friendcode.js';
export * as lookup from './lookup.js';
export * as addFriend from './add-friend.js';

View File

@ -1,4 +1,4 @@
import type { Arguments as ParentArguments } from '../nso.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,6 +1,6 @@
import Table from '../util/table.js';
import Table from '../../util/table.js';
import { PresenceState } from '../../api/coral-types.js';
import type { Arguments as ParentArguments } from '../nso.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,21 +1,20 @@
import * as net from 'node:net';
import * as os from 'node:os';
import { randomUUID } from 'node:crypto';
import * as persist from 'node-persist';
import express, { Request, RequestHandler, Response } from 'express';
import bodyParser from 'body-parser';
import { v4 as uuidgen } from 'uuid';
import type { Arguments as ParentArguments } from '../nso.js';
import CoralApi from '../../api/coral.js';
import type { Arguments as ParentArguments } from './index.js';
import CoralApi, { CoralApiInterface, CoralErrorResponse } from '../../api/coral.js';
import { Announcement, CoralStatus, CurrentUser, Friend, FriendCodeUrl, FriendCodeUser, Presence } from '../../api/coral-types.js';
import { AuthPolicy, AuthToken, ZncPresenceEventStreamEvent } from '../../api/znc-proxy.js';
import { ErrorResponse } from '../../api/util.js';
import ZncProxyApi, { AuthPolicy, AuthToken, ZncPresenceEventStreamEvent } from '../../api/znc-proxy.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { product } from '../../util/product.js';
import { parseListenAddress } from '../../util/net.js';
import { addCliFeatureUserAgent } from '../../util/useragent.js';
import { EventStreamResponse, HttpServer, ResponseError } from '../util/http-server.js';
import { EventStreamResponse, HttpServer, ResponseError } from '../../util/http-server.js';
import { SavedToken } from '../../common/auth/coral.js';
import { NotificationManager, PresenceEvent, ZncNotifications } from '../../common/notify.js';
import Users, { CoralUser } from '../../common/users.js';
@ -37,12 +36,12 @@ declare global {
interface RequestData {
req: Request;
res: Response;
user?: CoralUser;
user?: CoralUser<CoralApiInterface>;
policy?: AuthPolicy;
token?: string;
}
interface RequestDataWithUser extends RequestData {
user: CoralUser;
user: CoralUser<CoralApiInterface>;
}
const debug = createDebug('cli:nso:http-server');
@ -104,7 +103,7 @@ class Server extends HttpServer {
constructor(
readonly storage: persist.LocalStorage,
readonly users: Users<CoralUser>,
readonly users: Users<CoralUser<CoralApiInterface>>,
) {
super();
@ -265,7 +264,7 @@ class Server extends HttpServer {
}
});
private coral_auth_promise = new Map</** session token */ string, Promise<CoralUser>>();
private coral_auth_promise = new Map</** session token */ string, Promise<CoralUser<CoralApiInterface>>>();
private coral_auth_timeout = new Map</** session token */ string, NodeJS.Timeout>();
async getCoralUser(req: Request) {
@ -320,7 +319,11 @@ class Server extends HttpServer {
}
async handleAuthRequest({user}: RequestDataWithUser) {
return user.data;
if (user.nso instanceof ZncProxyApi) {
return user.nso.fetch('/auth');
} else {
return user.data;
}
}
async handleTokenRequest({policy, token}: RequestData) {
@ -362,7 +365,8 @@ class Server extends HttpServer {
}
async handleCreateTokenRequest({req, user}: RequestDataWithUser) {
const token = uuidgen();
const token = randomUUID();
const auth: AuthToken = {
user: user.data.user.id,
policy: req.body.policy,
@ -404,7 +408,7 @@ class Server extends HttpServer {
private user_data_promise = new Map</** NA ID */ string, Promise<[number, CurrentUser]>>();
private cached_userdata = new Map</** NA ID */ string, [number, CurrentUser]>();
async getUserData(id: string, coral: CoralApi) {
async getUserData(id: string, coral: CoralApiInterface) {
return this._cache(id, () => coral.getCurrentUser(),
this.user_data_promise, this.cached_userdata);
}
@ -670,7 +674,7 @@ class Server extends HttpServer {
private cached_friendcode_data = new Map</** FC ID */ string,
[number, [FriendCodeUser | null, /** NA ID */ string]]>();
async getFriendCodeUser(id: string, coral: CoralApi, friendcode: string) {
async getFriendCodeUser(id: string, coral: CoralApiInterface, friendcode: string) {
if (!FRIEND_CODE.test(friendcode)) {
throw new ResponseError(400, 'invalid_request', 'Invalid friend code');
}
@ -685,7 +689,7 @@ class Server extends HttpServer {
const user = await coral.getUserByFriendCode(friendcode);
return [user, id];
} catch (err) {
if (err instanceof ErrorResponse && err.data?.status === CoralStatus.RESOURCE_NOT_FOUND) {
if (err instanceof CoralErrorResponse && err.status === CoralStatus.RESOURCE_NOT_FOUND) {
// A user with this friend code doesn't exist
// This should be cached
return [null, id];
@ -715,7 +719,7 @@ class Server extends HttpServer {
private user_friendcodeurl_promise = new Map</** NA ID */ string, Promise<[number, FriendCodeUrl]>>();
private cached_friendcodeurl = new Map</** NA ID */ string, [number, FriendCodeUrl]>();
getFriendCodeUrl(id: string, coral: CoralApi) {
getFriendCodeUrl(id: string, coral: CoralApiInterface) {
return this._cache(id, () => coral.getFriendCodeUrl(),
this.user_friendcodeurl_promise, this.cached_friendcodeurl);
}
@ -749,7 +753,7 @@ class Server extends HttpServer {
try {
await i.loop(true);
while (!res.closed) {
while (!res.destroyed) {
await i.loop();
this.resetAuthTimeout(na_session_token, () => user.data.user.id);

View File

@ -1,16 +1,25 @@
export * as token from './token.js';
export * as auth from './auth.js';
export * as user from './user.js';
export * as permissions from './permissions.js';
export * as announcements from './announcements.js';
export * as webservices from './webservices.js';
export * as webservicetoken from './webservicetoken.js';
export * as friends from './friends.js';
export * as activeEvent from './active-event.js';
export * as presence from './presence.js';
export * as notify from './notify.js';
export * as httpServer from './http-server.js';
export * as zncProxyTokens from './znc-proxy-tokens.js';
export * as friendcode from './friendcode.js';
export * as lookup from './lookup.js';
export * as addFriend from './add-friend.js';
import process from 'node:process';
import type { Arguments as ParentArguments } from '../../cli.js';
import createDebug from '../../util/debug.js';
import { Argv, YargsArguments } from '../../util/yargs.js';
import * as commands from './commands.js';
const debug = createDebug('cli:nso');
export const command = 'nso <command>';
export const desc = 'Nintendo Switch Online';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
return yargs.option('znc-proxy-url', {
describe: 'URL of Nintendo Switch Online app API proxy server to use',
type: 'string',
default: process.env.ZNC_PROXY_URL,
});
}
export type Arguments = YargsArguments<ReturnType<typeof builder>>;

View File

@ -1,4 +1,4 @@
import type { Arguments as ParentArguments } from '../nso.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,6 +1,6 @@
import * as path from 'node:path';
import persist from 'node-persist';
import type { Arguments as ParentArguments } from '../nso.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,5 +1,5 @@
import { PresencePermissions } from '../../api/coral-types.js';
import type { Arguments as ParentArguments } from '../nso.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,4 +1,4 @@
import type { Arguments as ParentArguments } from '../nso.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,9 +1,9 @@
import type { Arguments as ParentArguments } from '../nso.js';
import { read } from 'read';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/coral.js';
import prompt from '../util/prompt.js';
const debug = createDebug('cli:nso:token');
@ -26,7 +26,8 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
if (!argv.token) {
argv.token = await prompt({
argv.token = await read<string>({
output: process.stderr,
prompt: `Token: `,
silent: true,
});

View File

@ -1,4 +1,4 @@
import type { Arguments as ParentArguments } from '../nso.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nso.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,8 +1,9 @@
import type { Arguments as ParentArguments } from '../nso.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken, Login } from '../../common/auth/coral.js';
import { checkMembershipActive } from '../../common/auth/util.js';
const debug = createDebug('cli:nso:webservicetoken');
@ -54,12 +55,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
}
const verifymembership = webservice.customAttributes.find(a => a.attrKey === 'verifyMembership');
if (verifymembership?.attrValue === 'true') {
const membership = data.nsoAccount.user.links.nintendoAccount.membership;
const active = typeof membership.active === 'object' ? membership.active.active : membership.active;
if (!active) throw new Error('Nintendo Switch Online membership required');
}
if (verifymembership?.attrValue === 'true') checkMembershipActive(data);
const webserviceToken = await nso.getWebServiceToken(webservice.id);

View File

@ -1,12 +1,13 @@
import fetch from 'node-fetch';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nso.js';
import { fetch } from 'undici';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import { getToken } from '../../common/auth/coral.js';
import { AuthPolicy, AuthToken } from '../../api/znc-proxy.js';
import createDebug from '../../util/debug.js';
import { Argv } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getUserAgent } from '../../util/useragent.js';
import { ErrorResponse } from '../../api/util.js';
const debug = createDebug('cli:nso:znc-proxy-tokens');
@ -164,7 +165,7 @@ export function builder(yargs: Argv<ParentArguments>) {
debug('fetch %s %s, response %d', 'DELETE', '/token', response.status);
if (response.status !== 204) {
throw new Error('Unknown error ' + response.status);
throw await ErrorResponse.fromResponse(response, 'Non-204 status code');
}
console.warn('Deleted access token');

View File

@ -1,20 +0,0 @@
import type { Arguments as ParentArguments } from '../cli.js';
import createDebug from '../util/debug.js';
import { Argv, YargsArguments } from '../util/yargs.js';
import * as commands from './pctl/index.js';
const debug = createDebug('cli:pctl');
export const command = 'pctl <command>';
export const desc = 'Nintendo Switch Parental Controls';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
return yargs;
}
export type Arguments = YargsArguments<ReturnType<typeof builder>>;

View File

@ -1,12 +1,10 @@
import * as crypto from 'node:crypto';
import type { Arguments as ParentArguments } from '../pctl.js';
import { read } from 'read';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getPctlToken } from '../../common/auth/moon.js';
import { getNintendoAccountSessionToken } from '../../api/na.js';
import { ZNMA_CLIENT_ID } from '../../api/moon.js';
import prompt from '../util/prompt.js';
import { NintendoAccountSessionAuthorisationMoon } from '../../api/moon.js';
const debug = createDebug('cli:pctl:auth');
@ -27,52 +25,20 @@ export function builder(yargs: Argv<ParentArguments>) {
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const state = crypto.randomBytes(36).toString('base64url');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest().toString('base64url');
const authenticator = NintendoAccountSessionAuthorisationMoon.create();
const params = {
state,
redirect_uri: 'npf54789befb391a838://auth',
client_id: ZNMA_CLIENT_ID,
scope: [
'openid',
'user',
'user.mii',
'moonUser:administration',
'moonDevice:create',
'moonOwnedDevice:administration',
'moonParentalControlSetting',
'moonParentalControlSetting:update',
'moonParentalControlSettingState',
'moonPairingState',
'moonSmartDevice:administration',
'moonDailySummary',
'moonMonthlySummary',
].join(' '),
response_type: 'session_token_code',
session_token_code_challenge: challenge,
session_token_code_challenge_method: 'S256',
};
const authoriseurl = 'https://accounts.nintendo.com/connect/1.0.0/authorize?' +
new URLSearchParams(params).toString();
debug('Authentication parameters', {
state,
verifier,
challenge,
}, params);
debug('Authentication parameters', authenticator);
console.log('1. Open this URL and login to your Nintendo Account:');
console.log('');
console.log(authoriseurl);
console.log(authenticator.authorise_url);
console.log('');
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".');
console.log('');
const applink = await prompt({
const applink = await read<string>({
output: process.stderr,
prompt: `Paste the link: `,
});
@ -82,8 +48,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const authorisedparams = new URLSearchParams(authorisedurl.hash.substr(1));
debug('Redirect URL parameters', [...authorisedparams.entries()]);
const code = authorisedparams.get('session_token_code')!;
const token = await getNintendoAccountSessionToken(code, verifier, ZNMA_CLIENT_ID);
const token = await authenticator.getSessionToken(authorisedparams);
console.log('Session token', token);

8
src/cli/pctl/commands.ts Normal file
View File

@ -0,0 +1,8 @@
export * as token from './token.js';
export * as auth from './auth.js';
export * as devices from './devices.js';
export * as dailySummaries from './daily-summaries.js';
export * as monthlySummaries from './monthly-summaries.js';
export * as monthlySummary from './monthly-summary.js';
export * as settings from './settings.js';
export * as dumpSummaries from './dump-summaries.js';

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../pctl.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../pctl.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,7 +1,6 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../pctl.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
@ -42,7 +41,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const directory = argv.directory ?? path.join(argv.dataPath, 'summaries');
await mkdirp(directory);
await fs.mkdir(directory, {recursive: true});
const devices = await moon.getDevices();
@ -110,7 +109,7 @@ async function dumpDailySummariesForDevice(moon: MoonApi, directory: string, dev
for (const summary of summaries.items) {
const filename = 'pctl-daily-' + summary.deviceId + '-' + summary.date +
(summary.result === DailySummaryResult.ACHIEVED ? '' : '-' + timestamp) + '.json';
(summary.result === DailySummaryResult.CALCULATING ? '-' + timestamp : '') + '.json';
const file = path.join(directory, filename);
try {

View File

@ -1,8 +1,20 @@
export * as token from './token.js';
export * as auth from './auth.js';
export * as devices from './devices.js';
export * as dailySummaries from './daily-summaries.js';
export * as monthlySummaries from './monthly-summaries.js';
export * as monthlySummary from './monthly-summary.js';
export * as settings from './settings.js';
export * as dumpSummaries from './dump-summaries.js';
import type { Arguments as ParentArguments } from '../../cli.js';
import createDebug from '../../util/debug.js';
import { Argv, YargsArguments } from '../../util/yargs.js';
import * as commands from './commands.js';
const debug = createDebug('cli:pctl');
export const command = 'pctl <command>';
export const desc = 'Nintendo Switch Parental Controls';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
return yargs;
}
export type Arguments = YargsArguments<ReturnType<typeof builder>>;

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../pctl.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../pctl.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,4 +1,4 @@
import type { Arguments as ParentArguments } from '../pctl.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,9 +1,9 @@
import type { Arguments as ParentArguments } from '../pctl.js';
import { read } from 'read';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getPctlToken } from '../../common/auth/moon.js';
import prompt from '../util/prompt.js';
const debug = createDebug('cli:pctl:token');
@ -26,7 +26,8 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
if (!argv.token) {
argv.token = await prompt({
argv.token = await read<string>({
output: process.stderr,
prompt: `Token: `,
silent: true,
});

View File

@ -1,4 +1,4 @@
import type { Arguments as ParentArguments } from '../pctl.js';
import type { Arguments as ParentArguments } from './index.js';
import { getPctlToken } from '../../common/auth/moon.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';

View File

@ -2,14 +2,14 @@ import * as net from 'node:net';
import * as os from 'node:os';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import express, { Request, Response } from 'express';
import fetch from 'node-fetch';
import { fetch } from 'undici';
import * as persist from 'node-persist';
import mkdirp from 'mkdirp';
import { BankaraMatchMode, BankaraMatchSetting_schedule, CoopRule, CoopSetting_schedule, DetailFestRecordDetailResult, DetailVotingStatusResult, FestMatchSetting_schedule, FestRecordResult, FestState, FestTeam_schedule, FestTeam_votingStatus, FestVoteState, Fest_schedule, FriendListResult, FriendOnlineState, Friend_friendList, GraphQLSuccessResponse, KnownRequestId, LeagueMatchSetting_schedule, RegularMatchSetting_schedule, StageScheduleResult, VsMode, XMatchSetting_schedule } from 'splatnet3-types/splatnet3';
import StageScheduleQuery_730cd98 from 'splatnet3-types/graphql/730cd98e84f1030d3e9ac86b6f1aae13';
import mimetypes from 'mime-types';
import { BankaraMatchSetting_schedule, CoopRule, CoopSetting_schedule, DetailFestRecordDetailResult, DetailVotingStatusResult, FestMatchSetting_schedule, FestRecordResult, FestState, FestTeam_schedule, FestTeam_votingStatus, FestVoteState, Fest_schedule, FriendListResult, FriendOnlineState, Friend_friendList, GraphQLSuccessResponse, KnownRequestId, LeagueMatchSetting_schedule, RegularMatchSetting_schedule, StageScheduleResult, XMatchSetting_schedule } from 'splatnet3-types/splatnet3';
import type { Arguments as ParentArguments } from '../cli.js';
import { product, version } from '../util/product.js';
import { git, product, version } from '../util/product.js';
import Users, { CoralUser } from '../common/users.js';
import { Friend } from '../api/coral-types.js';
import SplatNet3Api, { PersistedQueryResult, RequestIdSymbol } from '../api/splatnet3.js';
@ -19,19 +19,30 @@ import createDebug from '../util/debug.js';
import { initStorage } from '../util/storage.js';
import { addCliFeatureUserAgent, getUserAgent } from '../util/useragent.js';
import { parseListenAddress } from '../util/net.js';
import { EventStreamResponse, HttpServer, ResponseError } from './util/http-server.js';
import { EventStreamResponse, HttpServer, ResponseError } from '../util/http-server.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
import { getTitleIdFromEcUrl } from '../util/misc.js';
import { getSettingForCoopRule, getSettingForVsMode } from '../discord/monitor/splatoon3.js';
import { CoralApiInterface } from '../api/coral.js';
import { PresenceEmbedFormat, getUserEmbedOptionsFromRequest, renderUserEmbedImage, renderUserEmbedSvg } from '../common/presence-embed.js';
const debug = createDebug('cli:presence-server');
const debugSplatnet3Proxy = createDebug('cli:presence-server:splatnet3-proxy');
enum PresenceScope {
PRESENCE = 'presence',
PRESENCE_TIMESTAMPS = 'presence_timestamps',
PRESENCE_TITLE = 'title',
SPLATOON3_PRESENCE = 'splatoon3',
SPLATOON3_FEST_TEAM = 'splatoon3_fest_team',
}
interface AllUsersResult extends Friend {
title: TitleResult | null;
splatoon3?: Friend_friendList | null;
splatoon3_fest_team?: (FestTeam_schedule & FestTeam_votingStatus) | null;
}
interface PresenceResponse {
export interface PresenceResponse {
friend: Friend;
title: TitleResult | null;
splatoon3?: Friend_friendList | null;
@ -227,6 +238,7 @@ abstract class SplatNet3User {
fest_vote_status: GraphQLSuccessResponse<DetailVotingStatusResult> | null = null;
promise = new Map<string, Promise<void>>();
delay_retry_after_error_until: number | null = null;
updated = {
friends: Date.now(),
@ -235,6 +247,8 @@ abstract class SplatNet3User {
current_fest: null as number | null,
fest_vote_status: null as number | null,
};
delay_retry_after_error = 5 * 1000; // 5 seconds
update_interval = 10 * 1000; // 10 seconds
update_interval_schedules = 60 * 60 * 1000; // 60 minutes
update_interval_fest_voting_status: number | null = null; // 10 seconds
@ -245,10 +259,16 @@ abstract class SplatNet3User {
protected async update(key: keyof SplatNet3User['updated'], callback: () => Promise<void>, ttl: number) {
if (((this.updated[key] ?? 0) + ttl) < Date.now()) {
const promise = this.promise.get(key) ?? callback.call(null).then(() => {
const promise = this.promise.get(key) ?? Promise.resolve().then(() => {
const delay_retry = (this.delay_retry_after_error_until ?? 0) - Date.now();
return delay_retry > 0 ? new Promise(rs => setTimeout(rs, delay_retry)) : null;
}).then(() => callback.call(null)).then(() => {
this.updated[key] = Date.now();
this.delay_retry_after_error_until = null;
this.promise.delete(key);
}).catch(err => {
this.delay_retry_after_error_until = Date.now() + this.delay_retry_after_error;
this.promise.delete(key);
throw err;
});
@ -424,7 +444,7 @@ class SplatNet3ApiUser extends SplatNet3User {
fest: await this.getCurrentFest(),
};
await mkdirp(path.join(this.record_fest_votes!.path, 'splatnet3-fest-votes-' + id));
await fs.mkdir(path.join(this.record_fest_votes!.path, 'splatnet3-fest-votes-' + id), {recursive: true});
await fs.writeFile(path.join(this.record_fest_votes!.path, 'splatnet3-fest-votes-' + id, Date.now() + '.json'), JSON.stringify(record, null, 4) + '\n');
}
@ -520,11 +540,12 @@ class Server extends HttpServer {
app: express.Express;
titles = new Map</** NSA ID */ string, [TitleResult | null, /** updated */ number]>();
readonly promise_image = new Map<string, Promise<string>>();
readonly promise_image = new Map<string, Promise<string |
readonly [name: string, data: Uint8Array, type: string]>>();
constructor(
readonly storage: persist.LocalStorage,
readonly coral_users: Users<CoralUser>,
readonly coral_users: Users<CoralUser<CoralApiInterface>>,
readonly splatnet3_users: Users<SplatNet3User> | null,
readonly user_ids: string[],
image_proxy_path?: {baas?: string; atum?: string; splatnet3?: string;},
@ -556,6 +577,20 @@ class Server extends HttpServer {
app.get('/api/presence/:user/events', this.createApiRequestHandler((req, res) =>
this.handlePresenceStreamRequest(req, res, req.params.user)));
app.get('/api/presence/:user/image', this.createApiRequestHandler((req, res) =>
this.handleUserImageRequest(req, res, req.params.user)));
app.get('/api/presence/:user/title/redirect', this.createApiRequestHandler((req, res) =>
this.handlePresenceTitleRedirectRequest(req, res, req.params.user)));
app.get('/api/presence/:user/embed', this.createApiRequestHandler((req, res) =>
this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.SVG)));
app.get('/api/presence/:user/embed.png', this.createApiRequestHandler((req, res) =>
this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.PNG)));
app.get('/api/presence/:user/embed.jpeg', this.createApiRequestHandler((req, res) =>
this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.JPEG)));
app.get('/api/presence/:user/embed.webp', this.createApiRequestHandler((req, res) =>
this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.WEBP)));
if (image_proxy_path?.baas) {
this.image_proxy_path_baas = image_proxy_path.baas;
app.use('/api/presence/resources/baas', express.static(this.image_proxy_path_baas, {redirect: false}));
@ -631,9 +666,20 @@ class Server extends HttpServer {
return user;
}
getAccessScopeFromHeaders(req: Request) {
const headers = typeof req.headers['x-nxapi-auth-presence-scope'] === 'string' ?
[req.headers['x-nxapi-auth-presence-scope']] :
req.headers['x-nxapi-auth-presence-scope'] ?? [];
if (!headers.length) return null;
return headers.map(s => s.split(' ') as PresenceScope[])
.reduce((a, b) => a.filter(s => b.includes(s)));
}
async handleAllUsersRequest(req: Request, res: Response) {
if (!this.allow_all_users) {
throw new ResponseError(403, 'forbidden');
throw new ResponseError(403, 'unauthorised');
}
const include_splatnet3 = this.splatnet3_users && req.query['include-splatoon3'] === '1';
@ -695,7 +741,7 @@ class Server extends HttpServer {
}
if (match.splatoon3_fest_team) break;
for (const player of team.preVotes.nodes) {
if (player.userIcon.url !== friend.userIcon.url) continue;
@ -705,7 +751,7 @@ class Server extends HttpServer {
};
break;
}
if (match.splatoon3_fest_team) break;
}
@ -724,7 +770,10 @@ class Server extends HttpServer {
return {result, [ResourceUrlMapSymbol]: images};
}
async handlePresenceRequest(req: Request, res: Response | null, presence_user_nsaid: string, is_stream = false) {
async handlePresenceRequest(
req: Request, res: Response | null, presence_user_nsaid: string,
is_stream = false, scope = this.getAccessScopeFromHeaders(req),
) {
if (res && !is_stream) {
const req_url = new URL(req.url, 'http://localhost');
const stream_url = new URL('/api/presence/' + encodeURIComponent(presence_user_nsaid) + '/events', req_url);
@ -734,9 +783,13 @@ class Server extends HttpServer {
res?.setHeader('Access-Control-Allow-Origin', '*');
if (scope && !scope.includes(PresenceScope.PRESENCE)) {
throw new ResponseError(403, 'unauthorised', 'Missing required scope presence');
}
const include_splatnet3 = this.splatnet3_users && req.query['include-splatoon3'] === '1';
let match: [CoralUser, Friend, string] | null = null;
let match: [CoralUser<CoralApiInterface>, Friend, string] | null = null;
for (const user_naid of this.user_ids) {
const token = await this.storage.getItem('NintendoAccountToken.' + user_naid);
@ -770,10 +823,41 @@ class Server extends HttpServer {
title,
};
if (this.splatnet3_users && include_splatnet3) {
if (scope && !scope.includes(PresenceScope.PRESENCE_TIMESTAMPS)) {
response.friend = {
...response.friend,
presence: {
...response.friend.presence,
game: 'name' in response.friend.presence.game ? {
...response.friend.presence.game,
firstPlayedAt: 0,
totalPlayTime: 0,
} : {},
logoutAt: 0,
updatedAt: 0,
},
};
}
if (scope && !scope.includes(PresenceScope.PRESENCE_TITLE)) {
response.friend = {
...response.friend,
presence: {
...response.friend.presence,
game: {},
},
};
response.title = null;
}
if (this.splatnet3_users && include_splatnet3 && (!scope ||
scope.includes(PresenceScope.SPLATOON3_PRESENCE) ||
scope.includes(PresenceScope.SPLATOON3_FEST_TEAM)
)) {
const user = await this.getSplatNet3User(user_naid);
await this.handleSplatoon3Presence(friend, user, response);
await this.handleSplatoon3Presence(friend, user, response, scope);
}
const images = await this.downloadImages(response, this.getResourceBaseUrls(req));
@ -802,7 +886,10 @@ class Server extends HttpServer {
return title;
}
async handleSplatoon3Presence(coral_friend: Friend, user: SplatNet3User, response: PresenceResponse) {
async handleSplatoon3Presence(
coral_friend: Friend, user: SplatNet3User, response: PresenceResponse,
scope: PresenceScope[] | null,
) {
const is_playing_splatoon3 = 'name' in coral_friend.presence.game ?
getTitleIdFromEcUrl(coral_friend.presence.game.shopUri) === '0100c2500fc20000' : false;
@ -821,9 +908,11 @@ class Server extends HttpServer {
if (!friend) return;
response.splatoon3 = friend;
if (!scope || scope.includes(PresenceScope.SPLATOON3_PRESENCE)) {
response.splatoon3 = friend;
}
if (fest_vote_status) {
if (fest_vote_status && (!scope || scope.includes(PresenceScope.SPLATOON3_FEST_TEAM))) {
const fest = await user.getCurrentFest();
const fest_team = this.getFestTeamVotingStatus(fest_vote_status, fest, friend);
@ -835,11 +924,27 @@ class Server extends HttpServer {
}
}
if (scope && !scope.includes(PresenceScope.PRESENCE_TITLE)) {
// Remove all information that could show if the user is playing Splatoon 3
response.splatoon3 = {
...friend,
playerName: null,
isLocked: null,
isVcEnabled: null,
vsMode: null,
coopRule: null,
onlineState: friend.onlineState === FriendOnlineState.OFFLINE ?
FriendOnlineState.OFFLINE : FriendOnlineState.ONLINE,
};
return;
}
if ((friend.onlineState === FriendOnlineState.VS_MODE_MATCHING ||
friend.onlineState === FriendOnlineState.VS_MODE_FIGHTING) && friend.vsMode
) {
const schedules = await user.getSchedules();
const vs_setting = this.getSettingForVsMode(schedules, friend.vsMode);
const vs_setting = getSettingForVsMode(schedules, friend.vsMode);
const vs_stages = vs_setting?.vsStages.map(stage => ({
...stage,
image: schedules.vsStages.nodes.find(s => s.id === stage.id)?.originalImage ?? stage.image,
@ -858,11 +963,7 @@ class Server extends HttpServer {
friend.onlineState === FriendOnlineState.COOP_MODE_FIGHTING
) {
const schedules = await user.getSchedules();
const coop_schedules =
friend.coopRule === CoopRule.BIG_RUN ? schedules.coopGroupingSchedule.bigRunSchedules :
friend.coopRule === CoopRule.TEAM_CONTEST ? schedules.coopGroupingSchedule.teamContestSchedules :
schedules.coopGroupingSchedule.regularSchedules;
const coop_setting = getSchedule(coop_schedules)?.setting;
const coop_setting = getSettingForCoopRule(schedules.coopGroupingSchedule, friend.coopRule as CoopRule);
response.splatoon3_coop_setting = coop_setting ?? null;
}
@ -899,31 +1000,6 @@ class Server extends HttpServer {
return null;
}
getSettingForVsMode(schedules: StageScheduleResult, vs_mode: Pick<VsMode, 'id' | 'mode'>) {
if (vs_mode.mode === 'REGULAR') {
return getSchedule(schedules.regularSchedules)?.regularMatchSetting;
}
if (vs_mode.mode === 'BANKARA') {
const settings = getSchedule(schedules.bankaraSchedules)?.bankaraMatchSettings;
if (vs_mode.id === 'VnNNb2RlLTI=') {
return settings?.find(s => s.mode === BankaraMatchMode.CHALLENGE);
}
if (vs_mode.id === 'VnNNb2RlLTUx') {
return settings?.find(s => s.mode === BankaraMatchMode.OPEN);
}
}
if (vs_mode.mode === 'FEST') {
return getSchedule(schedules.festSchedules)?.festMatchSetting;
}
if (vs_mode.mode === 'LEAGUE' && 'leagueSchedules' in schedules) {
return getSchedule((schedules as StageScheduleQuery_730cd98).leagueSchedules)?.leagueMatchSetting;
}
if (vs_mode.mode === 'X_MATCH') {
return getSchedule(schedules.xSchedules)?.xMatchSetting;
}
return null;
}
async handleUserFestVotingStatusHistoryRequest(req: Request, res: Response, presence_user_nsaid: string) {
if (!this.record_fest_votes?.read) {
throw new ResponseError(404, 'not_found', 'Not recording fest voting status history');
@ -1039,7 +1115,8 @@ class Server extends HttpServer {
res.setHeader('Access-Control-Allow-Origin', '*');
const result = await this.handlePresenceRequest(req, null, presence_user_nsaid, true);
const scope = this.getAccessScopeFromHeaders(req);
const result = await this.handlePresenceRequest(req, null, presence_user_nsaid, true, scope);
const stream = new EventStreamResponse(req, res);
stream.json_replacer = replacer;
@ -1072,10 +1149,10 @@ class Server extends HttpServer {
let last_result = result;
while (!req.socket.closed) {
while (!req.socket.destroyed) {
try {
debug('Updating data for event stream %d', stream.id);
const result = await this.handlePresenceRequest(req, null, presence_user_nsaid, true);
const result = await this.handlePresenceRequest(req, null, presence_user_nsaid, true, scope);
stream.sendEvent('update', 'debug: timestamp ' + new Date().toISOString());
@ -1117,6 +1194,112 @@ class Server extends HttpServer {
}
}
async handleUserImageRequest(req: Request, res: Response, presence_user_nsaid: string) {
res.setHeader('Access-Control-Allow-Origin', '*');
const result = await this.handlePresenceRequest(req, null, presence_user_nsaid);
const url_map = await this.downloadImages({
url: result.friend.imageUri,
}, this.getResourceBaseUrls(req));
const image_url = url_map[result.friend.imageUri];
res.statusCode = 303;
res.setHeader('Location', image_url);
res.setHeader('Content-Type', 'text/plain');
res.write('Redirecting to ' + image_url + '\n');
res.end();
}
async handlePresenceTitleRedirectRequest(req: Request, res: Response, presence_user_nsaid: string) {
const result = await this.handlePresenceRequest(req, null, presence_user_nsaid);
let redirect_url = result.title?.url;
if (!redirect_url) {
const req_url = new URL(req.url, 'http://localhost');
const fallback_url = req_url.searchParams.get('fallback-url');
const friend_code = req_url.searchParams.get('friend-code');
const friend_code_hash = req_url.searchParams.get('friend-code-hash');
if (friend_code || friend_code_hash) {
if (!friend_code?.match(/^\d{4}-\d{4}-\d{4}$/)) {
throw new ResponseError(400, 'invalid_request', 'Invalid friend code');
}
if (!friend_code_hash?.match(/^[0-9a-z]{10}$/i)) {
throw new ResponseError(400, 'invalid_request', 'Invalid friend code hash');
}
redirect_url = 'https://lounge.nintendo.com/friendcode/' + friend_code + '/' + friend_code_hash;
} else if (fallback_url) {
try {
const fallback_url_parsed = new URL(fallback_url);
if (fallback_url_parsed.protocol !== 'https:') {
throw new ResponseError(400, 'invalid_request', 'Unacceptable fallback URL protocol');
}
redirect_url = fallback_url;
} catch (err) {
if (err instanceof TypeError) {
throw new ResponseError(400, 'invalid_request', 'Invalid fallback URL');
}
throw err;
}
} else if (req_url.searchParams.get('fallback-prevent-navigation') === '1') {
res.statusCode = 204;
res.end();
return;
} else {
throw new ResponseError(404, 'not_found', 'No active title');
}
}
res.statusCode = 303;
res.setHeader('Location', redirect_url);
res.setHeader('Content-Type', 'text/plain');
res.write('Redirecting to ' + redirect_url + '\n');
res.end();
}
async handlePresenceEmbedRequest(req: Request, res: Response, presence_user_nsaid: string, format = PresenceEmbedFormat.SVG) {
res.setHeader('Access-Control-Allow-Origin', '*');
const result = await this.handlePresenceRequest(req, null, presence_user_nsaid);
const {theme, friend_code, transparent, width, scale: req_scale, options} = getUserEmbedOptionsFromRequest(req);
const scale = format === PresenceEmbedFormat.SVG ? 1 : req_scale;
const etag = createHash('sha256').update(JSON.stringify({
result,
theme,
friend_code,
transparent,
width,
scale,
options,
v: version + '-' + git?.revision,
})).digest('base64url');
if (req.headers['if-none-match'] === '"' + etag + '"' || req.headers['if-none-match'] === 'W/"' + etag + '"') {
res.statusCode = 304;
res.end();
return;
}
const url_map = await this.getImages(result, this.getResourceBaseUrls(req));
const svg = renderUserEmbedSvg(result, url_map, theme, friend_code, options, scale, transparent, width);
const [image, type] = await renderUserEmbedImage(svg, format);
res.setHeader('Content-Type', type);
res.setHeader('Cache-Control', 'public, no-cache'); // no-cache means store but revalidate
res.setHeader('Etag', '"' + etag + '"');
res.end(image);
}
async handleSplatNet3ProxyFriends(req: Request, res: Response) {
if (!this.enable_splatnet3_proxy) throw new ResponseError(403, 'forbidden');
@ -1174,6 +1357,37 @@ class Server extends HttpServer {
atum: string | null;
splatnet3: string | null;
}): Promise<Record<string, string>> {
const image_urls = this.getImageUrls(data, base_url);
const url_map: Record<string, string> = {};
await Promise.all(image_urls.map(async ([url, dir, base_url]) => {
url_map[url] = new URL(await this.downloadImage(url, dir), base_url).toString();
}));
return url_map;
}
async getImages(data: unknown, base_url: {
baas: string | null;
atum: string | null;
splatnet3: string | null;
}): Promise<Record<string, readonly [name: string, data: Uint8Array, type: string]>> {
const image_urls = this.getImageUrls(data, base_url);
const url_map: Record<string, readonly [name: string, data: Uint8Array, type: string]> = {};
await Promise.all(image_urls.map(async ([url, dir, base_url]) => {
const [name, data, type] = await this.downloadImage(url, dir, true);
url_map[url] = [new URL(name, base_url).toString(), data, type];
}));
return url_map;
}
getImageUrls(data: unknown, base_url: {
baas: string | null;
atum: string | null;
splatnet3: string | null;
}) {
const image_urls: [url: string, dir: string, base_url: string][] = [];
// Use JSON.stringify to iterate over everything in the response
@ -1205,13 +1419,7 @@ class Server extends HttpServer {
return value;
});
const url_map: Record<string, string> = {};
await Promise.all(image_urls.map(async ([url, dir, base_url]) => {
url_map[url] = new URL(await this.downloadImage(url, dir), base_url).toString();
}));
return url_map;
return image_urls;
}
getResourceBaseUrls(req: Request) {
@ -1226,15 +1434,25 @@ class Server extends HttpServer {
};
}
downloadImage(url: string, dir: string) {
downloadImage(url: string, dir: string, return_image_data: true): Promise<readonly [name: string, data: Uint8Array, type: string]>
downloadImage(url: string, dir: string, return_image_data?: false): Promise<string>
downloadImage(url: string, dir: string, return_image_data?: boolean): Promise<string | readonly [name: string, data: Uint8Array, type: string]>
downloadImage(url: string, dir: string, return_image_data?: boolean) {
const pathname = new URL(url).pathname;
const name = pathname.substr(1).toLowerCase()
.replace(/^resources\//g, '')
.replace(/(\/|^)\.\.(\/|$)/g, '$1...$2') +
(path.extname(pathname) ? '' : '.jpeg');
const type = (mimetypes.lookup(path.extname(pathname) || '.jpeg') || 'image/jpeg').split(';')[0];
const promise = this.promise_image.get(dir + '/' + name) ?? Promise.resolve().then(async () => {
try {
if (return_image_data) {
const data = await fs.readFile(path.join(dir, name));
return [name, data, type] as const;
}
await fs.stat(path.join(dir, name));
return name;
} catch (err) {}
@ -1245,11 +1463,15 @@ class Server extends HttpServer {
if (!response.ok) throw new ErrorResponse('Unable to download resource ' + name, response, data.toString());
await mkdirp(path.dirname(path.join(dir, name)));
await fs.mkdir(path.dirname(path.join(dir, name)), {recursive: true});
await fs.writeFile(path.join(dir, name), data);
debug('Downloaded image %s', name);
if (return_image_data) {
return [name, data, type] as const;
}
return name;
}).then(result => {
this.promise_image.delete(dir + '/' + name);
@ -1317,27 +1539,3 @@ function replacer(key: string, value: any, data: unknown) {
return value;
}
function getSplatoon3inkUrl(image_url: string) {
const url = new URL(image_url);
if (!url.hostname.endsWith('.nintendo.net')) return image_url;
const path = url.pathname.replace(/^\/resources\/prod\//, '/');
return 'https://splatoon3.ink/assets/splatnet' + path;
}
function getSchedule<T extends {startTime: string; endTime: string;}>(schedules: T[] | {nodes: T[]}): T | null {
if ('nodes' in schedules) schedules = schedules.nodes;
const now = Date.now();
for (const schedule of schedules) {
const start = new Date(schedule.startTime);
const end = new Date(schedule.endTime);
if (start.getTime() >= now) continue;
if (end.getTime() < now) continue;
return schedule;
}
return null;
}

View File

@ -1,30 +0,0 @@
import process from 'node:process';
import type { Arguments as ParentArguments } from '../cli.js';
import createDebug from '../util/debug.js';
import { Argv, YargsArguments } from '../util/yargs.js';
import * as commands from './splatnet2/index.js';
const debug = createDebug('cli:splatnet2');
export const command = 'splatnet2 <command>';
export const desc = 'SplatNet 2';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
return yargs.option('znc-proxy-url', {
describe: 'URL of Nintendo Switch Online app API proxy server to use',
type: 'string',
default: process.env.ZNC_PROXY_URL,
}).option('auto-update-session', {
alias: ['auto-update-iksm-session'],
describe: 'Automatically obtain and refresh the iksm_session cookie',
type: 'boolean',
default: true,
});
}
export type Arguments = YargsArguments<ReturnType<typeof builder>>;

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../splatnet2.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../splatnet2.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -0,0 +1,12 @@
export * as user from './user.js';
export * as token from './token.js';
export * as stages from './stages.js';
export * as challenges from './challenges.js';
export * as weapons from './weapons.js';
export * as hero from './hero.js';
export * as battles from './battles.js';
export * as schedule from './schedule.js';
export * as dumpResults from './dump-results.js';
export * as dumpRecords from './dump-records.js';
export * as monitor from './monitor.js';
export * as xRankSeasons from './x-rank-seasons.js';

View File

@ -1,7 +1,6 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../splatnet2.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
@ -68,7 +67,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const directory = argv.directory ?? path.join(argv.dataPath, 'splatnet2');
await mkdirp(directory);
await fs.mkdir(directory, {recursive: true});
const [records, stages, activefestivals, timeline] = await Promise.all([
splatnet.getRecords(),

View File

@ -1,6 +1,6 @@
import * as path from 'node:path';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../splatnet2.js';
import * as fs from 'node:fs/promises';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
@ -57,7 +57,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const directory = argv.directory ?? path.join(argv.dataPath, 'splatnet2');
await mkdirp(directory);
await fs.mkdir(directory, {recursive: true});
const updated = argv.checkUpdated ? new Date((await splatnet.getRecords()).records.update_time * 1000) : undefined;

View File

@ -1,5 +1,5 @@
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../splatnet2.js';
import Table from '../../util/table.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';

View File

@ -1,12 +1,30 @@
export * as user from './user.js';
export * as token from './token.js';
export * as stages from './stages.js';
export * as challenges from './challenges.js';
export * as weapons from './weapons.js';
export * as hero from './hero.js';
export * as battles from './battles.js';
export * as schedule from './schedule.js';
export * as dumpResults from './dump-results.js';
export * as dumpRecords from './dump-records.js';
export * as monitor from './monitor.js';
export * as xRankSeasons from './x-rank-seasons.js';
import process from 'node:process';
import type { Arguments as ParentArguments } from '../../cli.js';
import createDebug from '../../util/debug.js';
import { Argv, YargsArguments } from '../../util/yargs.js';
import * as commands from './commands.js';
const debug = createDebug('cli:splatnet2');
export const command = 'splatnet2 <command>';
export const desc = 'SplatNet 2';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
return yargs.option('znc-proxy-url', {
describe: 'URL of Nintendo Switch Online app API proxy server to use',
type: 'string',
default: process.env.ZNC_PROXY_URL,
}).option('auto-update-session', {
alias: ['auto-update-iksm-session'],
describe: 'Automatically obtain and refresh the iksm_session cookie',
type: 'boolean',
default: true,
});
}
export type Arguments = YargsArguments<ReturnType<typeof builder>>;

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