Add CoralApiInterface

This commit is contained in:
Samuel Elliott 2023-07-04 21:01:25 +01:00
parent 865a09ee99
commit d1785ee1fc
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
15 changed files with 214 additions and 169 deletions

View File

@ -1,13 +1,14 @@
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 { generateAuthData, getNintendoAccountToken, getNintendoAccountUser, NintendoAccountSessionAuthorisation, NintendoAccountToken, NintendoAccountUser } from './na.js';
import { ErrorResponse, ResponseSymbol } from './util.js';
import createDebug from '../util/debug.js';
import { JwtPayload } from '../util/jwt.js';
import { getAdditionalUserAgents } from '../util/useragent.js';
import { timeoutSignal } from '../util/misc.js';
import { getAdditionalUserAgents } from '../util/useragent.js';
import type { CoralRemoteConfig } from '../common/remote-config.js';
import { AccountLogin, AccountLoginParameter, AccountToken, AccountTokenParameter, Announcements, CoralErrorResponse, 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';
const debug = createDebug('nxapi:api:coral');
@ -40,7 +41,39 @@ export interface ResultData<T> {
correlationId: string;
}
export default class CoralApi {
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?: CoralErrorResponse, res?: Response) => Promise<CoralAuthData | void>) | null = null;
/** @internal */
_renewToken: Promise<void> | null = null;
@ -50,11 +83,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,11 +131,11 @@ 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,
@ -148,15 +200,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 +278,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 = {
@ -268,10 +320,10 @@ export default class CoralApi {
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 = {
@ -305,11 +357,10 @@ export default class CoralApi {
return data;
}
/** @private */
setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) {
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;
}

View File

@ -295,10 +295,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,
@ -323,10 +325,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 {

View File

@ -2,7 +2,7 @@ import fetch, { Response } from 'node-fetch';
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';
@ -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);
}

View File

@ -3,7 +3,7 @@ import { v4 as uuidgen } from 'uuid';
import { WebServiceToken } from './coral-types.js';
import { NintendoAccountUser } from './na.js';
import { defineResponse, ErrorResponse } from './util.js';
import CoralApi from './coral.js';
import { 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';
@ -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);
}

View File

@ -1,7 +1,7 @@
import fetch, { Response } from 'node-fetch';
import { BankaraBattleHistoriesRefetchResult, BankaraBattleHistoriesRefetchVariables, GraphQLRequest, GraphQLResponse, GraphQLSuccessResponse, KnownRequestId, LatestBattleHistoriesRefetchResult, LatestBattleHistoriesRefetchVariables, MyOutfitInput, PagerUpdateBattleHistoriesByVsModeResult, PagerUpdateBattleHistoriesByVsModeVariables, PrivateBattleHistoriesRefetchResult, PrivateBattleHistoriesRefetchVariables, RegularBattleHistoriesRefetchResult, RegularBattleHistoriesRefetchVariables, RequestId, ResultTypes, VariablesTypes, XBattleHistoriesRefetchResult, XBattleHistoriesRefetchVariables } from 'splatnet3-types/splatnet3';
import { 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';
@ -928,8 +928,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 +948,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 +977,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);
}

View File

@ -1,7 +1,7 @@
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 { 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 { 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';
@ -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)
@ -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,7 @@ 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');
}
@ -165,8 +151,7 @@ export default class ZncProxyApi implements CoralApi {
throw new Error('Not supported in ZncProxyApi');
}
/** @private */
setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) {
protected setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) {
throw new Error('Not supported in ZncProxyApi');
}

View File

@ -1,8 +1,7 @@
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, KeyboardEvent, Menu, MenuItem, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
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';
@ -60,21 +59,9 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
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) =>
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)));
.catch(err => err instanceof WebServiceValidationError ?
handleOpenWebServiceError(err, webservice, qs, u.data, BrowserWindow.fromWebContents(e.sender)!) :
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)));

View File

@ -1,13 +1,12 @@
import { app, dialog, Menu, Tray, nativeImage, MenuItem, BrowserWindow, KeyboardEvent } from './electron.js';
import { app, Menu, Tray, nativeImage, MenuItem, BrowserWindow, KeyboardEvent } from './electron.js';
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';
@ -155,11 +154,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);
}
},
}));
@ -168,26 +163,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;
dialog.showMessageBox({
type: 'error',
message: (err instanceof Error ? err.name : 'Error') + ' opening web service',
detail: (err instanceof Error ? err.stack ?? err.message : err) + '\n\n' + util.inspect({
webservice: {
id: webservice.id,
name: webservice.name,
uri: webservice.uri,
},
user_na_id: data.user.id,
user_nsa_id: data.nsoAccount.user.nsaId,
user_coral_id: data.nsoAccount.user.id,
}, {compact: true}),
});
handleOpenWebServiceError(err, webservice, undefined, data);
}
}

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, nativeImage, nativeTheme, Notification, ShareMenu, shell, WebContents } from './electron.js';
import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, MessageBoxOptions, nativeImage, nativeTheme, Notification, ShareMenu, shell, WebContents } from './electron.js';
import * as path from 'node:path';
import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
@ -10,7 +10,7 @@ import { createWebServiceWindow } from './windows.js';
import { askUserForUri } from './util.js';
import type { DownloadImagesRequest, NativeShareRequest, NativeShareUrlRequest, QrCodeReaderCameraOptions, QrCodeReaderCheckinOptions, QrCodeReaderCheckinResult, QrCodeReaderPhotoLibraryOptions, SendMessageOptions } from '../preload-webservice/znca-js-api.js';
import createDebug from '../../util/debug.js';
import CoralApi from '../../api/coral.js';
import { CoralApiInterface, CoralAuthData } from '../../api/coral.js';
import { CurrentUser, WebService, WebServiceToken } from '../../api/coral-types.js';
import { NintendoAccountUser } from '../../api/na.js';
import { SavedToken } from '../../common/auth/coral.js';
@ -18,10 +18,10 @@ import { SavedToken } from '../../common/auth/coral.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;
@ -54,7 +54,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 +85,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 +120,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();
@ -197,6 +185,38 @@ function askUserForWebServiceUri(store: Store, uri: string) {
return askUserForUri(store, uri, 'Select a user to open this web service');
}
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,
};
const options: MessageBoxOptions = {
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(data, {compact: true}),
buttons,
};
const result = window ?
await dialog.showMessageBox(window, options) :
await dialog.showMessageBox(options);
return result;
}
export interface WebServiceData {
webservice: WebService;
url: string;
@ -223,6 +243,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,
@ -319,12 +340,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;
}

View File

@ -5,7 +5,7 @@ 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 CoralApi, { CoralApiInterface } 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';
@ -37,12 +37,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 +104,7 @@ class Server extends HttpServer {
constructor(
readonly storage: persist.LocalStorage,
readonly users: Users<CoralUser>,
readonly users: Users<CoralUser<CoralApiInterface>>,
) {
super();
@ -265,7 +265,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) {
@ -404,7 +404,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 +670,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');
}
@ -715,7 +715,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);
}

View File

@ -1,5 +1,5 @@
import { Response } from 'node-fetch';
import CoralApi, { CoralAuthData, Result, ZNCA_CLIENT_ID } from '../api/coral.js';
import CoralApi, { CoralApiInterface, CoralAuthData, Result, ZNCA_CLIENT_ID } from '../api/coral.js';
import { Announcements, Friends, Friend, GetActiveEventResult, WebServices, CoralErrorResponse } from '../api/coral-types.js';
import ZncProxyApi from '../api/znc-proxy.js';
import { NintendoAccountSession, Storage } from './storage/index.js';
@ -35,7 +35,7 @@ export default class Coral {
onUpdatedWebServices: ((webservices: Result<WebServices>) => void) | null = null;
constructor(
public api: CoralApi,
public api: CoralApiInterface,
public data: CoralAuthData,
public announcements: Result<Announcements> | null = null,
public friends: Result<Friends> | null = null,
@ -115,6 +115,10 @@ export default class Coral {
}
async addFriend(nsa_id: string) {
if (!(this.api instanceof CoralApi)) {
throw new Error('Cannot send friend requests using Coral API proxy');
}
if (nsa_id === this.data.nsoAccount.user.nsaId) {
throw new Error('Cannot add self as a friend');
}
@ -220,7 +224,7 @@ export default class Coral {
return [nso, auth_data] as const;
}
static async createWithCoralApi(coral: CoralApi, data: SavedToken, skip_fetch = false) {
static async createWithCoralApi(coral: CoralApiInterface, data: SavedToken, skip_fetch = false) {
if (skip_fetch) {
debug('Already authenticated, skip fetching coral data');
return new Coral(coral, data, null, null, null, null);

View File

@ -27,11 +27,17 @@ export async function getToken(
data: SavedToken;
}>
export async function getToken(
storage: persist.LocalStorage, token: string, proxy_url?: string, ratelimit?: boolean
storage: persist.LocalStorage, token: string, proxy_url?: undefined, ratelimit?: boolean
): Promise<{
nso: CoralApi;
data: SavedToken;
}>
export async function getToken(
storage: persist.LocalStorage, token: string, proxy_url?: string, ratelimit?: boolean
): Promise<{
nso: CoralApi | ZncProxyApi;
data: SavedToken;
}>
export async function getToken(
storage: persist.LocalStorage, token: string, proxy_url?: string, ratelimit = SHOULD_LIMIT_USE
) {
@ -73,7 +79,9 @@ export async function getToken(
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, {existingToken});
if (nso instanceof CoralApi) {
nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, {existingToken});
}
await storage.setItem('NsoToken.' + token, existingToken);
await storage.setItem('NintendoAccountToken.' + data.user.id, token);
@ -90,7 +98,9 @@ export async function getToken(
new ZncProxyApi(proxy_url, token) :
CoralApi.createWithSavedToken(existingToken);
nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, {existingToken});
if (nso instanceof CoralApi) {
nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, {existingToken});
}
return {nso, data: existingToken};
}

View File

@ -1,5 +1,5 @@
import persist from 'node-persist';
import CoralApi from '../api/coral.js';
import { CoralApiInterface } from '../api/coral.js';
import { ActiveEvent, Announcements, CurrentUser, Friend, Game, Presence, PresenceState, WebServices, CoralErrorResponse, GetActiveEventResult } from '../api/coral-types.js';
import ZncProxyApi from '../api/znc-proxy.js';
import { ErrorResponse } from '../api/util.js';
@ -26,9 +26,9 @@ export class ZncNotifications extends Loop {
constructor(
public storage: persist.LocalStorage,
public token: string,
public nso: CoralApi,
public nso: CoralApiInterface,
public data: Omit<SavedToken, 'expires_at'>,
public user?: CoralUser,
public user?: CoralUser<CoralApiInterface>,
) {
super();
}

View File

@ -1,7 +1,7 @@
import * as crypto from 'node:crypto';
import * as persist from 'node-persist';
import createDebug from '../util/debug.js';
import CoralApi, { Result } from '../api/coral.js';
import CoralApi, { CoralApiInterface, Result } from '../api/coral.js';
import ZncProxyApi from '../api/znc-proxy.js';
import { Announcements, Friends, Friend, GetActiveEventResult, CoralSuccessResponse, WebService, WebServices } from '../api/coral-types.js';
import { getToken, SavedToken } from './auth/coral.js';
@ -52,7 +52,8 @@ export default class Users<T extends UserData> {
}
static coral(store: Store | persist.LocalStorage, znc_proxy_url: string, ratelimit?: boolean): Users<CoralUser<ZncProxyApi>>
static coral(store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean): Users<CoralUser>
static coral(store: Store | persist.LocalStorage, znc_proxy_url?: undefined, ratelimit?: boolean): Users<CoralUser<CoralApi>>
static coral(store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean): Users<CoralUser<CoralApiInterface>>
static coral(_store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean) {
const store = 'storage' in _store ? _store : null;
const storage = 'storage' in _store ? _store.storage : _store;
@ -83,7 +84,7 @@ export default class Users<T extends UserData> {
}
}
export interface CoralUserData<T extends CoralApi = CoralApi> extends UserData {
export interface CoralUserData<T extends CoralApiInterface = CoralApi> extends UserData {
nso: T;
data: SavedToken;
announcements: CoralSuccessResponse<Announcements>;
@ -92,7 +93,7 @@ export interface CoralUserData<T extends CoralApi = CoralApi> extends UserData {
active_event: CoralSuccessResponse<GetActiveEventResult>;
}
export class CoralUser<T extends CoralApi = CoralApi> implements CoralUserData<T> {
export class CoralUser<T extends CoralApiInterface = CoralApi> implements CoralUserData<T> {
created_at = Date.now();
expires_at = Infinity;
@ -171,6 +172,10 @@ export class CoralUser<T extends CoralApi = CoralApi> implements CoralUserData<T
}
async addFriend(nsa_id: string) {
if (!(this.nso instanceof CoralApi)) {
throw new Error('Cannot send friend requests using Coral API proxy');
}
if (nsa_id === this.data.nsoAccount.user.nsaId) {
throw new Error('Cannot add self as a friend');
}

View File

@ -1,5 +1,7 @@
export {
default,
CoralApiInterface,
CoralAuthData,
PartialCoralAuthData,