Merge branch 'i18n' into pull/63

# Conflicts:
#	src/app/i18n/index.ts
This commit is contained in:
Samuel Elliott 2023-06-02 17:13:02 +01:00
commit b306d1d786
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
147 changed files with 2312 additions and 800 deletions

View File

@ -212,6 +212,18 @@ DEBUG=nxapi:api:* nxapi ...
DEBUG=* nxapi ...
```
By default all nxapi logs will be written to a platform-specific location:
Platform | Log path
----------------|----------------
macOS | `Library/Logs/nxapi-nodejs`
Windows | `%localappdata%\nxapi-nodejs\Log`
Linux | `$XDG_STATE_HOME/nxapi-nodejs` or `.local/state/nxapi-nodejs`
This only applies to the command line and Electron app and can be disabled by setting `NXAPI_DEBUG_FILE` to `0`. Each process writes to a new file. nxapi will automatically delete log files older than 14 days.
nxapi logs may contain sensitive information such as Nintendo Account access tokens.
#### Environment variables
Some options can be set using environment variables. These can be stored in a `.env` file in the data location. Environment variables will be read from the `.env` file in the default location, then the `.env` file in `NXAPI_DATA_PATH` location. `.env` files will not be read from the location set in the `--data-path` option.
@ -234,6 +246,7 @@ Environment variable | Description
`NXAPI_SPLATNET3_UPGRADE_QUERIES` | Sets when the SplatNet 3 client is allowed to upgrade persisted query IDs to newer versions. If `0` queries are never upgraded (not recommended). If `1` queries are upgraded if they do not contain potentially breaking changes (not recommended, as like `0` this allows older queries to be sent to the API). If `2` queries are upgraded, requests that would include breaking changes are rejected. If `3` all queries are upgraded, even if they contain potentially breaking changes (default).
`NXAPI_SPLATNET3_STRICT` | Disables strict handling of errors from the SplatNet 3 GraphQL API if set to `0`. If set to `1` (default) requests will be rejected if the response includes any errors, even if the response includes a result.
`DEBUG` | Used by the [debug](https://github.com/debug-js/debug) package. Sets which modules should have debug logging enabled. See [debug logs](#debug-logs).
`NXAPI_DEBUG_FILE` | Disables writing debug logs to a file if set to `0`.
Other environment variables may also be used by Node.js, Electron or other packages nxapi depends on.
@ -254,13 +267,19 @@ When using nxapi as a TypeScript/JavaScript library, the `addUserAgent` function
import { addUserAgent } from 'nxapi';
addUserAgent('your-script/1.0.0 (+https://github.com/...)');
```
// This could also be read from a package.json file
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { readFile } from 'node:fs/promises':
const pkg = JSON.parse(await readFile(resolve(fileURLToPath(import.meta.url), '..', 'package.json'), 'utf-8'));
addUserAgent(pkg.name + '/' + pkg.version + ' (+' + pkg.repository.url + ')');
The `addUserAgentFromPackageJson` function can be used to add data from a package.json file.
```ts
import { addUserAgentFromPackageJson } from 'nxapi';
await addUserAgentFromPackageJson(new URL('../package.json', import.meta.url));
await addUserAgentFromPackageJson(path.resolve(fileURLToString(import.meta.url), '..', 'package.json'));
// adds "test-package/0.1.0 (+https://github.com/ghost/example.git)"
await addUserAgentFromPackageJson(new URL('../package.json', import.meta.url), 'additional information');
// adds "test-package/0.1.0 (+https://github.com/ghost/example.git; additional information)"
```
### Usage as a TypeScript/JavaScript library
@ -296,7 +315,7 @@ The reason Nintendo added this is probably to try and stop people automating acc
- Nintendo Switch Online app API docs
- https://github.com/ZekeSnider/NintendoSwitchRESTAPI
- https://dev.to/mathewthe2/intro-to-nintendo-switch-rest-api-2cm7
- nxapi includes TypeScript definitions of all API resources and JSON Web Token payloads at [src/api](src/api)
- nxapi includes TypeScript definitions for all API resources and JSON Web Token payloads at [src/api](src/api)
- Coral client authentication (`f` parameter)
- https://github.com/samuelthomas2774/nxapi/discussions/10
- ~~https://github.com/frozenpandaman/splatnet2statink/wiki/api-docs - splatnet2statink and flapg API docs~~

View File

@ -1,7 +1,3 @@
#!/usr/bin/env node
import createDebug from 'debug';
createDebug.log = console.warn.bind(console);
import('../dist/cli.js').then(cli => cli.main.call(null));
import('../dist/cli-entry.js');

View File

@ -40,18 +40,18 @@ services:
presence-server:
build: .
command: presence-server --listen \[::]:80 --splatnet3
command: presence-server --listen \[::]:80 --splatnet3 --splatnet3-fest-votes
restart: unless-stopped
profiles:
- presence-server
labels:
traefik.enable: true
traefik.http.routers.nxapi-presence.entrypoints: websecure
traefik.http.routers.nxapi-presence.rule: Host(`${TRAEFIK_HOST:-nxapi.ta.fancy.org.uk}`) && (Path(`/api/presence`) || PathPrefix(`/api/presence/`))
traefik.http.routers.nxapi-presence.rule: Host(`${TRAEFIK_HOST:-nxapi.ta.fancy.org.uk}`) && (Path(`/api/presence`) || PathPrefix(`/api/presence/`) || PathPrefix(`/api/splatnet3/resources/`))
traefik.http.routers.nxapi-presence.tls: true
traefik.http.services.nxapi-presence.loadbalancer.server.port: 80
environment:
DEBUG: '*,-express:*'
DEBUG: '*,-express:*,-send'
ZNC_PROXY_URL: http://znc-proxy/api/znc
NXAPI_PRESENCE_SERVER_USER: ${NXAPI_PRESENCE_SERVER_USER:-}
NXAPI_PRESENCE_SERVER_SPLATNET3_PROXY_URL: http://presence-splatnet3-proxy/api/splatnet3-presence
@ -60,7 +60,7 @@ services:
presence-splatnet3-proxy:
build: .
command: presence-server --listen \[::]:80 --splatnet3 --splatnet3-proxy
command: presence-server --listen \[::]:80 --splatnet3 --splatnet3-proxy --splatnet3-record-fest-votes
restart: unless-stopped
profiles:
- presence-server

View File

@ -151,6 +151,10 @@ try {
This function is used to set the user agent string to use for non-Nintendo API requests. Any project using nxapi (including as a dependency of another project) must call this function with an appropriate user agent string segment. See [user agent strings](../../README.md#user-agent-strings).
#### `addUserAgentFromPackageJson`
This function is used to set the user agent string to use for non-Nintendo API requests using data from a package.json file. A string, URL object or the package.json data can be provided, as well as optional additional data. If a string/URL is provided this will return a Promise that will be resolved once the user agent is updated. See [user agent strings](../../README.md#user-agent-strings).
#### `version`
nxapi's version number.

52
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "nxapi",
"version": "1.6.0",
"version": "1.6.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "nxapi",
"version": "1.6.0",
"version": "1.6.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"body-parser": "^1.20.1",
@ -23,7 +23,7 @@
"node-notifier": "^10.0.1",
"node-persist": "^3.1.0",
"read": "^1.0.7",
"splatnet3-types": "^0.2.20230125112953",
"splatnet3-types": "^0.2.20230601143335",
"supports-color": "^8.1.1",
"tslib": "^2.4.1",
"uuid": "^8.3.2",
@ -39,7 +39,6 @@
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^14.1.0",
"@rollup/plugin-replace": "^4.0.0",
"@rollup/plugin-typescript": "^8.5.0",
"@types/body-parser": "^1.19.2",
"@types/cli-table": "^0.3.1",
"@types/debug": "^4.1.7",
@ -362,29 +361,6 @@
"rollup": "^1.20.0 || ^2.0.0"
}
},
"node_modules/@rollup/plugin-typescript": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-8.5.0.tgz",
"integrity": "sha512-wMv1/scv0m/rXx21wD2IsBbJFba8wGF3ErJIr6IKRfRj49S85Lszbxb4DCo8iILpluTjk2GAAu9CoZt4G3ppgQ==",
"dev": true,
"dependencies": {
"@rollup/pluginutils": "^3.1.0",
"resolve": "^1.17.0"
},
"engines": {
"node": ">=8.0.0"
},
"peerDependencies": {
"rollup": "^2.14.0",
"tslib": "*",
"typescript": ">=3.7.0"
},
"peerDependenciesMeta": {
"tslib": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
@ -4139,9 +4115,9 @@
"dev": true
},
"node_modules/splatnet3-types": {
"version": "0.2.20230125112953",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.2.20230125112953.tgz",
"integrity": "sha512-ZoyYHjRlq0ZIg8ZWVnQ6MYWSjEv7nVMgSImyDqho9taZS31yxrSj/xZer6teTSrCTGIabYDvx+MPEhAHg8Jbpw=="
"version": "0.2.20230601143335",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.2.20230601143335.tgz",
"integrity": "sha512-gZO2DUohuPhhPhwJrEcOR07fYgAvA42ZuXEvaw3x7c5LQzjNzgjkwOQNnpHAMZcE6lIfT2L45v8cjfyu8VcBgA=="
},
"node_modules/sprintf-js": {
"version": "1.1.2",
@ -4942,16 +4918,6 @@
"magic-string": "^0.25.7"
}
},
"@rollup/plugin-typescript": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-8.5.0.tgz",
"integrity": "sha512-wMv1/scv0m/rXx21wD2IsBbJFba8wGF3ErJIr6IKRfRj49S85Lszbxb4DCo8iILpluTjk2GAAu9CoZt4G3ppgQ==",
"dev": true,
"requires": {
"@rollup/pluginutils": "^3.1.0",
"resolve": "^1.17.0"
}
},
"@rollup/pluginutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
@ -7895,9 +7861,9 @@
"dev": true
},
"splatnet3-types": {
"version": "0.2.20230125112953",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.2.20230125112953.tgz",
"integrity": "sha512-ZoyYHjRlq0ZIg8ZWVnQ6MYWSjEv7nVMgSImyDqho9taZS31yxrSj/xZer6teTSrCTGIabYDvx+MPEhAHg8Jbpw=="
"version": "0.2.20230601143335",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.2.20230601143335.tgz",
"integrity": "sha512-gZO2DUohuPhhPhwJrEcOR07fYgAvA42ZuXEvaw3x7c5LQzjNzgjkwOQNnpHAMZcE6lIfT2L45v8cjfyu8VcBgA=="
},
"sprintf-js": {
"version": "1.1.2",

View File

@ -1,6 +1,6 @@
{
"name": "nxapi",
"version": "1.6.0",
"version": "1.6.1",
"description": "Nintendo Switch app APIs",
"license": "AGPL-3.0-or-later",
"author": "Samuel Elliott <samuel+nxapi@fancy.org.uk>",
@ -49,7 +49,7 @@
"node-notifier": "^10.0.1",
"node-persist": "^3.1.0",
"read": "^1.0.7",
"splatnet3-types": "^0.2.20230125112953",
"splatnet3-types": "^0.2.20230601143335",
"supports-color": "^8.1.1",
"tslib": "^2.4.1",
"uuid": "^8.3.2",
@ -62,7 +62,6 @@
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^14.1.0",
"@rollup/plugin-replace": "^4.0.0",
"@rollup/plugin-typescript": "^8.5.0",
"@types/body-parser": "^1.19.2",
"@types/cli-table": "^0.3.1",
"@types/debug": "^4.1.7",
@ -101,7 +100,8 @@
"!dist/app/package",
"!**/node_modules/**/*",
"resources/app",
"resources/common"
"resources/common",
"!resources/common/remote-config.json"
],
"asar": false,
"extraMetadata": {

View File

@ -11,8 +11,8 @@ const git = (...args) => execFile('git', args, options).then(({stdout}) => stdou
const pkg = JSON.parse(await fs.readFile(new URL('../../package.json', import.meta.url), 'utf-8'));
const [revision, branch_str, changed_files_str, tags_str, commit_count_str] = await Promise.all([
git('rev-parse', 'HEAD'),
git('rev-parse', '--abbrev-ref', 'HEAD'),
process.env.CI_COMMIT_SHA || git('rev-parse', 'HEAD'),
process.env.CI_COMMIT_BRANCH || git('rev-parse', '--abbrev-ref', 'HEAD'),
git('diff', '--name-only', 'HEAD'),
git('log', '--tags', '--no-walk', '--pretty=%D'),
git('rev-list', '--count', 'HEAD'),

View File

@ -10,8 +10,8 @@ const git = (...args) => execFile('git', args, options).then(({stdout}) => stdou
const pkg = JSON.parse(await fs.readFile(new URL('../../package.json', import.meta.url), 'utf-8'));
const [revision, branch, changed_files] = await Promise.all([
git('rev-parse', 'HEAD'),
git('rev-parse', '--abbrev-ref', 'HEAD'),
process.env.CI_COMMIT_SHA || git('rev-parse', 'HEAD'),
process.env.CI_COMMIT_BRANCH || git('rev-parse', '--abbrev-ref', 'HEAD'),
git('diff', '--name-only', 'HEAD'),
]);

View File

@ -1,7 +1,7 @@
{
"require_version": [],
"coral": {
"znca_version": "2.4.0"
"znca_version": "2.5.1"
},
"coral_auth": {
"default": "imink",
@ -10,15 +10,15 @@
"imink": {}
},
"moon": {
"znma_version": "1.17.0",
"znma_build": "261"
"znma_version": "1.18.0",
"znma_build": "275"
},
"coral_gws_nooklink": {
"blanco_version": "2.1.1"
},
"coral_gws_splatnet3": {
"app_ver": "2.0.0-bd36a652",
"version": "2.0.0",
"revision": "bd36a652913aa26b132b84df921e07b20f4a414d"
"app_ver": "4.0.0-e2ee936d",
"version": "4.0.0",
"revision": "e2ee936dbecad1fd8582c2a35c2603c63767263f"
}
}

View File

@ -2,4 +2,9 @@
mkdir -p /data/android
# Logs will be captured by Docker if enabled
# This is set here so that running another process with `docker exec` (which
# doesn't capture logs) will still write to a file by default
export NXAPI_DEBUG_FILE=0
exec /app/bin/nxapi.js --data-path /data "$@"

View File

@ -2,8 +2,8 @@ import * as path from 'path';
import { fileURLToPath } from 'url';
import * as fs from 'fs';
import * as child_process from 'child_process';
import { Module } from 'module';
import typescript from '@rollup/plugin-typescript';
import commonjs from '@rollup/plugin-commonjs';
import alias from '@rollup/plugin-alias';
import replace from '@rollup/plugin-replace';
@ -18,6 +18,14 @@ const default_remote_config =
JSON.parse(fs.readFileSync(path.join(dir, 'resources', 'common', 'remote-config.json'), 'utf-8'));
const git = (() => {
if (process.env.GITLAB_CI && process.env.CI_COMMIT_SHA) {
return {
revision: process.env.CI_COMMIT_SHA,
branch: process.env.CI_COMMIT_BRANCH || null,
changed_files: [],
};
}
try {
fs.statSync(path.join(dir, '.git'));
} catch (err) {
@ -34,7 +42,7 @@ const git = (() => {
branch: branch && branch !== 'HEAD' ? branch : null,
changed_files: changed_files.length ? changed_files.split('\n') : [],
};
})();;
})();
// If CI_COMMIT_TAG is set this is a tagged version for release
const release = process.env.NODE_ENV === 'production' ? process.env.CI_COMMIT_TAG || null : null;
@ -43,7 +51,7 @@ const release = process.env.NODE_ENV === 'production' ? process.env.CI_COMMIT_TA
* @type {import('@rollup/plugin-replace').RollupReplaceOptions}
*/
const replace_options = {
include: ['src/util/product.ts'],
include: ['dist/util/product.js'],
values: {
'globalThis.__NXAPI_BUNDLE_PKG__': JSON.stringify(pkg),
'globalThis.__NXAPI_BUNDLE_GIT__': JSON.stringify(git),
@ -58,20 +66,21 @@ const replace_options = {
* @type {import('rollup').RollupOptions['watch']}
*/
const watch = {
include: 'src/**',
include: 'dist/**',
};
/**
* @type {import('rollup').RollupOptions}
*/
const main = {
input: ['src/cli-entry.ts', 'src/app/main/index.ts'],
input: ['dist/cli-entry.js', 'dist/app/app-init.js', 'dist/app/main/index.js'],
output: {
dir: 'dist/bundle',
format: 'es',
sourcemap: true,
entryFileNames: chunk => {
if (chunk.name === 'cli-entry') return 'cli-bundle.js';
if (chunk.name === 'app-init') return 'app-init-bundle.js';
if (chunk.name === 'index') return 'app-main-bundle.js';
return 'entry-' + chunk.name + '.js';
},
@ -79,19 +88,17 @@ const main = {
},
plugins: [
replace(replace_options),
typescript({
outDir: 'dist/bundle/ts',
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
// events and stream modify module.exports
requireReturnsDefault: 'preferred',
}),
json(),
alias({
entries: [
...Module.builtinModules.map(m => ({find: m, replacement: 'node:' + m})),
],
}),
nodeResolve({
exportConditions: ['node'],
browser: false,
@ -110,29 +117,27 @@ const main = {
* @type {import('rollup').RollupOptions}
*/
const app_entry = {
input: 'src/app/app-entry.cts',
input: 'dist/app/app-entry.cjs',
output: {
file: 'dist/bundle/app-entry.cjs',
format: 'iife',
inlineDynamicImports: true,
sourcemap: true,
globals: {
'electron': 'require("electron")',
},
},
plugins: [
replace(replace_options),
replace({
include: ['src/app/app-entry.cts'],
include: ['dist/app/app-entry.cjs'],
values: {
'__NXAPI_BUNDLE_APP_MAIN__': JSON.stringify('./app-main-bundle.js'),
'__NXAPI_BUNDLE_APP_INIT__': JSON.stringify('./app-init-bundle.js'),
},
preventAssignment: true,
}),
typescript({
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
// events and stream modify module.exports
requireReturnsDefault: 'preferred',
@ -146,7 +151,10 @@ const app_entry = {
],
external: [
'electron',
path.resolve(__dirname, 'src/app/app-main-bundle.js'),
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'),
],
watch,
};
@ -155,7 +163,7 @@ const app_entry = {
* @type {import('rollup').RollupOptions}
*/
const app_preload = {
input: 'src/app/preload/index.ts',
input: 'dist/app/preload/index.js',
output: {
file: 'dist/app/bundle/preload.cjs',
format: 'cjs',
@ -163,13 +171,7 @@ const app_preload = {
},
plugins: [
replace(replace_options),
typescript({
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
}),
nodeResolve({
@ -187,20 +189,14 @@ const app_preload = {
* @type {import('rollup').RollupOptions}
*/
const app_preload_webservice = {
input: 'src/app/preload-webservice/index.ts',
input: 'dist/app/preload-webservice/index.js',
output: {
file: 'dist/app/bundle/preload-webservice.cjs',
format: 'cjs',
},
plugins: [
replace(replace_options),
typescript({
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
}),
nodeResolve({
@ -218,7 +214,7 @@ const app_preload_webservice = {
* @type {import('rollup').RollupOptions}
*/
const app_browser = {
input: 'src/app/browser/index.ts',
input: 'dist/app/browser/index.js',
output: {
dir: 'dist/app/bundle',
format: 'es',
@ -238,14 +234,7 @@ const app_browser = {
title: 'nxapi',
}),
replace(replace_options),
typescript({
outDir: 'dist/app/bundle/ts',
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
}),
nodePolyfill(),
@ -255,6 +244,7 @@ const app_browser = {
// 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')},
// rollup-plugin-polyfill-node doesn't support node: module identifiers
{find: /^node:(.+)/, replacement: '$1'},
@ -268,10 +258,12 @@ const app_browser = {
watch,
};
const skip = process.env.BUNDLE_SKIP?.split(',') ?? [];
export default [
main,
app_entry,
app_preload,
app_preload_webservice,
app_browser,
];
!skip?.includes('main') && main,
!skip?.includes('app-entry') && app_entry,
!skip?.includes('app-preload') && app_preload,
!skip?.includes('app-preload-webservice') && app_preload_webservice,
!skip?.includes('app-browser') && app_browser,
].filter(c => c);

View File

@ -1,10 +1,10 @@
import fetch, { Response } from 'node-fetch';
import { v4 as uuidgen } from 'uuid';
import createDebug from 'debug';
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 createDebug from '../util/debug.js';
import { JwtPayload } from '../util/jwt.js';
import { getAdditionalUserAgents } from '../util/useragent.js';
import { timeoutSignal } from '../util/misc.js';
@ -41,13 +41,17 @@ export interface ResultData<T> {
}
export default class CoralApi {
onTokenExpired: ((data: CoralErrorResponse, res: Response) => Promise<CoralAuthData | void>) | null = null;
onTokenExpired: ((data?: CoralErrorResponse, res?: Response) => Promise<CoralAuthData | void>) | null = null;
/** @internal */
_renewToken: Promise<void> | null = null;
/** @internal */
_token_expired = false;
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,
) {}
@ -55,8 +59,18 @@ export default class CoralApi {
async fetch<T = unknown>(
url: string, method = 'GET', body?: string, headers?: object,
/** @internal */ _autoRenewToken = true,
/** @internal */ _attempt = 0
/** @internal */ _attempt = 0,
): Promise<Result<T>> {
if (this._token_expired && _autoRenewToken && !this._renewToken) {
if (!this.onTokenExpired || _attempt) throw new Error('Token expired');
this._renewToken = this.onTokenExpired.call(null).then(data => {
if (data) this.setTokenWithSavedToken(data);
}).finally(() => {
this._renewToken = null;
});
}
if (this._renewToken && _autoRenewToken) {
await this._renewToken;
}
@ -84,6 +98,7 @@ export default class CoralApi {
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
this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, data, response).then(data => {
if (data) this.setTokenWithSavedToken(data);
@ -210,7 +225,12 @@ export default class CoralApi {
async getWebServiceToken(id: number, /** @internal */ _attempt = 0): Promise<Result<WebServiceToken>> {
await this._renewToken;
const data = await f(this.token, HashMethod.WEB_SERVICE, this.useragent ?? getAdditionalUserAgents());
const data = await f(this.token, HashMethod.WEB_SERVICE, {
platform: ZNCA_PLATFORM,
version: this.znca_version,
useragent: this.useragent ?? getAdditionalUserAgents(),
user: {na_id: this.na_id, coral_user_id: this.coral_user_id},
});
const req: WebServiceTokenParameter = {
id,
@ -242,8 +262,12 @@ export default class CoralApi {
// Nintendo Account token
const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID);
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL,
this.useragent ?? getAdditionalUserAgents());
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, {
platform: ZNCA_PLATFORM,
version: this.znca_version,
useragent: this.useragent ?? getAdditionalUserAgents(),
user: {na_id: user.id, coral_user_id: this.coral_user_id},
});
const req: AccountTokenParameter = {
naBirthday: user.birthday,
@ -273,6 +297,9 @@ export default class CoralApi {
/** @private */
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._token_expired = false;
}
static async createWithSessionToken(token: string, useragent = getAdditionalUserAgents()) {
@ -292,6 +319,8 @@ export default class CoralApi {
return new this(
data.credential.accessToken,
useragent,
'' + data.nsoAccount.user.id,
data.user.id,
data.znca_version,
data.znca_useragent,
);
@ -313,14 +342,19 @@ export default class CoralApi {
static async loginWithNintendoAccountToken(
nintendoAccountToken: NintendoAccountToken,
user: NintendoAccountUser,
useragent = getAdditionalUserAgents()
useragent = getAdditionalUserAgents(),
) {
const { default: { coral: config } } = await import('../common/remote-config.js');
if (!config) throw new Error('Remote configuration prevents Coral authentication');
const znca_useragent = `com.nintendo.znca/${config.znca_version}(${ZNCA_PLATFORM}/${ZNCA_PLATFORM_VERSION})`;
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, useragent);
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, {
platform: ZNCA_PLATFORM,
version: config.znca_version,
useragent,
user: {na_id: user.id},
});
debug('Getting Nintendo Switch Online app token');

View File

@ -1,12 +1,11 @@
import process from 'node:process';
import fetch from 'node-fetch';
import createDebug from 'debug';
import fetch, { Headers } from 'node-fetch';
import { v4 as uuidgen } from 'uuid';
import { defineResponse, ErrorResponse } from './util.js';
import createDebug from '../util/debug.js';
import { timeoutSignal } from '../util/misc.js';
import { getUserAgent } from '../util/useragent.js';
const debugS2s = createDebug('nxapi:api:s2s');
const debugFlapg = createDebug('nxapi:api:flapg');
const debugImink = createDebug('nxapi:api:imink');
const debugZncaApi = createDebug('nxapi:api:znca-api');
@ -16,7 +15,10 @@ export abstract class ZncaApi {
public useragent?: string
) {}
abstract genf(token: string, hash_method: HashMethod): Promise<FResult>;
abstract genf(
token: string, hash_method: HashMethod,
user?: {na_id: string; coral_user_id?: string;},
): Promise<FResult>;
}
export enum HashMethod {
@ -28,49 +30,6 @@ export enum HashMethod {
// flapg
//
/** @deprecated The flapg API no longer requires client authentication */
export async function getLoginHash(token: string, timestamp: string | number, useragent?: string) {
const { default: { coral_auth: { splatnet2statink: config } } } = await import('../common/remote-config.js');
if (!config) throw new Error('Remote configuration prevents splatnet2statink API use');
debugS2s('Getting login hash');
const [signal, cancel] = timeoutSignal();
const response = await fetch('https://elifessler.com/s2s/api/gen2', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': getUserAgent(useragent),
},
body: new URLSearchParams({
naIdToken: token,
timestamp: '' + timestamp,
}).toString(),
signal,
}).finally(cancel);
if (response.status !== 200) {
throw new ErrorResponse('[s2s] Non-200 status code', response, await response.text());
}
const data = await response.json() as LoginHashApiResponse | LoginHashApiError;
if ('error' in data) {
throw new ErrorResponse('[s2s] ' + data.error, response, data);
}
debugS2s('Got login hash "%s"', data.hash, data);
return data.hash;
}
export interface LoginHashApiResponse {
hash: string;
}
export interface LoginHashApiError {
error: string;
}
export async function flapg(
hash_method: HashMethod, token: string,
timestamp?: string | number, request_id?: string,
@ -131,11 +90,6 @@ export type FlapgApiResponse = IminkFResponse;
export type FlapgApiError = IminkFError;
export class ZncaApiFlapg extends ZncaApi {
/** @deprecated */
async getLoginHash(id_token: string, timestamp: string) {
return getLoginHash(id_token, timestamp, this.useragent);
}
async genf(token: string, hash_method: HashMethod) {
const request_id = uuidgen();
@ -158,7 +112,8 @@ export class ZncaApiFlapg extends ZncaApi {
export async function iminkf(
hash_method: HashMethod, token: string,
timestamp?: number, request_id?: string,
useragent?: string
user?: {na_id: string; coral_user_id?: string;},
useragent?: string,
) {
const { default: { coral_auth: { imink: config } } } = await import('../common/remote-config.js');
if (!config) throw new Error('Remote configuration prevents imink API use');
@ -172,6 +127,7 @@ export async function iminkf(
token,
timestamp: typeof timestamp === 'number' ? '' + timestamp : undefined,
request_id,
...user,
};
const [signal, cancel] = timeoutSignal();
@ -217,16 +173,17 @@ export interface IminkFError {
}
export class ZncaApiImink extends ZncaApi {
async genf(token: string, hash_method: HashMethod) {
async genf(token: string, hash_method: HashMethod, user?: {na_id: string; coral_user_id?: string;}) {
const request_id = uuidgen();
const result = await iminkf(hash_method, token, undefined, request_id, this.useragent);
const result = await iminkf(hash_method, token, undefined, request_id, user, this.useragent);
return {
provider: 'imink' as const,
hash_method, token, request_id,
timestamp: result.timestamp,
f: result.f,
user,
result,
};
}
@ -239,10 +196,13 @@ export class ZncaApiImink extends ZncaApi {
export async function genf(
url: string, hash_method: HashMethod,
token: string, timestamp?: number, request_id?: string,
useragent?: string
user?: {na_id: string; coral_user_id?: string;},
app?: {platform?: string; version?: string;},
useragent?: string,
) {
debugZncaApi('Getting f parameter', {
url, hash_method, token, timestamp, request_id,
url, hash_method, token, timestamp, request_id, user,
znca_platform: app?.platform, znca_version: app?.version,
});
const req: AndroidZncaFRequest = {
@ -250,15 +210,20 @@ export async function genf(
token,
timestamp,
request_id,
...user,
};
const headers = new Headers({
'Content-Type': 'application/json',
'User-Agent': getUserAgent(useragent),
});
if (app?.platform) headers.append('X-znca-Platform', app.platform);
if (app?.version) headers.append('X-znca-Version', app.version);
const [signal, cancel] = timeoutSignal();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': getUserAgent(useragent),
},
headers,
body: JSON.stringify(req),
signal,
}).finally(cancel);
@ -289,21 +254,27 @@ export interface AndroidZncaFResponse {
f: string;
timestamp?: number;
request_id?: string;
warnings?: {error: string; error_message: string}[];
}
export interface AndroidZncaFError {
error: string;
error_message?: string;
errors?: {error: string; error_message: string}[];
warnings?: {error: string; error_message: string}[];
}
export class ZncaApiNxapi extends ZncaApi {
constructor(readonly url: string, useragent?: string) {
constructor(readonly url: string, readonly app?: {platform?: string; version?: string;}, useragent?: string) {
super(useragent);
}
async genf(token: string, hash_method: HashMethod) {
async genf(token: string, hash_method: HashMethod, user?: {na_id: string; coral_user_id?: string}) {
const request_id = uuidgen();
const result = await genf(this.url + '/f', hash_method, token, undefined, request_id, this.useragent);
const result = await genf(this.url + '/f', hash_method, token, undefined, request_id,
user, this.app, this.useragent);
return {
provider: 'nxapi' as const,
@ -311,17 +282,21 @@ export class ZncaApiNxapi extends ZncaApi {
hash_method, token, request_id,
timestamp: result.timestamp!, // will be included as not sent in request
f: result.f,
user,
result,
};
}
}
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, useragent?: string): Promise<FResult> {
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, options?: ZncaApiOptions): Promise<FResult>;
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, useragent?: string): Promise<FResult>;
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, options?: ZncaApiOptions | string): Promise<FResult> {
if (typeof options === 'string') options = {useragent: options};
if (typeof hash_method === 'string') hash_method = parseInt(hash_method);
const provider = getPreferredZncaApiFromEnvironment(useragent) ?? await getDefaultZncaApi(useragent);
const provider = getPreferredZncaApiFromEnvironment(options) ?? await getDefaultZncaApi(options);
return provider.genf(token, hash_method);
return provider.genf(token, hash_method, options?.user);
}
export type FResult = {
@ -331,6 +306,7 @@ export type FResult = {
timestamp: number;
request_id: string;
f: string;
user?: {na_id: string; coral_user_id?: string;};
result: unknown;
} & ({
provider: 'flapg';
@ -344,37 +320,52 @@ export type FResult = {
result: AndroidZncaFResponse;
});
export function getPreferredZncaApiFromEnvironment(useragent?: string): ZncaApi | null {
interface ZncaApiOptions {
useragent?: string;
platform?: string;
version?: string;
user?: {na_id: string; coral_user_id?: string;};
}
export function getPreferredZncaApiFromEnvironment(options?: ZncaApiOptions): ZncaApi | null;
export function getPreferredZncaApiFromEnvironment(useragent?: string): ZncaApi | null;
export function getPreferredZncaApiFromEnvironment(options?: ZncaApiOptions | string): ZncaApi | null {
if (typeof options === 'string') options = {useragent: options};
if (process.env.NXAPI_ZNCA_API) {
if (process.env.NXAPI_ZNCA_API === 'flapg') {
return new ZncaApiFlapg(useragent);
return new ZncaApiFlapg(options?.useragent);
}
if (process.env.NXAPI_ZNCA_API === 'imink') {
return new ZncaApiImink(useragent);
return new ZncaApiImink(options?.useragent);
}
throw new Error('Unknown znca API provider');
}
if (process.env.ZNCA_API_URL) {
return new ZncaApiNxapi(process.env.ZNCA_API_URL, useragent);
return new ZncaApiNxapi(process.env.ZNCA_API_URL, options, options?.useragent);
}
return null;
}
export async function getDefaultZncaApi(useragent?: string) {
export async function getDefaultZncaApi(options?: ZncaApiOptions): Promise<ZncaApi>;
export async function getDefaultZncaApi(useragent?: string): Promise<ZncaApi>;
export async function getDefaultZncaApi(options?: ZncaApiOptions | string) {
if (typeof options === 'string') options = {useragent: options};
const { default: { coral_auth: { default: provider } } } = await import('../common/remote-config.js');
if (provider === 'flapg') {
return new ZncaApiFlapg(useragent);
return new ZncaApiFlapg(options?.useragent);
}
if (provider === 'imink') {
return new ZncaApiImink(useragent);
return new ZncaApiImink(options?.useragent);
}
if (provider[0] === 'nxapi') {
return new ZncaApiNxapi(provider[1], useragent);
return new ZncaApiNxapi(provider[1], options, options?.useragent);
}
throw new Error('Invalid znca API provider');

View File

@ -1,8 +1,8 @@
import fetch, { Response } from 'node-fetch';
import createDebug from 'debug';
import { getNintendoAccountToken, getNintendoAccountUser, 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';
import { timeoutSignal } from '../util/misc.js';
const debug = createDebug('nxapi:api:moon');
@ -16,9 +16,10 @@ const ZNMA_USER_AGENT = 'moon_ANDROID/' + ZNMA_VERSION + ' (com.nintendo.znma; b
'; ANDROID 26)';
export default class MoonApi {
onTokenExpired: ((data: MoonError, res: Response) => Promise<MoonAuthData | PartialMoonAuthData | void>) | null = null;
onTokenExpired: ((data?: MoonError, res?: Response) => Promise<MoonAuthData | PartialMoonAuthData | void>) | null = null;
/** @internal */
_renewToken: Promise<void> | null = null;
protected _token_expired = false;
protected constructor(
public token: string,
@ -31,8 +32,18 @@ export default class MoonApi {
async fetch<T extends object>(
url: string, method = 'GET', body?: string, headers?: object,
/** @internal */ _autoRenewToken = true,
/** @internal */ _attempt = 0
/** @internal */ _attempt = 0,
): Promise<HasResponse<T, Response>> {
if (this._token_expired && _autoRenewToken && !this._renewToken) {
if (!this.onTokenExpired || _attempt) throw new Error('Token expired');
this._renewToken = this.onTokenExpired.call(null).then(data => {
if (data) this.setTokenWithSavedToken(data);
}).finally(() => {
this._renewToken = null;
});
}
if (this._renewToken && _autoRenewToken) {
await this._renewToken;
}
@ -62,6 +73,7 @@ export default class MoonApi {
debug('fetch %s %s, response %s', method, url, response.status);
if (response.status === 401 && _autoRenewToken && !_attempt && this.onTokenExpired) {
this._token_expired = true;
const data = await response.json() as MoonError;
// _renewToken will be awaited when calling fetch
@ -123,6 +135,7 @@ export default class MoonApi {
private setTokenWithSavedToken(data: MoonAuthData | PartialMoonAuthData) {
this.token = data.nintendoAccountToken.access_token!;
if ('user' in data) this.naId = data.user.id;
this._token_expired = false;
}
static async createWithSessionToken(token: string) {

View File

@ -1,6 +1,6 @@
import fetch from 'node-fetch';
import createDebug from 'debug';
import { defineResponse, ErrorResponse } from './util.js';
import createDebug from '../util/debug.js';
import { JwtPayload } from '../util/jwt.js';
import { timeoutSignal } from '../util/misc.js';
@ -14,8 +14,7 @@ export async function getNintendoAccountSessionToken(code: string, verifier: str
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Platform': 'Android',
'X-ProductVersion': '2.0.0',
'Accept': 'application/json',
'User-Agent': 'NASDKAPI; Android',
},
body: new URLSearchParams({

View File

@ -1,10 +1,10 @@
import fetch, { Response } from 'node-fetch';
import createDebug from 'debug';
import { WebServiceToken } from './coral-types.js';
import { NintendoAccountUser } from './na.js';
import { defineResponse, ErrorResponse, HasResponse } from './util.js';
import CoralApi 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';
const debug = createDebug('nxapi:api:nooklink');
@ -17,9 +17,10 @@ const NOOKLINK_URL = NOOKLINK_WEBSERVICE_URL + '/api';
const BLANCO_VERSION = '2.1.1';
export default class NooklinkApi {
onTokenExpired: ((data: WebServiceError, res: Response) => Promise<NooklinkAuthData | void>) | null = null;
onTokenExpired: ((data?: WebServiceError, res?: Response) => Promise<NooklinkAuthData | void>) | null = null;
/** @internal */
_renewToken: Promise<void> | null = null;
protected _token_expired = false;
protected constructor(
public gtoken: string,
@ -30,8 +31,18 @@ export default class NooklinkApi {
async fetch<T extends object>(
url: string, method = 'GET', body?: string | FormData, headers?: object,
/** @internal */ _autoRenewToken = true,
/** @internal */ _attempt = 0
/** @internal */ _attempt = 0,
): Promise<HasResponse<T, Response>> {
if (this._token_expired && _autoRenewToken && !this._renewToken) {
if (!this.onTokenExpired || _attempt) throw new Error('Token expired');
this._renewToken = this.onTokenExpired.call(null).then(data => {
if (data) this.setTokenWithSavedToken(data);
}).finally(() => {
this._renewToken = null;
});
}
if (this._renewToken && _autoRenewToken) {
await this._renewToken;
}
@ -57,6 +68,7 @@ export default class NooklinkApi {
debug('fetch %s %s, response %s', method, url, response.status);
if (response.status === 401 && _autoRenewToken && !_attempt && this.onTokenExpired) {
this._token_expired = true;
const data = await response.json() as WebServiceError;
// _renewToken will be awaited when calling fetch
@ -109,6 +121,7 @@ export default class NooklinkApi {
private setTokenWithSavedToken(data: NooklinkAuthData) {
this.gtoken = data.gtoken;
this._token_expired = false;
}
static async createWithCoral(nso: CoralApi, user: NintendoAccountUser) {
@ -195,9 +208,10 @@ export default class NooklinkApi {
}
export class NooklinkUserApi {
onTokenExpired: ((data: WebServiceError, res: Response) => Promise<NooklinkUserAuthData | PartialNooklinkUserAuthData | void>) | null = null;
onTokenExpired: ((data?: WebServiceError, res?: Response) => Promise<NooklinkUserAuthData | PartialNooklinkUserAuthData | void>) | null = null;
/** @internal */
_renewToken: Promise<void> | null = null;
protected _token_expired = false;
protected constructor(
public user_id: string,
@ -211,8 +225,18 @@ export class NooklinkUserApi {
async fetch<T extends object>(
url: string, method = 'GET', body?: string | FormData, headers?: object,
/** @internal */ _autoRenewToken = true,
/** @internal */ _attempt = 0
/** @internal */ _attempt = 0,
): Promise<HasResponse<T, Response>> {
if (this._token_expired && _autoRenewToken && !this._renewToken) {
if (!this.onTokenExpired || _attempt) throw new Error('Token expired');
this._renewToken = this.onTokenExpired.call(null).then(data => {
if (data) this.setTokenWithSavedToken(data);
}).finally(() => {
this._renewToken = null;
});
}
if (this._renewToken && _autoRenewToken) {
await this._renewToken;
}
@ -239,6 +263,7 @@ export class NooklinkUserApi {
debug('fetch %s %s, response %s', method, url, response.status);
if (response.status === 401 && _autoRenewToken && !_attempt && this.onTokenExpired) {
this._token_expired = true;
const data = await response.json() as WebServiceError;
// _renewToken will be awaited when calling fetch
@ -327,6 +352,7 @@ export class NooklinkUserApi {
this.user_id = data.user_id;
this.auth_token = data.token.token;
this.gtoken = data.gtoken;
this._token_expired = false;
}
/** @internal */

View File

@ -1,11 +1,11 @@
import fetch from 'node-fetch';
import createDebug from 'debug';
import { v4 as uuidgen } from 'uuid';
import { WebServiceToken } from './coral-types.js';
import { NintendoAccountUser } from './na.js';
import { defineResponse, ErrorResponse } from './util.js';
import CoralApi 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';
import { toSeasonId, Rule as XPowerRankingRule, Season } from './splatnet2-xrank.js';
@ -25,6 +25,8 @@ export const updateIksmSessionLastUsed: {
} = {};
export default class SplatNet2Api {
protected _session_expired = false;
protected constructor(
public iksm_session: string,
public unique_id: string,
@ -32,6 +34,10 @@ export default class SplatNet2Api {
) {}
async fetch<T extends object>(url: string, method = 'GET', body?: string | FormData, headers?: object) {
if (this._session_expired) {
throw new Error('Session expired');
}
const [signal, cancel] = timeoutSignal();
const response = await fetch(SPLATNET2_URL + url, {
method,
@ -52,6 +58,10 @@ export default class SplatNet2Api {
debug('fetch %s %s, response %s', method, url, response.status);
if (response.status === 401) {
this._session_expired = true;
}
if (response.status !== 200) {
throw new ErrorResponse('[splatnet2] Non-200 status code', response, await response.text());
}

View File

@ -1,12 +1,12 @@
import createDebug from 'debug';
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 { timeoutSignal } from '../util/misc.js';
import { WebServiceToken } from './coral-types.js';
import CoralApi from './coral.js';
import { NintendoAccountUser } from './na.js';
import { BulletToken } from './splatnet3-types.js';
import { defineResponse, ErrorResponse, HasResponse, ResponseSymbol } from './util.js';
import createDebug from '../util/debug.js';
import { timeoutSignal } from '../util/misc.js';
const debug = createDebug('nxapi:api:splatnet3');
const debugGraphQl = createDebug('nxapi:api:splatnet3:graphql');
@ -74,9 +74,10 @@ enum MapQueriesMode {
export default class SplatNet3Api {
onTokenShouldRenew: ((remaining: number, res: Response) => Promise<SplatNet3AuthData | void>) | null = null;
onTokenExpired: ((res: Response) => Promise<SplatNet3AuthData | void>) | null = null;
onTokenExpired: ((res?: Response) => Promise<SplatNet3AuthData | void>) | null = null;
/** @internal */
_renewToken: Promise<void> | null = null;
protected _token_expired = false;
graphql_strict = process.env.NXAPI_SPLATNET3_STRICT !== '0';
@ -85,6 +86,7 @@ export default class SplatNet3Api {
public version: string,
public map_queries: Partial<Record<string, [/** new query ID */ string, /** unsafe */ boolean] | null>>,
readonly map_queries_mode: MapQueriesMode,
readonly na_country: string,
public language: string,
public useragent: string,
) {}
@ -92,8 +94,18 @@ export default class SplatNet3Api {
async fetch<T = unknown>(
url: string, method = 'GET', body?: string | FormData, headers?: object,
/** @internal */ _log?: string,
/** @internal */ _attempt = 0
/** @internal */ _attempt = 0,
): Promise<HasResponse<T, Response>> {
if (this._token_expired && !this._renewToken) {
if (!this.onTokenExpired || _attempt) throw new Error('Token expired');
this._renewToken = this.onTokenExpired.call(null).then(data => {
if (data) this.setTokenWithSavedToken(data);
}).finally(() => {
this._renewToken = null;
});
}
if (this._renewToken) {
await this._renewToken;
}
@ -120,6 +132,7 @@ export default class SplatNet3Api {
response.status, version);
if (response.status === 401 && !_attempt && this.onTokenExpired) {
this._token_expired = true;
// _renewToken will be awaited when calling fetch
this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, response).then(data => {
if (data) this.setTokenWithSavedToken(data);
@ -244,12 +257,16 @@ export default class SplatNet3Api {
/** / */
async getHome() {
return this.persistedQuery(RequestId.HomeQuery, {});
return this.persistedQuery(RequestId.HomeQuery, {
naCountry: this.na_country,
});
}
/** / -> /setting */
async getSettings() {
return this.persistedQuery(RequestId.SettingQuery, {});
return this.persistedQuery(RequestId.SettingQuery, {
naCountry: this.na_country,
});
}
/** / -> /photo_album */
@ -693,6 +710,14 @@ export default class SplatNet3Api {
});
}
/** / -> /my_outfits [-> /my_outfits/{id}] -> share */
async shareOutfit(index: number, timezone_offset_minutes = 0) {
return this.persistedQuery(RequestId.ShareMyOutfitQuery, {
myOutfitIndex: index,
timezoneOffset: timezone_offset_minutes, // (new Date()).getTimezoneOffset()
});
}
//
// Replays
//
@ -848,7 +873,7 @@ export default class SplatNet3Api {
PagerUpdateBattleHistoriesByVsModeVariables
>(RequestId.PagerUpdateBattleHistoriesByVsModeQuery, {
isBankara: false,
isLeague: false,
isEvent: false,
isPrivate: false,
isRegular: false,
isXBattle: false,
@ -920,6 +945,7 @@ export default class SplatNet3Api {
this.version = data.version;
this.language = data.bullet_token.lang;
this.useragent = data.useragent;
this._token_expired = false;
}
static async createWithCoral(nso: CoralApi, user: NintendoAccountUser) {
@ -933,6 +959,7 @@ export default class SplatNet3Api {
data.version,
data.queries ?? {},
getMapPersistedQueriesModeFromEnvironment(),
data.country,
data.bullet_token.lang,
data.useragent,
);
@ -944,6 +971,7 @@ export default class SplatNet3Api {
data.version,
data.queries ?? {},
getMapPersistedQueriesModeFromEnvironment(),
data.country ?? 'GB',
data.language,
SPLATNET3_WEBSERVICE_USERAGENT,
);
@ -1081,6 +1109,7 @@ export interface SplatNet3CliTokenData {
bullet_token: string;
expires_at: number;
language: string;
country: string;
version: string;
queries?: Partial<Record<string, [/** new query ID */ string, /** unsafe */ boolean] | null>>;
}

View File

@ -1,10 +1,10 @@
import fetch, { Response } from 'node-fetch';
import createDebug from 'debug';
import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, CoralErrorResponse, 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 { SavedToken } from '../common/auth/coral.js';
import createDebug from '../util/debug.js';
import { timeoutSignal } from '../util/misc.js';
import { getAdditionalUserAgents, getUserAgent } from '../util/useragent.js';
@ -12,10 +12,16 @@ 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;
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 = '';

View File

@ -2,7 +2,11 @@ const electron = require('electron');
// Do anything that must be run before the app is ready...
electron.app.whenReady()
Promise.all([
// @ts-expect-error
typeof __NXAPI_BUNDLE_APP_INIT__ !== 'undefined' ? import(__NXAPI_BUNDLE_APP_INIT__) : import('./app-init.js'),
electron.app.whenReady(),
])
// @ts-expect-error
.then(() => typeof __NXAPI_BUNDLE_APP_MAIN__ !== 'undefined' ? import(__NXAPI_BUNDLE_APP_MAIN__) : import('./main/index.js'))
.then(m => m.init.call(null))

5
src/app/app-init.ts Normal file
View File

@ -0,0 +1,5 @@
import { join } from 'node:path';
import { init as initDebug } from '../util/debug.js';
import { paths } from '../util/product.js';
await initDebug(join(paths.log, 'app'));

View File

@ -19,7 +19,7 @@ export default function DiscordPresenceSource(props: {
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}
{props.presence || props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
</View>
</TouchableOpacity>;
}
@ -86,28 +86,37 @@ function DiscordPresenceInactive() {
}
function DiscordPresence(props: {
presence: DiscordPresence;
user: User;
presence: DiscordPresence | null;
user: User | null;
}) {
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'sidebar' });
const large_image_url = props.presence.activity.largeImageKey?.match(/^\d{16}$/) ?
const large_image_url = props.presence ? props.presence.activity.largeImageKey?.match(/^\d{16}$/) ?
'https://cdn.discordapp.com/app-assets/' + props.presence.id + '/' +
props.presence.activity.largeImageKey + '.png' :
props.presence.activity.largeImageKey;
const user_image_url = 'https://cdn.discordapp.com/avatars/' + props.user.id + '/' + props.user.avatar + '.png';
props.presence.activity.largeImageKey : undefined;
return <>
<View style={styles.discordPresence}>
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.discriminator) % 5) + '.png' : undefined;
return <View style={styles.discordPresenceContainer}>
{props.presence ? <View style={styles.discordPresence}>
<Image source={{uri: large_image_url, width: 18, height: 18}} style={styles.discordPresenceImage} />
<Text style={styles.discordPresenceText} numberOfLines={1} ellipsizeMode="tail">{t('discord_playing')}</Text>
</View>
</View> : null}
<View style={styles.discordUser}>
{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}#{props.user.discriminator}</Text>
</View>
</>;
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">
{props.user.username}<Text style={styles.discordUserDiscriminator}>#{props.user.discriminator}</Text>
</Text>
</View> : <View style={styles.discordUser}>
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">{t('discord_not_connected')}</Text>
</View>}
</View>;
}
const styles = StyleSheet.create({
@ -131,8 +140,12 @@ const styles = StyleSheet.create({
userSelect: 'all',
},
discordPresenceContainer: {
marginTop: 2,
},
discordPresence: {
marginTop: 12,
marginTop: 10,
flexDirection: 'row',
alignItems: 'center',
},
@ -156,4 +169,7 @@ const styles = StyleSheet.create({
discordUserText: {
color: TEXT_COLOUR_DARK,
},
discordUserDiscriminator: {
opacity: 0.7,
},
});

View File

@ -13,6 +13,13 @@ export const languages = {
['Samuel Elliott', 'https://gitlab.fancy.org.uk/samuel', 'https://github.com/samuelthomas2774'],
],
},
'de-DE': {
name: 'Deutsch',
app: () => import('./locale/de-de.js'),
authors: [
['Inkception', 'https://github.com/Inkception'],
],
},
'ja-JP': {
name: 'Japanese',
app: () => import('./locale/ja-jp.js'),

View File

@ -0,0 +1,393 @@
import { CREDITS_NOTICE, LICENCE_NOTICE } from '../../../common/constants.js';
export const app = {
default_title: 'Nintendo Switch Online',
licence: LICENCE_NOTICE,
credits: CREDITS_NOTICE,
translation_credits: '{{language}} Übersetzungen von {{authors, list}}.',
};
export const app_menu = {
preferences: 'Einstellungen',
view: 'Anschauen',
learn_more: 'Mehr erfahren',
learn_more_github: 'Mehr erfahren (GitHub)',
search_issues: 'Probleme untersuchen',
refresh: 'Aktualisieren',
};
export const menu_app = {
coral_heading: 'Nintendo Switch Online',
na_id: 'Nintendo Account ID: {{id}}',
coral_id: 'Coral ID: {{id}}',
nsa_id: 'NSA ID: {{id}}',
discord_presence_enable: 'Discord Rich Presence aktivieren',
user_notifications_enable: 'Aktiviere Benachrichtigungen für diesen User',
friend_notifications_enable: 'Aktiviere Benachrichtigungen für Freunde von diesen User',
refresh: 'Jetzt aktualisieren',
add_friend: 'Freund hinzufügen',
web_services: 'Web-Services',
moon_heading: 'Nintendo Switch-Altersbeschränkungen',
add_account: 'Account hinzufügen',
show_main_window: 'Zeige Hauptfenster',
preferences: 'Einstellungen',
quit: 'Beenden',
};
export const menus = {
add_account: {
add_account_coral: 'Nintendo Switch Online Account hinzufügen',
add_account_moon: 'Altersbeschränkter Nintendo Switch Account hinzufügen',
},
friend_code: {
share: 'Teilen',
copy: 'Kopieren',
friend_code_regenerable: 'Regeneriere durch Nintendo Switch Konsole',
friend_code_regenerable_at: 'Kann {{date, datetime}} neu generiert werden',
},
user: {
na_id: 'Nintendo Account ID: {{id}}',
coral_id: 'Coral ID: {{id}}',
nsa_id: 'NSA ID: {{id}}',
discord_disable: 'Deaktiviere Discord Rich Presence',
discord_enabled_for: 'Discord Rich Presence für {{name}} aktiviert',
discord_enabled_via: 'Discord Rich Presence durch {{name}} aktiviert',
discord_enable: 'Aktiviere Discord Rich Presence für diesen User...',
friend_notifications_enable: 'Aktiviere Freund-Benachrichtigungen',
refresh: 'Jetzt aktualisieren',
add_friend: 'Freund hinzufügen',
remove_help: 'Benutze den nxapi Befehl, um diesen User zu entfernen',
},
friend: {
presence_online: 'Online',
game_first_played: 'Zuerst gespielt: {{date, datetime}}',
game_play_time_h: 'Spielzeit: $t(friend.hours, {"count": {{hours}}})',
game_play_time_hm: 'Spielzeit: $t(friend.hours, {"count": {{hours}}}), $t(friend.minutes, {"count": {{minutes}}})',
game_play_time_m: 'Spielzeit: $t(friend.minutes, {"count": {{minutes}}})',
hours_one: '{{count}} Stunde',
hours_other: '{{count}} Stunden',
minutes_one: '{{count}} Minute',
minutes_other: '{{count}} Minuten',
presence_inactive: 'Offline (Konsole online)',
presence_offline: 'Offline',
presence_updated: 'Aktualisiert: {{date, datetime}}',
presence_logout_time: 'Ausgeloggt: {{date, datetime}}',
discord_presence_enable: 'Aktiviere Discord Rich Presence',
},
};
export const notifications = {
playing: 'Spielt {{name}}',
offline: 'Offline',
};
export const handle_uri = {
friend_code_select: 'Wähle einen User aus, um Freunde hinzuzufügen',
web_service_select: 'Wähle einen User aus, um diesen Service zu öffnen',
web_service_invalid_title: 'Unbekannter Titel',
web_service_invalid_detail: 'Die angegebene URL verwies nicht auf einen existierenden Web-Service.',
cancel: 'Abbrechen',
};
export const na_auth = {
window: {
title: 'Nintendo Account',
},
znca_api_use: {
title: 'Verwendung einer Drittanbieter-API',
text: `Um Zugriff auf die API der Nintendo Switch Online App zu erhalten, muss nxapi einige Daten an Drittanbieter-APIs senden. Dieser Schritt wird benötigt, um Daten zu generieren, damit Nintendo denkt, dass du die echte Nintendo Switch Online App verwendest.
Standardmäßig wird nxapi-znca-api.fancy.org.uk oder api.imink.app benutzt. Ein anderer Service kann ebenfalls benutzt werden, indem eine Umgebungsvariable gesetzt wird. Die standardmäßige API könnte sich jederzeit ohne Hinweis ändern, wenn du keinen spezifischen Service erzwingst.
Die gesendeten Daten beinhalten:
- Deine Nintendo Account ID
- Bei Authorisierung mit der Nintendo Switch Online App: Ein Nintendo Account ID Token, welcher dein Land beinhaltet und für 15 Minuten gültig ist
- Bei Authorisierung mit Spielspezifischen Services: Deine Coral (Nintendo Switch Online App) User ID und ein Coral ID Token, welcher deine aktuelle Nintendo Switch Online Mitgliedschaft und Altersbeschränkungsstatus beinhaltet und für 2 Stunden gültig ist`,
ok: 'OK',
cancel: 'Abbrechen',
more_information: 'Weitere Informationen',
},
notification_coral: {
title: 'Nintendo Switch Online',
body_existing: 'Bereits als {{name}} (Nintendo Account {{na_name}} / {{na_username}}) eingeloggt',
body_authenticated: 'Authentifiziert als {{name}} (Nintendo Account {{na_name}} / {{na_username}})',
body_reauthenticated: 'Erneut als {{name}} (Nintendo Account {{na_name}} / {{na_username}}) authentifiziert',
},
notification_moon: {
title: 'Nintendo Switch-Altersbeschränkungen',
body_existing: 'Bereits als {{na_name}} ({{na_username}}) eingeloggt',
body_authenticated: 'Authentifiziert als {{na_name}} ({{na_username}})',
body_reauthenticated: 'Erneut als {{na_name}} ({{na_username}}) authentifiziert',
},
error: {
title: 'Fehler beim Hinzufügen des Accounts',
},
};
export const time_since = {
default: {
now: 'jetzt',
seconds_one: 'Vor {{count}} Sekunde',
seconds_other: 'Vor {{count}} Sekunden',
minutes_one: 'Vor {{count}} Minute',
minutes_other: 'Vor {{count}} Minuten',
hours_one: 'Vor {{count}} Stunde',
hours_other: 'Vor {{count}} Stunden',
days_one: 'Vor {{count}} Tag',
days_other: 'Vor {{count}} Tagen',
},
short: {
now: 'jetzt',
seconds_one: '{{count}} Sek',
seconds_other: '{{count}} Sek',
minutes_one: '{{count}} Min',
minutes_other: '{{count}} Min',
hours_one: '{{count}} Std',
hours_other: '{{count}} Std',
days_one: '{{count}} Tg',
days_other: '{{count}} Tg',
},
};
export const main_window = {
sidebar: {
discord_active: 'Discord Rich Presence aktiv',
discord_active_friend: 'Discord Rich Presence aktiv: <0></0>',
discord_not_active: 'Discord Rich Presence nicht aktiv',
discord_playing: 'Spielt',
discord_not_connected: 'Nicht mit Discord verbunden',
add_user: 'User hinzufügen',
discord_setup: 'Discord Rich Presence einrichten',
enable_auto_refresh: 'Automatisch aktualisieren',
},
update: {
update_available: 'Update verfügbar: {{name}}',
download: 'Download',
error: 'Fehler beim Überprüfen von Updates aufgetreten: {{message}}',
retry: 'Erneut versuchen',
},
main_section: {
error: {
title: 'Fehler beim Laden der Daten',
message: 'Ein Fehler ist beim Laden der {{errors, list}} Daten aufgetreten.',
message_friends: 'freunde',
message_webservices: 'spielspezifische services',
message_event: 'sprachchat',
retry: 'Erneut versuchen',
view_details: 'Details anschauen',
},
moon_only_user: {
title: 'Nintendo Switch Online',
desc_1: 'Dieser User ist mit der Nintendo Switch-Altersbeschränkungen App angemeldet, aber nicht mit der Nintendo Switch Online App.',
desc_2: 'Logge dich mit der Nintendo Switch Online App ein, um Details einzusehen oder benutze den nxapi Befehl, um auf Altersbeschränkungen zuzugreifen.',
login: 'Login',
},
section_error: 'Fehler beim Aktualisieren der Daten',
},
discord_section: {
title: 'Discord Rich Presence',
setup_with_existing_user: 'Benutze einer dieser Account, um die Discord Rich Presence einzurichten: <0></0>.',
add_user: 'Füge einen Nintendo Switch Online Account mit diesem User als Freund hinzu, um die Discord Rich Presence einzurichten.',
active_self: 'Die Aktivität wird mit diesem User auf Discord geteilt.',
active_friend: '<0></0>\'s Aktivität wird mit diesem Account auf Discord geteilt.',
active_unknown: 'Die Aktivität eines unbekannten Users wird mit Discord geteilt.',
active_via: 'Die Aktivität wird mit diesem User auf Discord über <0></0> geteilt.',
setup: 'Einrichten',
disable: 'Deaktivieren',
},
friends_section: {
title: 'Freunde',
no_friends: 'Füge Freunde mit deiner Nintendo Switch Konsole hinzu.',
friend_code: 'Dein Freundescode: <0></0>',
presence_playing: 'Spielt',
presence_offline: 'Offline',
},
webservices_section: {
title: 'Spielspezifische Services',
},
event_section: {
title: 'Sprachchat',
members: '{{event}} im Spiel, {{voip}} im Sprachchat',
members_with_total: '{{event}} im Spiel, {{voip}} von {{total}} im Sprachkanal',
app_start: 'Benutze die Nintendo Switch Online App auf iOS oder Android, um einen Sprachchat zu starten.',
app_join: 'Benutze die Nintendo Switch Online App auf iOS oder Android, um einem Sprachchat beizutreten.',
share: 'Teilen',
},
};
export const preferences_window = {
title: 'Einstellungen',
startup: {
heading: 'Start',
login: 'Öffne beim Start',
background: 'Öffne im Hintergrund',
},
sleep: {
heading: 'Sleep',
},
discord: {
heading: 'Discord Rich Presence',
enabled: 'Discord Rich Presence ist aktiviert.',
disabled: 'Discord Rich Presence ist deaktiviert.',
setup: 'Discord Rich Presence Setup',
user: 'Discord User',
user_any: 'Zuerst gesehen',
friend_code: 'Freundescode',
friend_code_help: 'Wenn du deinen Freundescode hinzufügst, wird ebenfalls dein User Icon in Discord angezeigt.',
friend_code_self: 'Teile meinen Freundescode',
friend_code_custom: 'Eigenen Freundescode festlegen',
inactive_presence: 'Zeige inaktive Aktivität',
inactive_presence_help: 'Zeigt "Spielt nicht", wenn eine verbundene Konsole online ist, du aber nicht spielst.',
play_time: 'Spielzeit',
play_time_hidden: 'Spielzeit niemals anzeigen',
play_time_nintendo: 'Zeige Spielzeit wie sie auf der Nintendo Switch Konsole angezeigt wird',
play_time_approximate_play_time: 'Zeige ungefähre Spielzeit (nächste 5 Stunden) an',
play_time_approximate_play_time_since: 'Zeige ungefähre Spielzeit (nächste 5 Stunden) mit erstem Startdatum an',
play_time_hour_play_time: 'Zeige ungefähre Spielzeit (nächste Stunde) an',
play_time_hour_play_time_since: 'Zeige ungefähre Spielzeit (nächste Stunde) mit erstem Startdatum an',
play_time_detailed_play_time: 'Zeige exakte Spielzeit an',
play_time_detailed_play_time_since: 'Zeige exakte Spielzeit mit erstem Startdatum an',
},
splatnet3: {
heading: 'SplatNet 3',
discord: 'Aktiviere die erweiterte Discord Rich Presence für Splatoon 3',
discord_help_1: 'Benutzt SplatNet 3, um zusätzliche Informationen anzuzeigen, während du Splatoon 3 spielst. Du musst einen zweiten Nintendo Switch Account hinzufügen, welcher mit deinem Hauptaccount befreundet ist und Zugriff auf SplatNet 3 hat.',
discord_help_2: 'Wenn du eine Presence URL benutzt, werden die zusätzlichen Informationen angezeigt, ohne diese Einstellung zu berücksichtigen.',
},
};
export const friend_window = {
no_presence: 'Du hast keinen Zugriff auf die Aktivität von diesem User oder er war nie online.',
nsa_id: 'NSA ID',
coral_id: 'Coral user ID',
no_coral_user: 'Hat nie die Nintendo Switch Online App genutzt',
friends_since: 'Freunde seit: {{date, datetime}}',
presence_updated_at: 'Aktivität aktualisiert: {{date, datetime}}',
presence_logout_at: 'Zuletzt online: {{date, datetime}}',
presence_sharing: 'Dieser User kann deine Aktivität sehen.',
presence_not_sharing: 'Dieser User kann deine Aktivität nicht sehen.',
discord_presence: 'Aktivität auf Discord teilen',
close: 'Schließen',
presence_playing: 'Spielt {{game}}',
presence_offline: 'Offline',
presence_last_seen: 'Zuletzt gesehen: {{since_logout}}',
game_played_for_h: 'Gespielt für $t(hours, {"count": {{hours}}})',
game_played_for_hm: 'Gespielt für $t(hours, {"count": {{hours}}}), $t(minutes, {"count": {{minutes}}})',
game_played_for_m: 'Gespielt für $t(minutes, {"count": {{minutes}}})',
hours_one: '{{count}} Stunde',
hours_other: '{{count}} Stunden',
minutes_one: '{{count}} Minute',
minutes_other: '{{count}} Minuten',
game_first_played: 'Zuerst gespielt am {{date, datetime}}',
game_first_played_now: 'Zuerst gespielt jetzt',
game_title_id: 'Titel ID',
game_shop: 'Nintendo eShop',
};
export const addfriend_window = {
title: 'Freund hinzufügen',
help: 'Gebe oder füge einen Freundescode oder eine Freundescode-URL ein, um eine Freundschaftsanfrage zu senden.',
lookup_error: 'Fehler beim Aufrufen des Freundescodes aufgetreten: {{message}}',
nsa_id: 'NSA ID',
coral_id: 'Coral user ID',
no_coral_user: 'Hat nie die Nintendo Switch Online App genutzt',
send_added: 'Du bist nun mit diesem User befreundet.',
send_sent: 'Freundschaftsanfrage gesendet. {{user}} kann deine Freundschaftsanfrage über die Nintendo Switch Konsole annehmen oder dir eine Freundschaftsanfrage über die Nintendo Switch Online App oder nxapi senden.',
send_sending: 'Sende Freundschaftsanfrage...',
send_error: 'Fehler beim Senden der Freundschaftsanfrage aufgetreten: {{message}}',
already_friends: 'Du bist bereits mit diesem User befreundet',
close: 'Schließen',
send: 'Anfrage senden',
};
export const discordsetup_window = {
title: 'Discord Rich Presence Setup',
mode_heading: '1. Wähle den Modus aus',
mode_coral_friend: 'Wähle einen User, der mit dir befreundet ist, aus.',
mode_url: 'Gebe eine URL ein, die deine Aktivität zurückgibt.',
mode_none: 'Deaktivieren',
coral_user_heading: '2. Wähle den User aus',
coral_user_help: 'Der User muss mit dir befreundet sein, um die Aktivität zu teilen.',
coral_friend_heading: '3. Wähle einen Freund aus',
coral_friend_help: 'Das ist der User, den du teilen möchtest.',
url_heading: '2. Gebe eine Presence-URL ein',
url_help: 'Der Link muss eine HTTPS-URL sein, die ein JSON-Objekt mit einem User, Freund oder Aktivitätsschlüssel zurückgibt. Diese Funktion ist für nxapi\'s znc API Proxy vorgesehen.',
preferences_heading: 'Konfiguriere zusätzliche Optionen für die Discord Rich Presence',
preferences: 'Einstellungen',
cancel: 'Abbrechen',
save: 'Speichern',
};
export const addaccountmanual_window = {
title: 'Account hinzufügen',
authorise_heading: '1. Logge dich in deinen Nintendo Account ein.',
authorise_help: 'Wähle noch keinen Account aus.',
authorise_open: 'Öffne die Nintendo Account Authorisierung',
response_heading: '2. Gebe den Weiterleitungslink ein',
response_help_1: 'Rechtsklick auf der "Externen Account verlinken" Seite, Rechtsklick auf "Diese Person auswählen" und den Link kopieren. Der Link sollte mit "{{url}}". beginnen',
response_help_2: 'Wenn du einen Kinder-Account, welcher mit deinem Account verlinkt ist, hinzufügen möchtest, klicke auf \"Diese Person auswählen\". Wenn dann nur der Kinder-Account angezeigt wird, Rechtsklick auf \"Diese Person auswählen\" und den Link kopieren.',
cancel: 'Abbrechen',
save: 'Account hinzufügen',
};

View File

@ -1,4 +1,4 @@
import { CREDITS_NOTICE, LICENCE_NOTICE } from '../../../common/constants.js';
import { CREDITS_NOTICE, LICENCE_NOTICE, ZNCA_API_USE_TEXT } from '../../../common/constants.js';
export const app = {
default_title: 'Nintendo Switch Online',
@ -99,6 +99,41 @@ export const handle_uri = {
cancel: 'Cancel',
};
export const na_auth = {
window: {
title: 'Nintendo Account',
},
znca_api_use: {
title: 'Third-party API usage',
// This should be translated in other languages
text: ZNCA_API_USE_TEXT,
ok: 'OK',
cancel: 'Cancel',
more_information: 'More information',
},
notification_coral: {
title: 'Nintendo Switch Online',
body_existing: 'Already signed in as {{name}} (Nintendo Account {{na_name}} / {{na_username}})',
body_authenticated: 'Authenticated as {{name}} (Nintendo Account {{na_name}} / {{na_username}})',
body_reauthenticated: 'Reauthenticated to {{name}} (Nintendo Account {{na_name}} / {{na_username}})',
},
notification_moon: {
title: 'Nintendo Switch Parental Controls',
body_existing: 'Already signed in as {{na_name}} ({{na_username}})',
body_authenticated: 'Authenticated as {{na_name}} ({{na_username}})',
body_reauthenticated: 'Reauthenticated to {{na_name}} ({{na_username}})',
},
error: {
title: 'Error adding account',
},
};
export const time_since = {
default: {
now: 'just now',
@ -131,6 +166,7 @@ export const main_window = {
discord_active_friend: 'Discord Rich Presence active: <0></0>',
discord_not_active: 'Discord Rich Presence not active',
discord_playing: 'Playing',
discord_not_connected: 'Not connected to Discord',
add_user: 'Add user',
discord_setup: 'Set up Discord Rich Presence',

View File

@ -2,25 +2,25 @@ import { app, BrowserWindow, dialog, ipcMain, LoginItemSettingsOptions, Menu } f
import process from 'node:process';
import * as path from 'node:path';
import { EventEmitter } from 'node:events';
import createDebug from 'debug';
import * as persist from 'node-persist';
import { i18n } from 'i18next';
import { init as initGlobals } from '../../common/globals.js';
import MenuApp from './menu.js';
import { handleOpenWebServiceUri } from './webservices.js';
import { EmbeddedPresenceMonitor, PresenceMonitorManager } from './monitor.js';
import { createWindow } from './windows.js';
import { DiscordPresenceConfiguration, LoginItem, LoginItemOptions, WindowType } from '../common/types.js';
import { initStorage, paths } from '../../util/storage.js';
import { checkUpdates, UpdateCacheData } from '../../common/update.js';
import Users, { CoralUser } from '../../common/users.js';
import { createModalWindow, createWindow } from './windows.js';
import { sendToAllWindows, setupIpc } from './ipc.js';
import { dev, dir, git, release, version } from '../../util/product.js';
import { addUserAgent } from '../../util/useragent.js';
import { askUserForUri } 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';
import { init as initGlobals } from '../../common/globals.js';
import { CREDITS_NOTICE, GITLAB_URL, LICENCE_NOTICE } from '../../common/constants.js';
import { checkUpdates, UpdateCacheData } from '../../common/update.js';
import Users, { CoralUser } from '../../common/users.js';
import createDebug from '../../util/debug.js';
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';
const debug = createDebug('app:main');
@ -101,17 +101,7 @@ export class App {
return this.preferences_window;
}
const window = createWindow(WindowType.PREFERENCES, {}, {
show: false,
maximizable: false,
minimizable: false,
width: 580,
height: 400,
minWidth: 580,
maxWidth: 580,
minHeight: 400,
maxHeight: 400,
});
const window = createModalWindow(WindowType.PREFERENCES, {});
window.on('closed', () => this.preferences_window = null);
@ -125,7 +115,7 @@ export class App {
debug('Initialising i18n with language %s', language);
await i18n.init({lng: language ?? undefined});
await i18n.loadNamespaces(['app', 'app_menu', 'menus', 'handle_uri']);
await i18n.loadNamespaces(['app', 'app_menu', 'menus', 'handle_uri', 'na_auth']);
return i18n;
}
@ -290,19 +280,9 @@ export async function handleOpenFriendCodeUri(app: App, uri: string) {
const selected_user = await askUserForUri(app, uri, app.i18n.t('handle_uri:friend_code_select'));
if (!selected_user) return;
createWindow(WindowType.ADD_FRIEND, {
createModalWindow(WindowType.ADD_FRIEND, {
user: selected_user[1].user.id,
friendcode,
}, {
// show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
});
}

View File

@ -1,27 +1,24 @@
import { BrowserWindow, clipboard, dialog, IpcMain, KeyboardEvent, Menu, MenuItem, Settings, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
import { BrowserWindow, clipboard, dialog, IpcMain, KeyboardEvent, Menu, MenuItem, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
import * as util from 'node:util';
import createDebug from 'debug';
import { User } from 'discord-rpc';
import openWebService, { QrCodeReaderOptions, WebServiceIpc, WebServiceValidationError } from './webservices.js';
import { createWindow, getWindowConfiguration } from './windows.js';
import { DiscordPresenceConfiguration, DiscordPresenceSource, LoginItemOptions, WindowType } from '../common/types.js';
import { CurrentUser, Friend, Game, PresenceState, WebService } from '../../api/coral-types.js';
import { createModalWindow, createWindow, 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 { CurrentUser, Friend, Game, PresenceState, WebService } from '../../api/coral-types.js';
import { NintendoAccountUser } from '../../api/na.js';
import { hrduration } from '../../util/misc.js';
import createDebug from '../../util/debug.js';
import { DiscordPresence } from '../../discord/types.js';
import { getDiscordRpcClients } from '../../discord/rpc.js';
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 { EmbeddedPresenceMonitor } from './monitor.js';
const debug = createDebug('app:main:ipc');
const shown_modal_windows = new WeakSet<BrowserWindow>();
export function setupIpc(appinstance: App, ipcMain: IpcMain) {
const store = appinstance.store;
const storage = appinstance.store.storage;
@ -54,8 +51,8 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
}, 60 * 60 * 1000);
ipcMain.handle('nxapi:accounts:list', () => storage.getItem('NintendoAccountIds'));
ipcMain.handle('nxapi:accounts:add-coral', () => askAddNsoAccount(store.storage).then(u => u?.data.user.id));
ipcMain.handle('nxapi:accounts:add-moon', () => askAddPctlAccount(store.storage).then(u => u?.data.user.id));
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));
ipcMain.handle('nxapi:coral:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id));
ipcMain.handle('nxapi:coral:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token));
@ -85,61 +82,15 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
ipcMain.handle('nxapi: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) => createWindow(WindowType.FRIEND, props, {
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
}).id);
ipcMain.handle('nxapi:window:discord', (e, props: DiscordSetupProps) => createWindow(WindowType.DISCORD_PRESENCE, props, {
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
}).id);
ipcMain.handle('nxapi:window:addfriend', (e, props: AddFriendProps) => createWindow(WindowType.ADD_FRIEND, props, {
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
}).id);
ipcMain.handle('nxapi:window:showfriend', (e, props: FriendProps) =>
createModalWindow(WindowType.FRIEND, props, e.sender).id);
ipcMain.handle('nxapi:window:discord', (e, props: DiscordSetupProps) =>
createModalWindow(WindowType.DISCORD_PRESENCE, props).id);
ipcMain.handle('nxapi:window:addfriend', (e, props: AddFriendProps) =>
createModalWindow(WindowType.ADD_FRIEND, props, e.sender).id);
ipcMain.handle('nxapi:window:setheight', (e, height: number) => {
const window = BrowserWindow.fromWebContents(e.sender)!;
const [curWidth, curHeight] = window.getSize();
const [curContentWidth, curContentHeight] = window.getContentSize();
const [minWidth, minHeight] = window.getMinimumSize();
const [maxWidth, maxHeight] = window.getMaximumSize();
if (height !== curContentHeight && curHeight === minHeight && curHeight === maxHeight) {
window.setMinimumSize(minWidth, height + (curHeight - curContentHeight));
window.setMaximumSize(maxWidth, height + (curHeight - curContentHeight));
}
window.setContentSize(curContentWidth, height);
if (!shown_modal_windows.has(window)) {
window.show();
shown_modal_windows.add(window);
}
setWindowHeight(window, height);
});
ipcMain.handle('nxapi:discord:config', () => appinstance.monitors.getDiscordPresenceConfiguration());
@ -155,8 +106,12 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
const users: User[] = [];
for (const client of await getDiscordRpcClients()) {
await client.connect(defaultTitle.client);
if (client.user && !users.find(u => u.id === client.user!.id)) users.push(client.user);
try {
await client.connect(defaultTitle.client);
if (client.user && !users.find(u => u.id === client.user!.id)) users.push(client.user);
} finally {
await client.destroy();
}
}
return users;
@ -175,10 +130,10 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
ipcMain.handle('nxapi:menu:add-user', e => (Menu.buildFromTemplate([
new MenuItem({label: t('add_account.add_account_coral')!, click:
(item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddNsoAccount(storage, !event.shiftKey)}),
askAddNsoAccount(appinstance, !event.shiftKey)}),
new MenuItem({label: t('add_account.add_account_moon')!, click:
(item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddPctlAccount(storage, !event.shiftKey)}),
askAddPctlAccount(appinstance, !event.shiftKey)}),
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:friend-code', (e, fc: CurrentUser['links']['friendCode']) => (Menu.buildFromTemplate([
new MenuItem({label: 'SW-' + fc.id, enabled: false}),
@ -252,21 +207,9 @@ function buildUserMenu(app: App, user: NintendoAccountUser, nso?: CurrentUser, m
click: () => app.menu?.setActiveDiscordPresenceUser(null)}),
] : [
new MenuItem({label: t('discord_enable')!,
click: () => createWindow(WindowType.DISCORD_PRESENCE, {
click: () => createModalWindow(WindowType.DISCORD_PRESENCE, {
friend_nsa_id: nso.nsaId,
}, {
parent: window,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
})}),
}, window)}),
]),
new MenuItem({label: t('friend_notifications_enable')!, type: 'checkbox',
checked: monitor?.friend_notifications,
@ -275,21 +218,9 @@ function buildUserMenu(app: App, user: NintendoAccountUser, nso?: CurrentUser, m
click: () => monitor?.skipIntervalInCurrentLoop(true)}),
new MenuItem({type: 'separator'}),
new MenuItem({label: t('add_friend')!,
click: () => createWindow(WindowType.ADD_FRIEND, {
click: () => createModalWindow(WindowType.ADD_FRIEND, {
user: user.id,
}, {
parent: window,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
})}),
}, window)}),
] : []),
new MenuItem({type: 'separator'}),
new MenuItem({label: t('remove_help')!, enabled: false}),

View File

@ -1,23 +1,25 @@
import { app, dialog, Menu, Tray, nativeImage, MenuItem, BrowserWindow, KeyboardEvent } from './electron.js';
import path from 'node:path';
import * as util from 'node:util';
import createDebug from 'debug';
import { askAddNsoAccount, askAddPctlAccount } from './na-auth.js';
import { App } from './index.js';
import { WebService } from '../../api/coral-types.js';
import openWebService, { WebServiceValidationError } from './webservices.js';
import { SavedToken } from '../../common/auth/coral.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import { dev, dir } from '../../util/product.js';
import { EmbeddedPresenceMonitor, EmbeddedProxyPresenceMonitor } from './monitor.js';
import { createWindow } from './windows.js';
import { createModalWindow, createWindow } from './windows.js';
import { WindowType } from '../common/types.js';
import CoralApi 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 { languages } from '../i18n/index.js';
const debug = createDebug('app:main:menu');
const show_force_language_menu = dev || git?.branch?.match(/^(i18n$|trans-)/);
export default class MenuApp {
tray: Tray;
@ -121,7 +123,7 @@ export default class MenuApp {
menu.append(new MenuItem({type: 'separator'}));
menu.append(new MenuItem({label: t('show_main_window')!, click: () => this.app.showMainWindow()}));
menu.append(new MenuItem({label: t('preferences')!, click: () => this.app.showPreferencesWindow()}));
if (dev) menu.append(new MenuItem({label: 'Language', submenu: Menu.buildFromTemplate([
if (show_force_language_menu) menu.append(new MenuItem({label: 'Language', submenu: Menu.buildFromTemplate([
...this.app.i18n.options.supportedLngs || ['cimode'],
].map(l => new MenuItem({
label: languages[l as keyof typeof languages]?.name ?? l,
@ -139,9 +141,9 @@ export default class MenuApp {
}
addNsoAccount = (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddNsoAccount(this.app.store.storage, !event.shiftKey);
askAddNsoAccount(this.app, !event.shiftKey);
addPctlAccount = (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddPctlAccount(this.app.store.storage, !event.shiftKey);
askAddPctlAccount(this.app, !event.shiftKey);
protected webservices = new Map</** language */ string, WebService[]>();
@ -300,18 +302,8 @@ export default class MenuApp {
}
showAddFriendWindow(user: string) {
createWindow(WindowType.ADD_FRIEND, {
createModalWindow(WindowType.ADD_FRIEND, {
user,
}, {
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
});
}
}

View File

@ -1,14 +1,14 @@
import { dialog, Notification } from './electron.js';
import createDebug from 'debug';
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 { ErrorResponse } from '../../api/util.js';
import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../../common/presence.js';
import { NotificationManager } from '../../common/notify.js';
import createDebug from '../../util/debug.js';
import { LoopResult } from '../../util/loop.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { App } from './index.js';
import { DiscordPresenceConfiguration, DiscordPresenceExternalMonitorsConfiguration, DiscordPresenceSource } from '../common/types.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';
@ -45,6 +45,7 @@ export class PresenceMonitorManager {
i.presence_user = null;
i.user_notifications = false;
i.friend_notifications = false;
i.discord_preconnect = true;
i.discord.onUpdateActivity = (presence: DiscordPresence | null) => {
this.app.store.emit('update-discord-presence', presence ? {...presence, config: undefined} : null);
@ -91,6 +92,7 @@ export class PresenceMonitorManager {
const i = new EmbeddedProxyPresenceMonitor(presence_url);
i.notifications = this.notifications;
i.discord_preconnect = true;
i.discord.onUpdateActivity = (presence: DiscordPresence | null) => {
this.app.store.emit('update-discord-presence', presence ? {...presence, config: undefined} : null);

View File

@ -1,19 +1,20 @@
import { app, BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell } from './electron.js';
import process from 'node:process';
import * as crypto from 'node:crypto';
import createDebug from 'debug';
import * as persist from 'node-persist';
import { app, BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell } from './electron.js';
import { getNintendoAccountSessionToken, NintendoAccountSessionToken } from '../../api/na.js';
import { App, protocol_registration_options } from './index.js';
import { createModalWindow, createWindow } 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 { getToken, SavedToken } from '../../common/auth/coral.js';
import { getPctlToken, SavedMoonToken } from '../../common/auth/moon.js';
import { ErrorResponse } from '../../api/util.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 { tryGetNativeImageFromUrl } from './util.js';
import { ZNCA_API_USE_URL } from '../../common/constants.js';
import { createWindow } from './windows.js';
import { WindowType } from '../common/types.js';
import { protocol_registration_options } from './index.js';
import { ZNCA_API_USE_TEXT, ZNCA_API_USE_URL } from '../../common/constants.js';
const debug = createDebug('app:main:na-auth');
@ -54,7 +55,7 @@ html {
let i = 0;
export function createAuthWindow() {
export function createAuthWindow(app: App) {
const browser_session = session.defaultSession;
const window = new BrowserWindow({
@ -63,7 +64,7 @@ export function createAuthWindow() {
resizable: false,
maximizable: false,
fullscreenable: false,
title: 'Nintendo Account',
title: app.i18n.t('na_auth:window.title') ?? 'Nintendo Account',
webPreferences: {
session: browser_session,
scrollBounce: true,
@ -100,16 +101,22 @@ export class AuthoriseCancelError extends AuthoriseError {
}
}
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window: false):
Promise<NintendoAccountSessionTokenCode & {window: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window: true):
Promise<NintendoAccountSessionTokenCode & {window?: never}>
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window?: boolean):
Promise<NintendoAccountSessionTokenCode & {window?: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window = true) {
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window: false,
): Promise<NintendoAccountSessionTokenCode & {window: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window: true,
): Promise<NintendoAccountSessionTokenCode & {window?: never}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window?: boolean,
): Promise<NintendoAccountSessionTokenCode & {window?: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window = true,
) {
return new Promise<NintendoAccountSessionTokenCode>((rs, rj) => {
const {url: authoriseurl, state, verifier, challenge} = getAuthUrl(client_id, scope);
const window = createAuthWindow();
const window = createAuthWindow(app);
const handleAuthUrl = (url: URL) => {
const authorisedparams = new URLSearchParams(url.hash.substr(1));
@ -312,19 +319,9 @@ function askUserForRedirectUri(
authoriseurl: string, client_id: string,
handleAuthUrl: (url: URL) => void, rj: (reason: any) => void
) {
const window = createWindow(WindowType.ADD_ACCOUNT_MANUAL_PROMPT, {
const window = createModalWindow(WindowType.ADD_ACCOUNT_MANUAL_PROMPT, {
authoriseurl,
client_id,
}, {
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
});
window.webContents.on('will-navigate', (event, url_string) => {
@ -354,9 +351,9 @@ const NSO_SCOPE = [
'user.screenName',
];
export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_browser = true) {
export async function addNsoAccount(app: App, use_in_app_browser = true) {
const {code, verifier, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false) :
await getSessionTokenCodeByInAppBrowser(app, ZNCA_CLIENT_ID, NSO_SCOPE, false) :
await getSessionTokenCodeByDefaultBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false);
window?.setFocusable(false);
@ -365,101 +362,131 @@ export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_br
try {
const [jwt, sig] = Jwt.decode(code);
const nsotoken = await storage.getItem('NintendoAccountToken.' + jwt.payload.sub) as string | undefined;
const nsotoken = await app.store.storage.getItem('NintendoAccountToken.' + jwt.payload.sub) as string | undefined;
if (nsotoken) {
const data = await storage.getItem('NsoToken.' + nsotoken) as SavedToken | undefined;
debug('Already authenticated', jwt.payload);
debug('Already authenticated', data);
try {
const {nso, data} = await getToken(app.store.storage, nsotoken, process.env.ZNC_PROXY_URL, false);
new Notification({
title: 'Nintendo Switch Online',
body: 'Already signed in as ' + data?.nsoAccount.user.name + ' (' + data?.user.nickname + ')',
icon: await tryGetNativeImageFromUrl(data!.nsoAccount.user.imageUri),
}).show();
new Notification({
title: app.i18n.t('na_auth:notification_coral.title') ?? 'Nintendo Switch Online',
body: app.i18n.t('na_auth:notification_coral.body_existing', {
name: data.nsoAccount.user.name,
na_name: data.user.nickname,
na_username: data.user.screenName,
}) ?? 'Already signed in as ' + data.nsoAccount.user.name + ' (Nintendo Account ' +
data.user.nickname + ' / ' + data.user.screenName + ')',
icon: await tryGetNativeImageFromUrl(data.nsoAccount.user.imageUri),
}).show();
return getToken(storage, nsotoken, process.env.ZNC_PROXY_URL, false);
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);
}
}
throw err;
}
}
await checkZncaApiUseAllowed(storage, window);
await checkZncaApiUseAllowed(app, window);
const token = await getNintendoAccountSessionToken(code, verifier, ZNCA_CLIENT_ID);
debug('session token', token);
const {nso, data} = await getToken(storage, token.session_token, process.env.ZNC_PROXY_URL, false);
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await storage.setItem('NintendoAccountIds', [...users]);
new Notification({
title: 'Nintendo Switch Online',
body: 'Authenticated as ' + data.nsoAccount.user.name + ' (NSO ' + data.user.nickname + ')',
icon: await tryGetNativeImageFromUrl(data.nsoAccount.user.imageUri),
}).show();
return {nso, data};
return authenticateCoralSessionToken(app, code, verifier);
} finally {
window?.close();
}
}
export async function askAddNsoAccount(storage: persist.LocalStorage, iab = true) {
async function authenticateCoralSessionToken(
app: App,
code: string, verifier: string,
reauthenticate = false,
) {
const token = await getNintendoAccountSessionToken(code, verifier, ZNCA_CLIENT_ID);
debug('session token', token);
const {nso, data} = await getToken(app.store.storage, token.session_token, process.env.ZNC_PROXY_URL, false);
const users = new Set(await app.store.storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await app.store.storage.setItem('NintendoAccountIds', [...users]);
new Notification({
title: app.i18n.t('na_auth:notification_coral.title') ?? 'Nintendo Switch Online',
body: app.i18n.t('na_auth:notification_coral.body_' + (reauthenticate ? 're' : '') + 'authenticated', {
name: data.nsoAccount.user.name,
na_name: data.user.nickname,
na_username: data.user.screenName,
}) ?? (reauthenticate ?
'Reauthenticated to ' + data.nsoAccount.user.name + ' (Nintendo Account ' + data.user.nickname + ' / ' +
data.user.screenName + ')' :
'Authenticated as ' + data.nsoAccount.user.name + ' (Nintendo Account ' + data.user.nickname + ' / ' +
data.user.screenName + ')'),
icon: await tryGetNativeImageFromUrl(data.nsoAccount.user.imageUri),
}).show();
return {nso, data};
}
export async function askAddNsoAccount(app: App, iab = true) {
try {
return await addNsoAccount(storage, iab);
return await addNsoAccount(app, iab);
} catch (err: any) {
if (err instanceof AuthoriseError && err.code === 'access_denied') return;
dialog.showErrorBox('Error adding account', err.stack || err.message);
dialog.showErrorBox(app.i18n.t('na_auth:error.title') ?? 'Error adding account',
err.stack || err.message);
}
}
async function checkZncaApiUseAllowed(storage: persist.LocalStorage, window?: BrowserWindow, force = false) {
async function checkZncaApiUseAllowed(app: App, window?: BrowserWindow, force = false) {
if (!force) {
if (await storage.getItem('ZncaApiConsent')) {
if (await app.store.storage.getItem('ZncaApiConsent')) {
return;
}
if (process.env.ZNC_PROXY_URL) {
debug('Skipping znca API consent; znc proxy URL set');
await storage.setItem('ZncaApiConsent', true);
await app.store.storage.setItem('ZncaApiConsent', true);
return;
}
const ids: string[] | undefined = await storage.getItem('NintendoAccountIds');
const ids: string[] | undefined = await app.store.storage.getItem('NintendoAccountIds');
for (const id of ids ?? []) {
const nsotoken: string | undefined = await storage.getItem('NintendoAccountToken.' + id);
const nsotoken: string | undefined = await app.store.storage.getItem('NintendoAccountToken.' + id);
if (!nsotoken) continue;
debug('Skipping znca API consent; Nintendo Switch Online account already linked');
await storage.setItem('ZncaApiConsent', true);
await app.store.storage.setItem('ZncaApiConsent', true);
return;
}
}
if (await askZncaApiUseAllowed(window)) {
await storage.setItem('ZncaApiConsent', true);
if (await askZncaApiUseAllowed(app, window)) {
await app.store.storage.setItem('ZncaApiConsent', true);
} else {
throw new Error('Cannot continue without third-party APIs allowed');
}
}
const ZNCA_API_USE_TEXT = `To access the Nintendo Switch Online app API, nxapi must send some data to third-party APIs. This is required to generate some data to make Nintendo think you\'re using the real Nintendo Switch Online app.
By default, this uses the imink API, but another service can be used by setting an environment variable. The default API may change without notice if you do not force use of a specific service.
The data sent includes:
- When authenticating to the Nintendo Switch Online app: a Nintendo Account ID token, containing your Nintendo Account ID and country, which is valid for 15 minutes
- When authenticating to game-specific services: a Coral (Nintendo Switch Online app) ID token, containing your Coral user ID, Nintendo Switch Online membership status, and Nintendo Account child restriction status, which is valid for 2 hours`;
async function askZncaApiUseAllowed(window?: BrowserWindow): Promise<boolean> {
async function askZncaApiUseAllowed(app?: App, window?: BrowserWindow): Promise<boolean> {
const options: MessageBoxOptions = {
message: 'Third-party API usage',
detail: ZNCA_API_USE_TEXT,
buttons: ['OK', 'Cancel', 'More information'],
message: app?.i18n.t('na_auth:znca_api_use.title') ?? 'Third-party API usage',
detail: app?.i18n.t('na_auth:znca_api_use.text') ?? ZNCA_API_USE_TEXT,
buttons: [
app?.i18n.t('na_auth:znca_api_use.ok') ?? 'OK',
app?.i18n.t('na_auth:znca_api_use.cancel') ?? 'Cancel',
app?.i18n.t('na_auth:znca_api_use.more_information') ?? 'More information',
],
cancelId: 1,
};
@ -471,7 +498,7 @@ async function askZncaApiUseAllowed(window?: BrowserWindow): Promise<boolean> {
if (result.response === 2) {
shell.openExternal(ZNCA_API_USE_URL);
return askZncaApiUseAllowed(window);
return askZncaApiUseAllowed(app, window);
}
return result.response === 0;
@ -493,9 +520,9 @@ const MOON_SCOPE = [
'moonMonthlySummary',
];
export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_browser = true) {
export async function addPctlAccount(app: App, use_in_app_browser = true) {
const {code, verifier, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false) :
await getSessionTokenCodeByInAppBrowser(app, ZNMA_CLIENT_ID, MOON_SCOPE, false) :
await getSessionTokenCodeByDefaultBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false);
window?.setFocusable(false);
@ -504,48 +531,78 @@ export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_b
try {
const [jwt, sig] = Jwt.decode(code);
const moontoken = await storage.getItem('NintendoAccountToken-pctl.' + jwt.payload.sub) as string | undefined;
const moontoken = await app.store.storage.getItem('NintendoAccountToken-pctl.' + jwt.payload.sub) as string | undefined;
if (moontoken) {
const data = await storage.getItem('MoonToken.' + moontoken) as SavedMoonToken | undefined;
debug('Already authenticated', jwt.payload);
debug('Already authenticated', data);
try {
const {moon, data} = await getPctlToken(app.store.storage, moontoken, false);
new Notification({
title: 'Nintendo Switch Parental Controls',
body: 'Already signed in as ' + data?.user.nickname,
}).show();
new Notification({
title: app.i18n.t('na_auth:notification_moon.title') ?? 'Nintendo Switch Parental Controls',
body: app.i18n.t('na_auth:notification_moon.body_existing', {
na_name: data.user.nickname,
na_username: data.user.screenName,
}) ?? 'Already signed in as ' + data.user.nickname + ' (' + data.user.screenName + ')',
}).show();
return getPctlToken(storage, moontoken, false);
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);
}
}
throw err;
}
}
const token = await getNintendoAccountSessionToken(code, verifier, ZNMA_CLIENT_ID);
debug('session token', token);
const {moon, data} = await getPctlToken(storage, token.session_token, false);
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await storage.setItem('NintendoAccountIds', [...users]);
new Notification({
title: 'Nintendo Switch Parental Controls',
body: 'Authenticated as ' + data.user.nickname,
}).show();
return {moon, data};
return authenticateMoonSessionToken(app, code, verifier);
} finally {
window?.close();
}
}
export async function askAddPctlAccount(storage: persist.LocalStorage, iab = true) {
async function authenticateMoonSessionToken(
app: App,
code: string, verifier: string,
reauthenticate = false,
) {
const token = await getNintendoAccountSessionToken(code, verifier, ZNMA_CLIENT_ID);
debug('session token', token);
const {moon, data} = await getPctlToken(app.store.storage, token.session_token, false);
const users = new Set(await app.store.storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await app.store.storage.setItem('NintendoAccountIds', [...users]);
new Notification({
title: app.i18n.t('na_auth:notification_moon.title') ?? 'Nintendo Switch Parental Controls',
body: app.i18n.t('na_auth:notification_moon.body_' + (reauthenticate ? 're' : '') + 'authenticated', {
na_name: data.user.nickname,
na_username: data.user.screenName,
}) ?? (reauthenticate ?
'Reauthenticated to ' + data.user.nickname + ' (' + data.user.screenName + ')' :
'Authenticated as ' + data.user.nickname + ' (' + data.user.screenName + ')'),
}).show();
return {moon, data};
}
export async function askAddPctlAccount(app: App, iab = true) {
try {
return await addPctlAccount(storage, iab);
return await addPctlAccount(app, iab);
} catch (err: any) {
if (err instanceof AuthoriseError && err.code === 'access_denied') return;
dialog.showErrorBox('Error adding account', err.stack || err.message);
dialog.showErrorBox(app.i18n.t('na_auth:error.title') ?? 'Error adding account',
err.stack || err.message);
}
}

View File

@ -1,19 +1,19 @@
import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, nativeImage, nativeTheme, Notification, ShareMenu, shell, WebContents } from './electron.js';
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 createDebug from 'debug';
import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, nativeImage, nativeTheme, Notification, ShareMenu, shell, WebContents } from './electron.js';
import fetch from 'node-fetch';
import CoralApi from '../../api/coral.js';
import { CurrentUser, WebService, WebServiceToken } from '../../api/coral-types.js';
import { App, Store } from './index.js';
import type { DownloadImagesRequest, NativeShareRequest, NativeShareUrlRequest, QrCodeReaderCameraOptions, QrCodeReaderCheckinOptions, QrCodeReaderCheckinResult, QrCodeReaderPhotoLibraryOptions, SendMessageOptions } from '../preload-webservice/znca-js-api.js';
import { SavedToken } from '../../common/auth/coral.js';
import { createWebServiceWindow } from './windows.js';
import { askUserForUri } 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 { SavedToken } from '../../common/auth/coral.js';
const debug = createDebug('app:main:webservices');
@ -68,6 +68,8 @@ export default async function openWebService(
if (!isWebServiceUrlAllowed(webservice, url)) {
debug('Web service attempted to navigate to a URL not allowed by it\'s `whiteList`', webservice, url);
debug('open', url);
shell.openExternal(url);
event.preventDefault();
}
});

View File

@ -10,7 +10,7 @@ const windows = new WeakMap<WebContents, WindowConfiguration>();
export function createWindow<T extends WindowType>(
type: T, props: WindowConfiguration<T>['props'],
options?: BrowserWindowConstructorOptions
options?: BrowserWindowConstructorOptions,
) {
// Create the browser window
const window = new BrowserWindow({
@ -51,6 +51,62 @@ export function getWindowConfiguration(webcontents: WebContents): WindowConfigur
return data;
}
const modal_window_width = new WeakMap<BrowserWindow, number>();
const modal_window_shown = new WeakSet<BrowserWindow>();
export function createModalWindow<T extends WindowType>(
type: T, props: WindowConfiguration<T>['props'],
parent?: BrowserWindow | WebContents,
options?: BrowserWindowConstructorOptions,
) {
if (parent && !(parent instanceof BrowserWindow)) {
parent = BrowserWindow.fromWebContents(parent) ?? undefined;
}
const window = createWindow(type, props, {
parent,
modal: !!parent,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
...options,
});
if (process.platform === 'win32') {
// Use a fixed window width on Windows due to a bug getting/setting window size
window.setResizable(false);
modal_window_width.set(window, options?.width ?? 560);
}
return window;
}
export function setWindowHeight(window: BrowserWindow, height: number) {
const [curWidth, curHeight] = window.getSize();
const [curContentWidth, curContentHeight] = window.getContentSize();
const [minWidth, minHeight] = window.getMinimumSize();
const [maxWidth, maxHeight] = window.getMaximumSize();
if (height !== curContentHeight && curHeight === minHeight && curHeight === maxHeight) {
window.setMinimumSize(minWidth, height + (curHeight - curContentHeight));
window.setMaximumSize(maxWidth, height + (curHeight - curContentHeight));
}
window.setContentSize(modal_window_width.get(window) ?? curContentWidth, height);
if (!modal_window_shown.has(window)) {
window.show();
modal_window_shown.add(window);
}
}
const BACKGROUND_COLOUR_MAIN_LIGHT = process.platform === 'win32' ? '#ffffff' : '#ececec';
const BACKGROUND_COLOUR_MAIN_DARK = process.platform === 'win32' ? '#000000' : '#252424';

View File

@ -1,9 +1,13 @@
import createDebug from 'debug';
import { join } from 'node:path';
import { init as initDebug } from './util/debug.js';
import { paths } from './util/product.js';
//
// cli entrypoint for Rollup bundle
// cli entrypoint
//
createDebug.log = console.warn.bind(console);
if (process.env.NXAPI_DEBUG_FILE !== '0') {
await initDebug(join(paths.log, 'cli'));
}
import('./cli.js').then(cli => cli.main.call(null));

View File

@ -1,8 +1,8 @@
import process from 'node:process';
import createDebug from 'debug';
import Yargs from 'yargs';
import * as commands from './cli/index.js';
import { checkUpdates } from './common/update.js';
import createDebug from './util/debug.js';
import { dev } from './util/product.js';
import { paths } from './util/storage.js';
import { YargsArguments } from './util/yargs.js';

View File

@ -1,5 +1,5 @@
import process from 'node:process';
import createDebug from 'debug';
import createDebug from '../util/debug.js';
import type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';

View File

@ -2,8 +2,8 @@ import process from 'node:process';
import { createRequire } from 'node:module';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../cli.js';
import createDebug from '../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
import { dir } from '../util/product.js';

View File

@ -1,6 +1,6 @@
import process from 'node:process';
import createDebug from 'debug';
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';

View File

@ -1,8 +1,8 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../nooklink.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';

View File

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

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nooklink.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';

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nooklink.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';

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nooklink.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nooklink.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';

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nooklink.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nooklink.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nooklink.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';

View File

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

View File

@ -1,6 +1,6 @@
import process from 'node:process';
import createDebug from 'debug';
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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,6 +1,6 @@
import * as crypto from 'node:crypto';
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.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';

View File

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

View File

@ -1,24 +1,24 @@
import * as net from 'node:net';
import * as os from 'node:os';
import createDebug from 'debug';
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 { Announcement, CoralStatus, CurrentUser, Friend, FriendCodeUrl, FriendCodeUser, Presence } from '../../api/coral-types.js';
import CoralApi from '../../api/coral.js';
import type { Arguments as ParentArguments } from '../nso.js';
import CoralApi 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 createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { SavedToken } from '../../common/auth/coral.js';
import { NotificationManager, PresenceEvent, ZncNotifications } from '../../common/notify.js';
import { product } from '../../util/product.js';
import { parseListenAddress } from '../../util/net.js';
import { AuthPolicy, AuthToken, ZncPresenceEventStreamEvent } from '../../api/znc-proxy.js';
import { addCliFeatureUserAgent } from '../../util/useragent.js';
import { ErrorResponse } from '../../api/util.js';
import Users, { CoralUser } from '../../common/users.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';
declare global {
namespace Express {
@ -755,10 +755,7 @@ class Server extends HttpServer {
this.resetAuthTimeout(na_session_token, () => user.data.user.id);
}
} catch (err) {
stream.sendEvent('error', {
error: (err as Error).name,
error_message: (err as Error).message,
});
stream.sendErrorEvent(err);
}
}
}

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,7 +1,7 @@
import * as path from 'node:path';
import createDebug from 'debug';
import persist from 'node-persist';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import { PresencePermissions } from '../../api/coral-types.js';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,9 +1,9 @@
import createDebug from 'debug';
import fetch from 'node-fetch';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../nso.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
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';

View File

@ -1,6 +1,6 @@
import * as crypto from 'node:crypto';
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../pctl.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';

View File

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

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../pctl.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';

View File

@ -1,8 +1,8 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../pctl.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';

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../pctl.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';

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../pctl.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../pctl.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';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../pctl.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';

View File

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

View File

@ -1,24 +1,27 @@
import * as net from 'node:net';
import * as os from 'node:os';
import createDebug from 'debug';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import express, { Request, Response } from 'express';
import fetch from 'node-fetch';
import * as persist from 'node-persist';
import { BankaraMatchMode, BankaraMatchSetting_schedule, CoopSetting_schedule, DetailVotingStatusResult, FestMatchSetting_schedule, FestState, FestTeam_schedule, FestTeam_votingStatus, FestVoteState, Fest_schedule, FriendListResult, FriendOnlineState, Friend_friendList, GraphQLSuccessResponse, LeagueMatchSetting_schedule, RegularMatchSetting_schedule, StageScheduleResult, VsMode, XMatchSetting_schedule } from 'splatnet3-types/splatnet3';
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 type { Arguments as ParentArguments } from '../cli.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
import { initStorage } from '../util/storage.js';
import { addCliFeatureUserAgent, getUserAgent } from '../util/useragent.js';
import { parseListenAddress } from '../util/net.js';
import { 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';
import { ErrorResponse, ResponseSymbol } from '../api/util.js';
import { getBulletToken, SavedBulletToken } from '../common/auth/splatnet3.js';
import SplatNet3Api from '../api/splatnet3.js';
import { ErrorResponse } from '../api/util.js';
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 { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
import { getTitleIdFromEcUrl } from '../util/misc.js';
import StageScheduleQuery_730cd98 from 'splatnet3-types/graphql/730cd98e84f1030d3e9ac86b6f1aae13';
const debug = createDebug('cli:presence-server');
const debugSplatnet3Proxy = createDebug('cli:presence-server:splatnet3-proxy');
@ -47,6 +50,21 @@ interface TitleResult {
since: string;
}
interface FestVotingStatusRecord {
result: Exclude<DetailVotingStatusResult['fest'], null>;
query: KnownRequestId;
app_version: string;
be_version: string | null;
friends: {
result: FriendListResult['friends'];
query: KnownRequestId;
be_version: string | null;
};
fest: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null;
}
export const command = 'presence-server';
export const desc = 'Starts a HTTP server to fetch presence data from Coral and SplatNet 3';
@ -78,6 +96,14 @@ export function builder(yargs: Argv<ParentArguments>) {
describe: 'SplatNet 3 proxy URL',
type: 'string',
default: process.env.NXAPI_PRESENCE_SERVER_SPLATNET3_PROXY_URL,
}).option('splatnet3-fest-votes', {
describe: 'Record Splatoon 3 fest vote history',
type: 'boolean',
default: false,
}).option('splatnet3-record-fest-votes', {
describe: 'Record Splatoon 3 fest vote history',
type: 'boolean',
default: false,
}).option('update-interval', {
describe: 'Max. update interval in seconds',
type: 'number',
@ -91,6 +117,8 @@ export function builder(yargs: Argv<ParentArguments>) {
type Arguments = YargsArguments<ReturnType<typeof builder>>;
const ResourceUrlMapSymbol = Symbol('ResourceUrls');
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
addCliFeatureUserAgent('presence-server');
@ -113,10 +141,23 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
SplatNet3ApiUser.create(storage, token, argv.zncProxyUrl);
}) : null;
const server = new Server(storage, coral_users, splatnet3_users, user_naids);
const image_proxy_path = {
baas: path.join(argv.dataPath, 'presence-server-resources', 'baas'),
atum: path.join(argv.dataPath, 'presence-server-resources', 'atum'),
splatnet3: path.join(argv.dataPath, 'presence-server-resources', 'splatnet3'),
};
const server = new Server(storage, coral_users, splatnet3_users, user_naids, image_proxy_path);
server.allow_all_users = argv.allowAllUsers;
server.enable_splatnet3_proxy = argv.splatnet3Proxy;
server.record_fest_votes = argv.splatnet3FestVotes || argv.splatnet3RecordFestVotes ? {
path: path.join(argv.dataPath, 'presence-server'),
read: argv.splatnet3FestVotes,
write: argv.splatnet3RecordFestVotes,
} : null;
server.update_interval = argv.updateInterval * 1000;
const app = server.app;
for (const address of argv.listen) {
@ -127,13 +168,62 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
console.log('Listening on %s, port %d', address.address, address.port);
});
}
if (argv.splatnet3RecordFestVotes) {
const update_interval_fest_voting_status_record = 60 * 60 * 1000; // 60 minutes
const recordFestVotes = async (is_force_early = false) => {
const users = await Promise.all(user_naids.map(id => server.getSplatNet3User(id)));
debug('Checking for new fest votes to record', is_force_early);
let fest_ending: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null = null;
for (const user of users) {
try {
const fest = await user.getCurrentFest();
if (is_force_early) user.updated.fest_vote_status = null;
// Fetching current fest vote data will record any new data
await user.getCurrentFestVotes();
if (fest && (!fest_ending ||
new Date(fest_ending.endTime).getTime() > new Date(fest.endTime).getTime()
)) {
fest_ending = fest;
}
} catch (err) {
debug('Error fetching current fest voting status for recording');
}
}
const time_until_fest_ends_ms = fest_ending ? new Date(fest_ending.endTime).getTime() - Date.now() : null;
const update_interval = time_until_fest_ends_ms && time_until_fest_ends_ms > 60 * 1000 ?
Math.min(time_until_fest_ends_ms - 60 * 1000, update_interval_fest_voting_status_record) :
update_interval_fest_voting_status_record;
setTimeout(() => recordFestVotes(update_interval !== update_interval_fest_voting_status_record),
update_interval);
};
recordFestVotes();
}
}
abstract class SplatNet3User {
created_at = Date.now();
expires_at = Infinity;
record_fest_votes: {
path: string;
read: boolean;
write: boolean;
} | null = null;
schedules: GraphQLSuccessResponse<StageScheduleResult> | null = null;
fest_records: GraphQLSuccessResponse<FestRecordResult> | null = null;
current_fest: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null = null;
fest_vote_status: GraphQLSuccessResponse<DetailVotingStatusResult> | null = null;
promise = new Map<string, Promise<void>>();
@ -141,6 +231,8 @@ abstract class SplatNet3User {
updated = {
friends: Date.now(),
schedules: null as number | null,
fest_records: null as number | null,
current_fest: null as number | null,
fest_vote_status: null as number | null,
};
update_interval = 10 * 1000; // 10 seconds
@ -199,15 +291,55 @@ abstract class SplatNet3User {
abstract getSchedulesData(): Promise<GraphQLSuccessResponse<StageScheduleResult>>;
async getCurrentFest(): Promise<StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null> {
let update_interval = this.update_interval_schedules;
if (this.schedules && this.schedules.data.currentFest) {
const tricolour_open = new Date(this.schedules.data.currentFest.midtermTime).getTime() <= Date.now();
const should_refresh_fest = tricolour_open &&
![FestState.SECOND_HALF, FestState.CLOSED].includes(this.schedules.data.currentFest.state as FestState);
if (should_refresh_fest) update_interval = this.update_interval;
}
await this.update('current_fest', async () => {
this.current_fest = await this.getCurrentFestData();
}, update_interval);
return this.current_fest;
}
abstract getCurrentFestData(): Promise<StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null>;
async getCurrentFestVotes(): Promise<DetailVotingStatusResult['fest'] | null> {
await this.update('fest_vote_status', async () => {
this.fest_vote_status = await this.getCurrentFestVotingStatusData();
const fest_vote_status = await this.getCurrentFestVotingStatusData();
if (fest_vote_status) this.tryRecordFestVotes(fest_vote_status);
this.fest_vote_status = fest_vote_status;
}, this.update_interval_fest_voting_status ?? this.update_interval);
return this.fest_vote_status?.data.fest ?? null;
}
abstract getCurrentFestVotingStatusData(): Promise<GraphQLSuccessResponse<DetailVotingStatusResult> | null>;
async tryRecordFestVotes(fest_vote_status: GraphQLSuccessResponse<DetailVotingStatusResult>) {
if (this.record_fest_votes?.write && fest_vote_status.data.fest &&
JSON.stringify(fest_vote_status?.data) !== JSON.stringify(this.fest_vote_status?.data)
) {
try {
await this.recordFestVotes(fest_vote_status as PersistedQueryResult<DetailVotingStatusResult>);
} catch (err) {
debug('Error recording updated fest vote data', fest_vote_status.data.fest.id, err);
}
}
}
async recordFestVotes(fest_vote_status: PersistedQueryResult<DetailVotingStatusResult>) {
throw new Error('Cannot record fest vote status when using SplatNet 3 API proxy');
}
}
class SplatNet3ApiUser extends SplatNet3User {
@ -227,10 +359,36 @@ class SplatNet3ApiUser extends SplatNet3User {
return this.splatnet.getSchedules();
}
async getCurrentFestVotingStatusData() {
async getCurrentFestData() {
const schedules = await this.getSchedules();
return !schedules.currentFest || new Date(schedules.currentFest.endTime).getTime() <= Date.now() ? null :
await this.getFestVotingStatusData(schedules.currentFest.id);
if (schedules.currentFest) {
return new Date(schedules.currentFest.endTime).getTime() <= Date.now() ? null : schedules.currentFest;
}
await this.update('fest_records', async () => {
this.fest_records = await this.splatnet.getFestRecords();
}, this.update_interval_schedules);
const current_or_upcoming_fest = this.fest_records!.data.festRecords.nodes.find(fest =>
new Date(fest.endTime).getTime() >= Date.now());
if (!current_or_upcoming_fest) return null;
const fest_detail = await this.getFestDetailData(current_or_upcoming_fest.id);
return fest_detail.data.fest;
}
async getFestDetailData(id: string) {
return this.current_fest?.id === id ?
await this.splatnet.getFestDetailRefetch(id) :
await this.splatnet.getFestDetail(id);
}
async getCurrentFestVotingStatusData() {
const fest = await this.getCurrentFest();
return !fest || new Date(fest.endTime).getTime() <= Date.now() ? null :
await this.getFestVotingStatusData(fest.id);
}
async getFestVotingStatusData(id: string) {
@ -239,6 +397,37 @@ class SplatNet3ApiUser extends SplatNet3User {
await this.splatnet.getFestVotingStatus(id);
}
async recordFestVotes(result: PersistedQueryResult<DetailVotingStatusResult>) {
if (!result.data.fest) return;
const id_str = Buffer.from(result.data.fest.id, 'base64').toString() || result.data.fest.id;
const match = id_str.match(/^Fest-([A-Z]{2}):(([A-Z]+)-(\d+))$/);
const id = match ? match[1] + '-' + match[2] : id_str;
debug('Recording updated fest vote data', id);
await this.getFriends();
const friends = this.friends as PersistedQueryResult<FriendListResult>;
const record: FestVotingStatusRecord = {
result: result.data.fest,
query: result[RequestIdSymbol],
app_version: this.splatnet.version,
be_version: result[ResponseSymbol].headers.get('x-be-version'),
friends: {
result: friends.data.friends,
query: friends[RequestIdSymbol],
be_version: friends[ResponseSymbol].headers.get('x-be-version'),
},
fest: await this.getCurrentFest(),
};
await mkdirp(path.join(this.record_fest_votes!.path, 'splatnet3-fest-votes-' + id));
await fs.writeFile(path.join(this.record_fest_votes!.path, 'splatnet3-fest-votes-' + id, Date.now() + '.json'), JSON.stringify(record, null, 4) + '\n');
}
static async create(storage: persist.LocalStorage, token: string, znc_proxy_url?: string) {
const {splatnet, data} = await getBulletToken(storage, token, znc_proxy_url, true);
@ -276,6 +465,10 @@ class SplatNet3ProxyUser extends SplatNet3User {
return this.fetch('/schedules');
}
async getCurrentFestData() {
return this.fetch('/fest/current');
}
async getCurrentFestVotingStatusData() {
return this.fetch('/fest/current/voting-status');
}
@ -310,6 +503,16 @@ class Server extends HttpServer {
allow_all_users = false;
enable_splatnet3_proxy = false;
record_fest_votes: {
path: string;
read: boolean;
write: boolean;
} | null = null;
readonly image_proxy_path_baas: string | null = null;
readonly image_proxy_path_atum: string | null = null;
readonly image_proxy_path_splatnet3: string | null = null;
update_interval = 30 * 1000;
/** Interval coral friends data should be updated if the requested user isn't friends with the authenticated user */
update_interval_unknown_friends = 10 * 60 * 1000; // 10 minutes
@ -317,12 +520,14 @@ 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>>();
constructor(
readonly storage: persist.LocalStorage,
readonly coral_users: Users<CoralUser>,
readonly splatnet3_users: Users<SplatNet3User> | null,
readonly user_ids: string[],
image_proxy_path?: {baas?: string; atum?: string; splatnet3?: string;},
) {
super();
@ -346,9 +551,20 @@ class Server extends HttpServer {
this.handleAllUsersRequest(req, res)));
app.get('/api/presence/:user', this.createApiRequestHandler((req, res) =>
this.handlePresenceRequest(req, res, req.params.user)));
app.get('/api/presence/:user/splatoon3-fest-votes', this.createApiRequestHandler((req, res) =>
this.handleUserFestVotingStatusHistoryRequest(req, res, req.params.user)));
app.get('/api/presence/:user/events', this.createApiRequestHandler((req, res) =>
this.handlePresenceStreamRequest(req, res, req.params.user)));
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}));
}
if (image_proxy_path?.atum) {
this.image_proxy_path_atum = image_proxy_path.atum;
app.use('/api/presence/resources/atum', express.static(this.image_proxy_path_atum, {redirect: false}));
}
app.use('/api/splatnet3-presence', (req, res, next) => {
console.log('[%s] [splatnet3 proxy] %s %s HTTP/%s from %s, port %d%s, %s',
new Date(), req.method, req.url, req.httpVersion,
@ -367,12 +583,52 @@ class Server extends HttpServer {
this.handleSplatNet3ProxyFriends(req, res)));
app.get('/api/splatnet3-presence/schedules', this.createApiRequestHandler((req, res) =>
this.handleSplatNet3ProxySchedules(req, res)));
app.get('/api/splatnet3-presence/fest/current', this.createApiRequestHandler((req, res) =>
this.handleSplatNet3ProxyCurrentFest(req, res)));
app.get('/api/splatnet3-presence/fest/current/voting-status', this.createApiRequestHandler((req, res) =>
this.handleSplatNet3ProxyCurrentFestVotingStatus(req, res)));
app.use('/api/splatnet3', (req, res, next) => {
console.log('[%s] [splatnet3] %s %s HTTP/%s from %s, port %d%s, %s',
new Date(), req.method, req.url, req.httpVersion,
req.socket.remoteAddress, req.socket.remotePort,
req.headers['x-forwarded-for'] ? ' (' + req.headers['x-forwarded-for'] + ')' : '',
req.headers['user-agent']);
res.setHeader('Server', product + ' presence-server splatnet3-proxy');
res.setHeader('X-Server', product + ' presence-server splatnet3-proxy');
res.setHeader('X-Served-By', os.hostname());
next();
});
if (image_proxy_path?.splatnet3) {
this.image_proxy_path_splatnet3 = image_proxy_path.splatnet3;
app.use('/api/splatnet3/resources', express.static(this.image_proxy_path_splatnet3, {redirect: false}));
}
}
protected encodeJsonForResponse(data: unknown, space?: number) {
return JSON.stringify(data, replacer, space);
return JSON.stringify(data, (key: string, value: unknown) => replacer(key, value, data), space);
}
async getCoralUser(naid: string) {
const token = await this.storage.getItem('NintendoAccountToken.' + naid);
const user = await this.coral_users.get(token);
user.update_interval = this.update_interval;
return user;
}
async getSplatNet3User(naid: string) {
const token = await this.storage.getItem('NintendoAccountToken.' + naid);
return this.getSplatNet3UserBySessionToken(token);
}
async getSplatNet3UserBySessionToken(token: string) {
const user = await this.splatnet3_users!.get(token);
user.record_fest_votes = this.record_fest_votes;
user.update_interval = this.update_interval;
return user;
}
async handleAllUsersRequest(req: Request, res: Response) {
@ -384,12 +640,7 @@ class Server extends HttpServer {
const result: AllUsersResult[] = [];
const users = await Promise.all(this.user_ids.map(async id => {
const token = await this.storage.getItem('NintendoAccountToken.' + id);
const user = await this.coral_users.get(token);
user.update_interval = this.update_interval;
return user;
}));
const users = await Promise.all(this.user_ids.map(id => this.getCoralUser(id)));
for (const user of users) {
const friends = await user.getFriends();
@ -415,12 +666,7 @@ class Server extends HttpServer {
}
if (this.splatnet3_users && include_splatnet3) {
const users = await Promise.all(this.user_ids.map(async id => {
const token = await this.storage.getItem('NintendoAccountToken.' + id);
const user = await this.splatnet3_users!.get(token);
user.update_interval = this.update_interval;
return user;
}));
const users = await Promise.all(this.user_ids.map(id => this.getSplatNet3User(id)));
for (const user of users) {
const friends = await user.getFriends();
@ -473,7 +719,9 @@ class Server extends HttpServer {
result.sort((a, b) => b.presence.updatedAt - a.presence.updatedAt);
return {result};
const images = await this.downloadImages(result, this.getResourceBaseUrls(req));
return {result, [ResourceUrlMapSymbol]: images};
}
async handlePresenceRequest(req: Request, res: Response | null, presence_user_nsaid: string, is_stream = false) {
@ -523,14 +771,14 @@ class Server extends HttpServer {
};
if (this.splatnet3_users && include_splatnet3) {
const token = await this.storage.getItem('NintendoAccountToken.' + user_naid);
const user = await this.splatnet3_users!.get(token);
user.update_interval = this.update_interval;
const user = await this.getSplatNet3User(user_naid);
await this.handleSplatoon3Presence(friend, user, response);
}
return response;
const images = await this.downloadImages(response, this.getResourceBaseUrls(req));
return {...response, [ResourceUrlMapSymbol]: images};
}
getTitleResult(friend: Friend, updated: number, req: Request) {
@ -546,7 +794,7 @@ class Server extends HttpServer {
name: game.name,
image_url: game.imageUri,
url: 'https://fancy.org.uk/api/nxapi/title/' + encodeURIComponent(id) + '/redirect?source=' +
encodeURIComponent('nxapi-' + version + '-presenceserver-' + req.headers.host),
encodeURIComponent('nxapi-' + version + '-presenceserver/' + req.headers.host),
since: new Date(Math.min(Date.now(), friend.presence.updatedAt * 1000)).toISOString(),
} : null;
@ -576,38 +824,13 @@ class Server extends HttpServer {
response.splatoon3 = friend;
if (fest_vote_status) {
const schedules = await user.getSchedules();
const fest = await user.getCurrentFest();
for (const team of fest_vote_status.teams) {
const schedule_team = schedules.currentFest?.teams.find(t => t.id === team.id);
if (!schedule_team || !team.votes || !team.preVotes) continue; // Shouldn't ever happen
const fest_team = this.getFestTeamVotingStatus(fest_vote_status, fest, friend);
for (const player of team.votes.nodes) {
if (player.userIcon.url !== friend.userIcon.url) continue;
response.splatoon3_fest_team = {
...createFestScheduleTeam(schedule_team, FestVoteState.VOTED),
...createFestVoteTeam(team, FestVoteState.VOTED),
};
break;
}
if (response.splatoon3_fest_team) break;
for (const player of team.preVotes.nodes) {
if (player.userIcon.url !== friend.userIcon.url) continue;
response.splatoon3_fest_team = {
...createFestScheduleTeam(schedule_team, FestVoteState.PRE_VOTED),
...createFestVoteTeam(team, FestVoteState.PRE_VOTED),
};
break;
}
if (response.splatoon3_fest_team) break;
}
if (!response.splatoon3_fest_team && fest_vote_status.undecidedVotes) {
if (fest_team) {
response.splatoon3_fest_team = fest_team;
} else if (fest_vote_status.undecidedVotes) {
response.splatoon3_fest_team = null;
}
}
@ -635,8 +858,9 @@ class Server extends HttpServer {
friend.onlineState === FriendOnlineState.COOP_MODE_FIGHTING
) {
const schedules = await user.getSchedules();
const coop_schedules = friend.coopRule === 'BIG_RUN' ?
schedules.coopGroupingSchedule.bigRunSchedules :
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;
@ -644,6 +868,37 @@ class Server extends HttpServer {
}
}
getFestTeamVotingStatus(
fest_vote_status: Exclude<DetailVotingStatusResult['fest'], null>,
fest: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null,
friend: Friend_friendList,
) {
for (const team of fest_vote_status.teams) {
const schedule_or_detail_team = fest?.teams.find(t => t.id === team.id);
if (!schedule_or_detail_team || !team.votes || !team.preVotes) continue;
for (const player of team.votes.nodes) {
if (player.userIcon.url !== friend.userIcon.url) continue;
return {
...createFestScheduleTeam(schedule_or_detail_team, FestVoteState.VOTED),
...createFestVoteTeam(team, FestVoteState.VOTED),
};
}
for (const player of team.preVotes.nodes) {
if (player.userIcon.url !== friend.userIcon.url) continue;
return {
...createFestScheduleTeam(schedule_or_detail_team, FestVoteState.PRE_VOTED),
...createFestVoteTeam(team, FestVoteState.PRE_VOTED),
};
}
}
return null;
}
getSettingForVsMode(schedules: StageScheduleResult, vs_mode: Pick<VsMode, 'id' | 'mode'>) {
if (vs_mode.mode === 'REGULAR') {
return getSchedule(schedules.regularSchedules)?.regularMatchSetting;
@ -669,6 +924,111 @@ class Server extends HttpServer {
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');
}
// Attempt to fetch the user's current presence to make sure they are
// still friends with the presence server user
await this.handlePresenceRequest(req, null, presence_user_nsaid);
const TimestampSymbol = Symbol('Timestamp');
const VoteKeySymbol = Symbol('VoteKey');
const response: {
result: {
id: string;
fest_id: string;
fest_team_id: string;
fest_team: FestTeam_votingStatus;
updated_at: string;
[TimestampSymbol]: number;
[VoteKeySymbol]: string;
}[];
} = {
result: [],
};
const latest = new Map<string, [timestamp: Date, data: FestTeam_votingStatus]>();
const all = req.query['include-all'] === '1';
for await (const dirent of await fs.opendir(this.record_fest_votes.path)) {
if (!dirent.isDirectory() || !dirent.name.startsWith('splatnet3-fest-votes-')) continue;
const id = dirent.name.substr(21);
const fest_votes_dir = path.join(this.record_fest_votes.path, dirent.name);
for await (const dirent of await fs.opendir(fest_votes_dir)) {
const match = dirent.name.match(/^(\d+)\.json$/);
if (!dirent.isFile() || !match) continue;
const timestamp = new Date(parseInt(match[1]));
const is_latest = (latest.get(id)?.[0].getTime() ?? 0) <= timestamp.getTime();
if (!all && !is_latest) continue;
try {
const data: FestVotingStatusRecord =
JSON.parse(await fs.readFile(path.join(fest_votes_dir, dirent.name), 'utf-8'));
const friend = data.friends.result.nodes.find(f => Buffer.from(f.id, 'base64').toString()
.match(/^Friend-([0-9a-f]{16})$/)?.[1] === presence_user_nsaid);
if (!friend) continue;
const fest_team = this.getFestTeamVotingStatus(data.result, data.fest, friend);
if (!fest_team) continue;
const fest_id = data.fest ?
Buffer.from(data.fest.id, 'base64').toString()
.match(/^Fest-([A-Z]{2}):(([A-Z]+)-(\d+))$/)?.[2] || data.fest.id :
null;
if (!fest_id) continue;
const fest_team_id =
Buffer.from(fest_team.id, 'base64').toString()
.match(/^FestTeam-([A-Z]{2}):((([A-Z]+)-(\d+)):([A-Za-z]+))$/)?.[2] || fest_team.id;
if (is_latest) latest.set(id, [timestamp, fest_team]);
if (!all) {
let index;
while ((index = response.result.findIndex(r => r.id === id)) >= 0) {
response.result.splice(index, 1);
}
}
response.result.push({
id,
fest_id,
fest_team_id,
fest_team,
updated_at: timestamp.toISOString(),
[TimestampSymbol]: timestamp.getTime(),
[VoteKeySymbol]: fest_id + '/' + fest_team_id + '/' + fest_team.myVoteState,
});
} catch (err) {
debug('Error reading fest voting status records', id, match[1], err);
}
}
}
if (!response.result.length) throw new ResponseError(404, 'not_found', 'No fest voting status history for this user');
response.result.sort((a, b) => a[TimestampSymbol] - b[TimestampSymbol]);
response.result = response.result.filter((result, index, results) => {
const prev_result = results[index - 1];
return !prev_result || result[VoteKeySymbol] !== prev_result[VoteKeySymbol];
});
response.result.reverse();
const images = await this.downloadImages(response.result, this.getResourceBaseUrls(req));
return {...response, [ResourceUrlMapSymbol]: images};
}
presence_streams = new Set<EventStreamResponse>();
async handlePresenceStreamRequest(req: Request, res: Response, presence_user_nsaid: string) {
@ -704,7 +1064,8 @@ class Server extends HttpServer {
for (const [key, value] of Object.entries(result) as
[keyof typeof result, typeof result[keyof typeof result]][]
) {
stream.sendEvent(key, value);
if (typeof key !== 'string') continue;
stream.sendEvent(key, {...value, [ResourceUrlMapSymbol]: result[ResourceUrlMapSymbol]});
}
await new Promise(rs => setTimeout(rs, this.update_interval));
@ -721,9 +1082,9 @@ class Server extends HttpServer {
for (const [key, value] of Object.entries(result) as
[keyof typeof result, typeof result[keyof typeof result]][]
) {
if (typeof key !== 'string') continue;
if (JSON.stringify(value) === JSON.stringify(last_result[key])) continue;
stream.sendEvent(key, value);
stream.sendEvent(key, {...value, [ResourceUrlMapSymbol]: result[ResourceUrlMapSymbol]});
}
last_result = result;
@ -735,8 +1096,9 @@ class Server extends HttpServer {
if (retry_after && /^\d+$/.test(retry_after)) {
stream.sendEvent(null, 'debug: timestamp ' + new Date().toISOString(), {
error: err,
error: 'unknown_error',
error_message: (err as Error).message,
...err,
});
await new Promise(rs => setTimeout(rs, parseInt(retry_after) * 1000));
@ -745,17 +1107,7 @@ class Server extends HttpServer {
}
}
if (err instanceof ResponseError) {
stream.sendEvent('error', {
error: err.code,
error_message: err.message,
});
} else {
stream.sendEvent('error', {
error: err,
error_message: (err as Error).message,
});
}
stream.sendErrorEvent(err);
debug('Error in event stream %d', stream.id, err);
@ -772,8 +1124,7 @@ class Server extends HttpServer {
req.headers.authorization.substr(3) : null;
if (!token) throw new ResponseError(401, 'unauthorised');
const user = await this.splatnet3_users!.get(token);
user.update_interval = this.update_interval;
const user = await this.getSplatNet3UserBySessionToken(token);
await user.getFriends();
return {result: user.friends};
@ -786,13 +1137,25 @@ class Server extends HttpServer {
req.headers.authorization.substr(3) : null;
if (!token) throw new ResponseError(401, 'unauthorised');
const user = await this.splatnet3_users!.get(token);
user.update_interval = this.update_interval;
const user = await this.getSplatNet3UserBySessionToken(token);
await user.getSchedules();
return {result: user.schedules!};
}
async handleSplatNet3ProxyCurrentFest(req: Request, res: Response) {
if (!this.enable_splatnet3_proxy) throw new ResponseError(403, 'forbidden');
const token = req.headers.authorization?.substr(0, 3) === 'na ' ?
req.headers.authorization.substr(3) : null;
if (!token) throw new ResponseError(401, 'unauthorised');
const user = await this.getSplatNet3UserBySessionToken(token);
await user.getCurrentFest();
return {result: user.current_fest};
}
async handleSplatNet3ProxyCurrentFestVotingStatus(req: Request, res: Response) {
if (!this.enable_splatnet3_proxy) throw new ResponseError(403, 'forbidden');
@ -800,12 +1163,106 @@ class Server extends HttpServer {
req.headers.authorization.substr(3) : null;
if (!token) throw new ResponseError(401, 'unauthorised');
const user = await this.splatnet3_users!.get(token);
user.update_interval = this.update_interval;
const user = await this.getSplatNet3UserBySessionToken(token);
await user.getCurrentFestVotes();
return {result: user.fest_vote_status};
}
async downloadImages(data: unknown, base_url: {
baas: string | null;
atum: string | null;
splatnet3: string | null;
}): Promise<Record<string, string>> {
const image_urls: [url: string, dir: string, base_url: string][] = [];
// Use JSON.stringify to iterate over everything in the response
JSON.stringify(data, (key: string, value: unknown) => {
if (this.image_proxy_path_baas && base_url.baas) {
if (typeof value === 'string' &&
value.startsWith('https://cdn-image-e0d67c509fb203858ebcb2fe3f88c2aa.baas.nintendo.com/')
) {
image_urls.push([value, this.image_proxy_path_baas, base_url.baas]);
}
}
if (this.image_proxy_path_atum && base_url.atum) {
if (typeof value === 'string' &&
value.startsWith('https://atum-img-lp1.cdn.nintendo.net/')
) {
image_urls.push([value, this.image_proxy_path_atum, base_url.atum]);
}
}
if (this.image_proxy_path_splatnet3 && base_url.splatnet3) {
if (typeof value === 'object' && value && 'url' in value && typeof value.url === 'string') {
if (value.url.toLowerCase().startsWith('https://api.lp1.av5ja.srv.nintendo.net/')) {
image_urls.push([value.url, this.image_proxy_path_splatnet3, base_url.splatnet3]);
}
}
}
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;
}
getResourceBaseUrls(req: Request) {
const base_url = process.env.BASE_URL ??
(req.headers['x-forwarded-proto'] === 'https' ? 'https://' : 'http://') +
req.headers.host;
return {
baas: this.image_proxy_path_baas ? base_url + '/api/presence/resources/baas/' : null,
atum: this.image_proxy_path_atum ? base_url + '/api/presence/resources/atum/' : null,
splatnet3: this.image_proxy_path_splatnet3 ? base_url + '/api/splatnet3/resources/' : null,
};
}
downloadImage(url: string, dir: string) {
const pathname = new URL(url).pathname;
const name = pathname.substr(1).toLowerCase()
.replace(/^resources\//g, '')
.replace(/(\/|^)\.\.(\/|$)/g, '$1...$2') +
(path.extname(pathname) ? '' : '.jpeg');
const promise = this.promise_image.get(dir + '/' + name) ?? Promise.resolve().then(async () => {
try {
await fs.stat(path.join(dir, name));
return name;
} catch (err) {}
debug('Fetching image %s', name);
const response = await fetch(url);
const data = new Uint8Array(await response.arrayBuffer());
if (!response.ok) throw new ErrorResponse('Unable to download resource ' + name, response, data.toString());
await mkdirp(path.dirname(path.join(dir, name)));
await fs.writeFile(path.join(dir, name), data);
debug('Downloaded image %s', name);
return name;
}).then(result => {
this.promise_image.delete(dir + '/' + name);
return result;
}).catch(err => {
this.promise_image.delete(dir + '/' + name);
throw err;
});
this.promise_image.set(dir + '/' + name, promise);
return promise;
}
}
function createScheduleFest(
@ -834,7 +1291,7 @@ function createFestVoteTeam(
id: team.id,
teamName: team.teamName,
image: {
url: getSplatoon3inkUrl(team.image.url),
url: team.image.url,
},
color: team.color,
votes: {nodes: []},
@ -842,11 +1299,19 @@ function createFestVoteTeam(
};
}
function replacer(key: string, value: any) {
if ((key === 'image' || key.endsWith('Image')) && value && typeof value === 'object' && 'url' in value) {
function replacer(key: string, value: any, data: unknown) {
const url_map = data && typeof data === 'object' && ResourceUrlMapSymbol in data &&
data[ResourceUrlMapSymbol] && typeof data[ResourceUrlMapSymbol] === 'object' ?
data[ResourceUrlMapSymbol] as Partial<Record<string, string>> : null;
if (typeof value === 'string') {
return url_map?.[value] ?? value;
}
if (typeof value === 'object' && value && 'url' in value && typeof value.url === 'string') {
return {
...value,
url: getSplatoon3inkUrl(value.url),
url: url_map?.[value.url] ?? value.url,
};
}

View File

@ -1,6 +1,6 @@
import process from 'node:process';
import createDebug from 'debug';
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';

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../splatnet2.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getIksmToken } from '../../common/auth/splatnet2.js';

View File

@ -1,7 +1,7 @@
import * as path from 'node:path';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../splatnet2.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getIksmToken } from '../../common/auth/splatnet2.js';

View File

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

View File

@ -1,6 +1,6 @@
import * as path from 'node:path';
import createDebug from 'debug';
import { getIksmToken } from '../../common/auth/splatnet2.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { Arguments as ParentArguments } from '../splatnet2.js';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../splatnet2.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { getAllSeasons } from '../../api/splatnet2-xrank.js';

View File

@ -1,6 +1,6 @@
import process from 'node:process';
import createDebug from 'debug';
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 './splatnet3/index.js';

View File

@ -1,7 +1,7 @@
import createDebug from 'debug';
import { Judgement } from 'splatnet3-types/splatnet3';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';

View File

@ -1,10 +1,10 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import fetch from 'node-fetch';
import { PhotoAlbumResult } from 'splatnet3-types/splatnet3';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';

View File

@ -1,9 +1,9 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import { FestState, Fest_detail, RequestId } from 'splatnet3-types/splatnet3';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';

View File

@ -1,8 +1,8 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';

View File

@ -1,9 +1,9 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import { BankaraBattleHistoriesRefetchResult, CoopHistoryResult, LatestBattleHistoriesRefetchResult, LatestBattleHistoriesResult, PrivateBattleHistoriesRefetchResult, RefetchableCoopHistory_CoopResultResult, RegularBattleHistoriesRefetchResult, RequestId, XBattleHistoriesRefetchResult } from 'splatnet3-types/splatnet3';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';

View File

@ -1,7 +1,7 @@
import createDebug from 'debug';
import { FestState } from 'splatnet3-types/splatnet3';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';

View File

@ -1,7 +1,7 @@
import createDebug from 'debug';
import { FriendOnlineState, Friend_friendList } from 'splatnet3-types/splatnet3';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';

View File

@ -1,7 +1,7 @@
import * as path from 'node:path';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';

View File

@ -1,6 +1,6 @@
import createDebug from 'debug';
import Table from '../util/table.js';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';

View File

@ -1,5 +1,5 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';
@ -40,6 +40,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
bullet_token: data.bullet_token.bulletToken,
expires_at: data.expires_at,
language: data.bullet_token.lang,
country: data.country,
version: data.version,
queries: data.queries,
};

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