Merge branch 'main' into i18n

# Conflicts:
#	package-lock.json
#	package.json
#	src/app/browser/main/discord-setup.tsx
#	src/app/browser/main/discord.tsx
#	src/app/main/app-menu.ts
#	src/app/main/index.ts
#	src/app/main/ipc.ts
#	src/app/main/menu.ts
#	src/app/main/monitor.ts
#	src/app/main/na-auth.ts
#	src/app/main/util.ts
#	src/app/main/webservices.ts
#	src/app/main/windows.ts
This commit is contained in:
Samuel Elliott
2024-10-15 23:31:28 +01:00
179 changed files with 13955 additions and 6527 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ 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';
import { Arguments as ParentArguments } from './index.js';
import { SplatNet2RecordsMonitor } from '../../common/splatnet2/monitor.js';
const debug = createDebug('cli:splatnet2:monitor');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
export * as user from './user.js';
export * as token from './token.js';
export * as friends from './friends.js';
export * as schedule from './schedule.js';
export * as festivals from './festivals.js';
export * as festival from './festival.js';
export * as battles from './battles.js';
export * as dumpRecords from './dump-records.js';
export * as dumpFests from './dump-fests.js';
export * as dumpAlbum from './dump-album.js';
export * as dumpResults from './dump-results.js';
export * as monitor from './monitor.js';

View File

@@ -1,9 +1,8 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import mkdirp from 'mkdirp';
import fetch from 'node-fetch';
import { fetch } from 'undici';
import { PhotoAlbumResult } from 'splatnet3-types/splatnet3';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
@@ -42,7 +41,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const directory = argv.directory ?? path.join(argv.dataPath, 'splatnet3');
await mkdirp(directory);
await fs.mkdir(directory, {recursive: true});
await dumpAlbumPhotos(splatnet, directory);
}

View File

@@ -1,8 +1,7 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import mkdirp from 'mkdirp';
import { FestState, Fest_detail, RequestId } from 'splatnet3-types/splatnet3';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
@@ -44,7 +43,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const directory = argv.directory ?? path.join(argv.dataPath, 'splatnet3');
await mkdirp(directory);
await fs.mkdir(directory, {recursive: true});
await dumpFestRecords(splatnet, directory, argv.includeRankings);
}

View File

@@ -1,7 +1,6 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import mkdirp from 'mkdirp';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
@@ -61,7 +60,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const directory = argv.directory ?? path.join(argv.dataPath, 'splatnet3');
await mkdirp(directory);
await fs.mkdir(directory, {recursive: true});
if (argv.history ?? _default) {
await dumpHistoryRecords(splatnet, directory);

View File

@@ -1,8 +1,7 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
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 type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
@@ -62,7 +61,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const directory = argv.directory ?? path.join(argv.dataPath, 'splatnet3');
await mkdirp(directory);
await fs.mkdir(directory, {recursive: true});
if (argv.battles ?? _default) {
await dumpResults(splatnet, directory);

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