mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-10-07 01:57:44 -05:00
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:
@@ -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;
|
||||
|
||||
194
src/api/coral.ts
194
src/api/coral.ts
@@ -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;
|
||||
|
||||
18
src/api/f.ts
18
src/api/f.ts
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
148
src/api/na.ts
148
src/api/na.ts
@@ -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> {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,6 +34,6 @@ export function NintendoSwitchUsers(props: {
|
||||
const styles = StyleSheet.create({
|
||||
userImage: {
|
||||
borderRadius: 8,
|
||||
textAlignVertical: -3,
|
||||
verticalAlign: -3,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import createDebug from 'debug';
|
||||
import type { NxapiElectronIpc } from '../preload/index.js';
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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} />);
|
||||
}
|
||||
|
||||
|
||||
@@ -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%',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
10
src/cli/commands.ts
Normal 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';
|
||||
@@ -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';
|
||||
@@ -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>>;
|
||||
10
src/cli/nooklink/commands.ts
Normal file
10
src/cli/nooklink/commands.ts
Normal 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';
|
||||
@@ -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();
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: ',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
16
src/cli/nso/commands.ts
Normal 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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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
8
src/cli/pctl/commands.ts
Normal 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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
12
src/cli/splatnet2/commands.ts
Normal file
12
src/cli/splatnet2/commands.ts
Normal 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';
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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';
|
||||
|
||||
12
src/cli/splatnet3/commands.ts
Normal file
12
src/cli/splatnet3/commands.ts
Normal 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';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user