From edfd55a7a975e53713e57137db08ffb88b688b98 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Thu, 1 Jun 2023 22:12:01 +0100 Subject: [PATCH] Translate Nintendo Account authentication window --- src/app/i18n/locale/en-gb.ts | 37 +++++++- src/app/main/index.ts | 2 +- src/app/main/ipc.ts | 8 +- src/app/main/menu.ts | 4 +- src/app/main/na-auth.ts | 160 +++++++++++++++++++---------------- src/common/constants.ts | 11 +++ 6 files changed, 142 insertions(+), 80 deletions(-) diff --git a/src/app/i18n/locale/en-gb.ts b/src/app/i18n/locale/en-gb.ts index 2ef9546..3e47b57 100644 --- a/src/app/i18n/locale/en-gb.ts +++ b/src/app/i18n/locale/en-gb.ts @@ -1,4 +1,4 @@ -import { CREDITS_NOTICE, LICENCE_NOTICE } from '../../../common/constants.js'; +import { CREDITS_NOTICE, LICENCE_NOTICE, ZNCA_API_USE_TEXT } from '../../../common/constants.js'; export const app = { default_title: 'Nintendo Switch Online', @@ -99,6 +99,41 @@ export const handle_uri = { cancel: 'Cancel', }; +export const na_auth = { + window: { + title: 'Nintendo Account', + }, + + znca_api_use: { + title: 'Third-party API usage', + + // This should be translated in other languages + text: ZNCA_API_USE_TEXT, + + ok: 'OK', + cancel: 'Cancel', + more_information: 'More information', + }, + + notification_coral: { + title: 'Nintendo Switch Online', + body_existing: 'Already signed in as {{name}} (Nintendo Account {{na_name}} / {{na_username}})', + body_authenticated: 'Authenticated as {{name}} (Nintendo Account {{na_name}} / {{na_username}})', + body_reauthenticated: 'Reauthenticated to {{name}} (Nintendo Account {{na_name}} / {{na_username}})', + }, + + notification_moon: { + title: 'Nintendo Switch Parental Controls', + body_existing: 'Already signed in as {{na_name}} ({{na_username}})', + body_authenticated: 'Authenticated as {{na_name}} ({{na_username}})', + body_reauthenticated: 'Reauthenticated to {{na_name}} ({{na_username}})', + }, + + error: { + title: 'Error adding account', + }, +}; + export const time_since = { default: { now: 'just now', diff --git a/src/app/main/index.ts b/src/app/main/index.ts index 14596ea..1d985d1 100644 --- a/src/app/main/index.ts +++ b/src/app/main/index.ts @@ -115,7 +115,7 @@ export class App { debug('Initialising i18n with language %s', language); await i18n.init({lng: language ?? undefined}); - await i18n.loadNamespaces(['app', 'app_menu', 'menus', 'handle_uri']); + await i18n.loadNamespaces(['app', 'app_menu', 'menus', 'handle_uri', 'na_auth']); return i18n; } diff --git a/src/app/main/ipc.ts b/src/app/main/ipc.ts index 139a1ef..1fa4f51 100644 --- a/src/app/main/ipc.ts +++ b/src/app/main/ipc.ts @@ -51,8 +51,8 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) { }, 60 * 60 * 1000); ipcMain.handle('nxapi:accounts:list', () => storage.getItem('NintendoAccountIds')); - ipcMain.handle('nxapi:accounts:add-coral', () => askAddNsoAccount(store.storage).then(u => u?.data.user.id)); - ipcMain.handle('nxapi:accounts:add-moon', () => askAddPctlAccount(store.storage).then(u => u?.data.user.id)); + ipcMain.handle('nxapi:accounts:add-coral', () => askAddNsoAccount(appinstance).then(u => u?.data.user.id)); + ipcMain.handle('nxapi:accounts:add-moon', () => askAddPctlAccount(appinstance).then(u => u?.data.user.id)); ipcMain.handle('nxapi:coral:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id)); ipcMain.handle('nxapi:coral:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token)); @@ -130,10 +130,10 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) { ipcMain.handle('nxapi:menu:add-user', e => (Menu.buildFromTemplate([ new MenuItem({label: t('add_account.add_account_coral')!, click: (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) => - askAddNsoAccount(storage, !event.shiftKey)}), + askAddNsoAccount(appinstance, !event.shiftKey)}), new MenuItem({label: t('add_account.add_account_moon')!, click: (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) => - askAddPctlAccount(storage, !event.shiftKey)}), + askAddPctlAccount(appinstance, !event.shiftKey)}), ]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined)); ipcMain.handle('nxapi:menu:friend-code', (e, fc: CurrentUser['links']['friendCode']) => (Menu.buildFromTemplate([ new MenuItem({label: 'SW-' + fc.id, enabled: false}), diff --git a/src/app/main/menu.ts b/src/app/main/menu.ts index 2e4fbd7..263e72e 100644 --- a/src/app/main/menu.ts +++ b/src/app/main/menu.ts @@ -139,9 +139,9 @@ export default class MenuApp { } addNsoAccount = (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) => - askAddNsoAccount(this.app.store.storage, !event.shiftKey); + askAddNsoAccount(this.app, !event.shiftKey); addPctlAccount = (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) => - askAddPctlAccount(this.app.store.storage, !event.shiftKey); + askAddPctlAccount(this.app, !event.shiftKey); protected webservices = new Map(); diff --git a/src/app/main/na-auth.ts b/src/app/main/na-auth.ts index 700d5f3..213dfe7 100644 --- a/src/app/main/na-auth.ts +++ b/src/app/main/na-auth.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, dialog, MessageBoxOptions, Notification, session, s import process from 'node:process'; import * as crypto from 'node:crypto'; import * as persist from 'node-persist'; -import { protocol_registration_options } from './index.js'; +import { App, protocol_registration_options } from './index.js'; import { createModalWindow, createWindow } from './windows.js'; import { tryGetNativeImageFromUrl } from './util.js'; import { WindowType } from '../common/types.js'; @@ -14,7 +14,7 @@ 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_URL } from '../../common/constants.js'; +import { ZNCA_API_USE_TEXT, ZNCA_API_USE_URL } from '../../common/constants.js'; const debug = createDebug('app:main:na-auth'); @@ -55,7 +55,7 @@ html { let i = 0; -export function createAuthWindow() { +export function createAuthWindow(app: App) { const browser_session = session.defaultSession; const window = new BrowserWindow({ @@ -64,7 +64,7 @@ export function createAuthWindow() { resizable: false, maximizable: false, fullscreenable: false, - title: 'Nintendo Account', + title: app.i18n.t('na_auth:window.title') ?? 'Nintendo Account', webPreferences: { session: browser_session, scrollBounce: true, @@ -101,16 +101,22 @@ export class AuthoriseCancelError extends AuthoriseError { } } -export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window: false): - Promise -export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window: true): - Promise -export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window?: boolean): - Promise -export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window = true) { +export function getSessionTokenCodeByInAppBrowser( + app: App, client_id: string, scope: string | string[], close_window: false, +): Promise +export function getSessionTokenCodeByInAppBrowser( + app: App, client_id: string, scope: string | string[], close_window: true, +): Promise +export function getSessionTokenCodeByInAppBrowser( + app: App, client_id: string, scope: string | string[], close_window?: boolean, +): Promise + +export function getSessionTokenCodeByInAppBrowser( + app: App, client_id: string, scope: string | string[], close_window = true, +) { return new Promise((rs, rj) => { const {url: authoriseurl, state, verifier, challenge} = getAuthUrl(client_id, scope); - const window = createAuthWindow(); + const window = createAuthWindow(app); const handleAuthUrl = (url: URL) => { const authorisedparams = new URLSearchParams(url.hash.substr(1)); @@ -345,9 +351,9 @@ const NSO_SCOPE = [ 'user.screenName', ]; -export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_browser = true) { +export async function addNsoAccount(app: App, use_in_app_browser = true) { const {code, verifier, window} = use_in_app_browser ? - await getSessionTokenCodeByInAppBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false) : + await getSessionTokenCodeByInAppBrowser(app, ZNCA_CLIENT_ID, NSO_SCOPE, false) : await getSessionTokenCodeByDefaultBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false); window?.setFocusable(false); @@ -356,17 +362,21 @@ export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_br try { const [jwt, sig] = Jwt.decode(code); - const nsotoken = await storage.getItem('NintendoAccountToken.' + jwt.payload.sub) as string | undefined; + const nsotoken = await app.store.storage.getItem('NintendoAccountToken.' + jwt.payload.sub) as string | undefined; if (nsotoken) { debug('Already authenticated', jwt.payload); try { - const {nso, data} = await getToken(storage, nsotoken, process.env.ZNC_PROXY_URL, false); + const {nso, data} = await getToken(app.store.storage, nsotoken, process.env.ZNC_PROXY_URL, false); new Notification({ - title: 'Nintendo Switch Online', - body: 'Already signed in as ' + data.nsoAccount.user.name + ' (Nintendo Account ' + + title: app.i18n.t('na_auth:notification_coral.title') ?? 'Nintendo Switch Online', + body: app.i18n.t('na_auth:notification_coral.body_existing', { + name: data.nsoAccount.user.name, + na_name: data.user.nickname, + na_username: data.user.screenName, + }) ?? 'Already signed in as ' + data.nsoAccount.user.name + ' (Nintendo Account ' + data.user.nickname + ' / ' + data.user.screenName + ')', icon: await tryGetNativeImageFromUrl(data.nsoAccount.user.imageUri), }).show(); @@ -378,7 +388,7 @@ export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_br if (data.error === 'invalid_grant') { // The session token has expired/was revoked - return authenticateCoralSessionToken(storage, code, verifier, true); + return authenticateCoralSessionToken(app, code, verifier, true); } } @@ -386,16 +396,16 @@ export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_br } } - await checkZncaApiUseAllowed(storage, window); + await checkZncaApiUseAllowed(app, window); - return authenticateCoralSessionToken(storage, code, verifier); + return authenticateCoralSessionToken(app, code, verifier); } finally { window?.close(); } } async function authenticateCoralSessionToken( - storage: persist.LocalStorage, + app: App, code: string, verifier: string, reauthenticate = false, ) { @@ -403,80 +413,80 @@ async function authenticateCoralSessionToken( debug('session token', token); - const {nso, data} = await getToken(storage, token.session_token, process.env.ZNC_PROXY_URL, false); + const {nso, data} = await getToken(app.store.storage, token.session_token, process.env.ZNC_PROXY_URL, false); - const users = new Set(await storage.getItem('NintendoAccountIds') ?? []); + const users = new Set(await app.store.storage.getItem('NintendoAccountIds') ?? []); users.add(data.user.id); - await storage.setItem('NintendoAccountIds', [...users]); + await app.store.storage.setItem('NintendoAccountIds', [...users]); new Notification({ - title: 'Nintendo Switch Online', - body: reauthenticate ? + title: app.i18n.t('na_auth:notification_coral.title') ?? 'Nintendo Switch Online', + body: app.i18n.t('na_auth:notification_coral.body_' + (reauthenticate ? 're' : '') + 'authenticated', { + name: data.nsoAccount.user.name, + na_name: data.user.nickname, + na_username: data.user.screenName, + }) ?? (reauthenticate ? 'Reauthenticated to ' + data.nsoAccount.user.name + ' (Nintendo Account ' + data.user.nickname + ' / ' + data.user.screenName + ')' : 'Authenticated as ' + data.nsoAccount.user.name + ' (Nintendo Account ' + data.user.nickname + ' / ' + - data.user.screenName + ')', + data.user.screenName + ')'), icon: await tryGetNativeImageFromUrl(data.nsoAccount.user.imageUri), }).show(); return {nso, data}; } -export async function askAddNsoAccount(storage: persist.LocalStorage, iab = true) { +export async function askAddNsoAccount(app: App, iab = true) { try { - return await addNsoAccount(storage, iab); + return await addNsoAccount(app, iab); } catch (err: any) { if (err instanceof AuthoriseError && err.code === 'access_denied') return; - dialog.showErrorBox('Error adding account', err.stack || err.message); + dialog.showErrorBox(app.i18n.t('na_auth:error.title') ?? 'Error adding account', + err.stack || err.message); } } -async function checkZncaApiUseAllowed(storage: persist.LocalStorage, window?: BrowserWindow, force = false) { +async function checkZncaApiUseAllowed(app: App, window?: BrowserWindow, force = false) { if (!force) { - if (await storage.getItem('ZncaApiConsent')) { + if (await app.store.storage.getItem('ZncaApiConsent')) { return; } if (process.env.ZNC_PROXY_URL) { debug('Skipping znca API consent; znc proxy URL set'); - await storage.setItem('ZncaApiConsent', true); + await app.store.storage.setItem('ZncaApiConsent', true); return; } - const ids: string[] | undefined = await storage.getItem('NintendoAccountIds'); + const ids: string[] | undefined = await app.store.storage.getItem('NintendoAccountIds'); for (const id of ids ?? []) { - const nsotoken: string | undefined = await storage.getItem('NintendoAccountToken.' + id); + const nsotoken: string | undefined = await app.store.storage.getItem('NintendoAccountToken.' + id); if (!nsotoken) continue; debug('Skipping znca API consent; Nintendo Switch Online account already linked'); - await storage.setItem('ZncaApiConsent', true); + await app.store.storage.setItem('ZncaApiConsent', true); return; } } - if (await askZncaApiUseAllowed(window)) { - await storage.setItem('ZncaApiConsent', true); + if (await askZncaApiUseAllowed(app, window)) { + await app.store.storage.setItem('ZncaApiConsent', true); } else { throw new Error('Cannot continue without third-party APIs allowed'); } } -const ZNCA_API_USE_TEXT = `To access the Nintendo Switch Online app API, nxapi must send some data to third-party APIs. This is required to generate some data to make Nintendo think you\'re using the real Nintendo Switch Online app. - -By default, this uses the imink API, but another service can be used by setting an environment variable. The default API may change without notice if you do not force use of a specific service. - -The data sent includes: - -- When authenticating to the Nintendo Switch Online app: a Nintendo Account ID token, containing your Nintendo Account ID and country, which is valid for 15 minutes -- When authenticating to game-specific services: a Coral (Nintendo Switch Online app) ID token, containing your Coral user ID, Nintendo Switch Online membership status, and Nintendo Account child restriction status, which is valid for 2 hours`; - -async function askZncaApiUseAllowed(window?: BrowserWindow): Promise { +async function askZncaApiUseAllowed(app?: App, window?: BrowserWindow): Promise { const options: MessageBoxOptions = { - message: 'Third-party API usage', - detail: ZNCA_API_USE_TEXT, - buttons: ['OK', 'Cancel', 'More information'], + message: app?.i18n.t('na_auth:znca_api_use.title') ?? 'Third-party API usage', + detail: app?.i18n.t('na_auth:znca_api_use.text') ?? ZNCA_API_USE_TEXT, + buttons: [ + app?.i18n.t('na_auth:znca_api_use.ok') ?? 'OK', + app?.i18n.t('na_auth:znca_api_use.cancel') ?? 'Cancel', + app?.i18n.t('na_auth:znca_api_use.more_information') ?? 'More information', + ], cancelId: 1, }; @@ -488,7 +498,7 @@ async function askZncaApiUseAllowed(window?: BrowserWindow): Promise { if (result.response === 2) { shell.openExternal(ZNCA_API_USE_URL); - return askZncaApiUseAllowed(window); + return askZncaApiUseAllowed(app, window); } return result.response === 0; @@ -510,9 +520,9 @@ const MOON_SCOPE = [ 'moonMonthlySummary', ]; -export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_browser = true) { +export async function addPctlAccount(app: App, use_in_app_browser = true) { const {code, verifier, window} = use_in_app_browser ? - await getSessionTokenCodeByInAppBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false) : + await getSessionTokenCodeByInAppBrowser(app, ZNMA_CLIENT_ID, MOON_SCOPE, false) : await getSessionTokenCodeByDefaultBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false); window?.setFocusable(false); @@ -521,17 +531,20 @@ export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_b try { const [jwt, sig] = Jwt.decode(code); - const moontoken = await storage.getItem('NintendoAccountToken-pctl.' + jwt.payload.sub) as string | undefined; + const moontoken = await app.store.storage.getItem('NintendoAccountToken-pctl.' + jwt.payload.sub) as string | undefined; if (moontoken) { debug('Already authenticated', jwt.payload); try { - const {moon, data} = await getPctlToken(storage, moontoken, false); + const {moon, data} = await getPctlToken(app.store.storage, moontoken, false); new Notification({ - title: 'Nintendo Switch Parental Controls', - body: 'Already signed in as ' + data.user.nickname + ' (' + data.user.screenName + ')', + title: app.i18n.t('na_auth:notification_moon.title') ?? 'Nintendo Switch Parental Controls', + body: app.i18n.t('na_auth:notification_moon.body_existing', { + na_name: data.user.nickname, + na_username: data.user.screenName, + }) ?? 'Already signed in as ' + data.user.nickname + ' (' + data.user.screenName + ')', }).show(); return {moon, data}; @@ -541,7 +554,7 @@ export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_b if (data.error === 'invalid_grant') { // The session token has expired/was revoked - return authenticateMoonSessionToken(storage, code, verifier, true); + return authenticateMoonSessionToken(app, code, verifier, true); } } @@ -549,44 +562,47 @@ export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_b } } - return authenticateMoonSessionToken(storage, code, verifier); + return authenticateMoonSessionToken(app, code, verifier); } finally { window?.close(); } } async function authenticateMoonSessionToken( - storage: persist.LocalStorage, + app: App, code: string, verifier: string, reauthenticate = false, ) { - const token = await getNintendoAccountSessionToken(code, verifier, ZNMA_CLIENT_ID); debug('session token', token); - const {moon, data} = await getPctlToken(storage, token.session_token, false); + const {moon, data} = await getPctlToken(app.store.storage, token.session_token, false); - const users = new Set(await storage.getItem('NintendoAccountIds') ?? []); + const users = new Set(await app.store.storage.getItem('NintendoAccountIds') ?? []); users.add(data.user.id); - await storage.setItem('NintendoAccountIds', [...users]); + await app.store.storage.setItem('NintendoAccountIds', [...users]); new Notification({ - title: 'Nintendo Switch Parental Controls', - body: reauthenticate ? + title: app.i18n.t('na_auth:notification_moon.title') ?? 'Nintendo Switch Parental Controls', + body: app.i18n.t('na_auth:notification_moon.body_' + (reauthenticate ? 're' : '') + 'authenticated', { + na_name: data.user.nickname, + na_username: data.user.screenName, + }) ?? (reauthenticate ? 'Reauthenticated to ' + data.user.nickname + ' (' + data.user.screenName + ')' : - 'Authenticated as ' + data.user.nickname + ' (' + data.user.screenName + ')', + 'Authenticated as ' + data.user.nickname + ' (' + data.user.screenName + ')'), }).show(); return {moon, data}; } -export async function askAddPctlAccount(storage: persist.LocalStorage, iab = true) { +export async function askAddPctlAccount(app: App, iab = true) { try { - return await addPctlAccount(storage, iab); + return await addPctlAccount(app, iab); } catch (err: any) { if (err instanceof AuthoriseError && err.code === 'access_denied') return; - dialog.showErrorBox('Error adding account', err.stack || err.message); + dialog.showErrorBox(app.i18n.t('na_auth:error.title') ?? 'Error adding account', + err.stack || err.message); } } diff --git a/src/common/constants.ts b/src/common/constants.ts index 550736b..f855503 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -20,3 +20,14 @@ This product is not affiliated with Nintendo, Discord and others. All product na export const CREDITS_NOTICE = ` This product uses services provided by Nintendo (https://nintendo.co.jp) and Jone Wang (https://imink.app). `.trim(); + +export const ZNCA_API_USE_TEXT = ` +To access the Nintendo Switch Online app API, nxapi must send some data to third-party APIs. This is required to generate some data to make Nintendo think you\'re using the real Nintendo Switch Online app. + +By default, this uses the imink API, but another service can be used by setting an environment variable. The default API may change without notice if you do not force use of a specific service. + +The data sent includes: + +- When authenticating to the Nintendo Switch Online app: a Nintendo Account ID token, containing your Nintendo Account ID and country, which is valid for 15 minutes +- When authenticating to game-specific services: a Coral (Nintendo Switch Online app) ID token, containing your Coral user ID, Nintendo Switch Online membership status, and Nintendo Account child restriction status, which is valid for 2 hours +`.trim();