diff --git a/src/api/coral.ts b/src/api/coral.ts index 69e6f05..90dbc93 100644 --- a/src/api/coral.ts +++ b/src/api/coral.ts @@ -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 { correlationId: string; } -export default class CoralApi { +export interface CoralApiInterface { + getAnnouncements(): Promise>; + getFriendList(): Promise>; + addFavouriteFriend(nsa_id: string): Promise>; + removeFavouriteFriend(nsa_id: string): Promise>; + getWebServices(): Promise>; + getActiveEvent(): Promise>; + getEvent(id: number): Promise>; + getUser(id: number): Promise>; + getUserByFriendCode(friend_code: string, hash?: string): Promise>; + getCurrentUser(): Promise>; + getFriendCodeUrl(): Promise>; + getCurrentUserPermissions(): Promise>; + getWebServiceToken(id: number): Promise>; +} + +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) | null = null; /** @internal */ _renewToken: Promise | 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( 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('/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 { 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; } diff --git a/src/api/na.ts b/src/api/na.ts index 7c3f52b..eabd4f6 100644 --- a/src/api/na.ts +++ b/src/api/na.ts @@ -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 { diff --git a/src/api/nooklink.ts b/src/api/nooklink.ts index e5a6625..e400082 100644 --- a/src/api/nooklink.ts +++ b/src/api/nooklink.ts @@ -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); } diff --git a/src/api/splatnet2.ts b/src/api/splatnet2.ts index 0816106..3e4ab30 100644 --- a/src/api/splatnet2.ts +++ b/src/api/splatnet2.ts @@ -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); } diff --git a/src/api/splatnet3.ts b/src/api/splatnet3.ts index 577c019..60d176d 100644 --- a/src/api/splatnet3.ts +++ b/src/api/splatnet3.ts @@ -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); } diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index aea00d1..dd88eb3 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -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) | null = null; - /** @internal */ - _renewToken: Promise | 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 { + async getToken(token: string, user: NintendoAccountUser): Promise { 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'); } diff --git a/src/app/main/ipc.ts b/src/app/main/ipc.ts index be8d0c0..22c0139 100644 --- a/src/app/main/ipc.ts +++ b/src/app/main/ipc.ts @@ -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))); diff --git a/src/app/main/menu.ts b/src/app/main/menu.ts index 888654b..683b47e 100644 --- a/src/app/main/menu.ts +++ b/src/app/main/menu.ts @@ -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); } } diff --git a/src/app/main/webservices.ts b/src/app/main/webservices.ts index 0855182..96fd1a6 100644 --- a/src/app/main/webservices.ts +++ b/src/app/main/webservices.ts @@ -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(); -const windowapi = new WeakMap(); +const windowapi = new WeakMap(); 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 { 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 { - 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; } diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 740080b..927abca 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -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; policy?: AuthPolicy; token?: string; } interface RequestDataWithUser extends RequestData { - user: CoralUser; + user: CoralUser; } const debug = createDebug('cli:nso:http-server'); @@ -104,7 +104,7 @@ class Server extends HttpServer { constructor( readonly storage: persist.LocalStorage, - readonly users: Users, + readonly users: Users>, ) { super(); @@ -265,7 +265,7 @@ class Server extends HttpServer { } }); - private coral_auth_promise = new Map>(); + private coral_auth_promise = new Map>>(); private coral_auth_timeout = new Map(); async getCoralUser(req: Request) { @@ -404,7 +404,7 @@ class Server extends HttpServer { private user_data_promise = new Map>(); private cached_userdata = new Map(); - 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(); - 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>(); private cached_friendcodeurl = new Map(); - getFriendCodeUrl(id: string, coral: CoralApi) { + getFriendCodeUrl(id: string, coral: CoralApiInterface) { return this._cache(id, () => coral.getFriendCodeUrl(), this.user_friendcodeurl_promise, this.cached_friendcodeurl); } diff --git a/src/client/coral.ts b/src/client/coral.ts index 6a87ed3..9a12716 100644 --- a/src/client/coral.ts +++ b/src/client/coral.ts @@ -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) => void) | null = null; constructor( - public api: CoralApi, + public api: CoralApiInterface, public data: CoralAuthData, public announcements: Result | null = null, public friends: Result | 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); diff --git a/src/common/auth/coral.ts b/src/common/auth/coral.ts index dd8cb42..c4e7956 100644 --- a/src/common/auth/coral.ts +++ b/src/common/auth/coral.ts @@ -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}; } diff --git a/src/common/notify.ts b/src/common/notify.ts index 21b7aa2..2b29ad4 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -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, - public user?: CoralUser, + public user?: CoralUser, ) { super(); } diff --git a/src/common/users.ts b/src/common/users.ts index 994a1c7..4ec9c5a 100644 --- a/src/common/users.ts +++ b/src/common/users.ts @@ -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 { } static coral(store: Store | persist.LocalStorage, znc_proxy_url: string, ratelimit?: boolean): Users> - static coral(store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean): Users + static coral(store: Store | persist.LocalStorage, znc_proxy_url?: undefined, ratelimit?: boolean): Users> + static coral(store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean): Users> 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 { } } -export interface CoralUserData extends UserData { +export interface CoralUserData extends UserData { nso: T; data: SavedToken; announcements: CoralSuccessResponse; @@ -92,7 +93,7 @@ export interface CoralUserData extends UserData { active_event: CoralSuccessResponse; } -export class CoralUser implements CoralUserData { +export class CoralUser implements CoralUserData { created_at = Date.now(); expires_at = Infinity; @@ -171,6 +172,10 @@ export class CoralUser implements CoralUserData