From ca0b063f589eb405efba7f8091779f72c58c76d9 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Sun, 11 Sep 2022 23:13:23 +0100 Subject: [PATCH] Allow adding an account using the default browser by holding shift when pressing the add account button --- src/app/main/electron.ts | 1 + src/app/main/index.ts | 6 ++ src/app/main/ipc.ts | 10 +++- src/app/main/menu.ts | 8 ++- src/app/main/na-auth.ts | 126 ++++++++++++++++++++++++++++++++++----- 5 files changed, 129 insertions(+), 22 deletions(-) diff --git a/src/app/main/electron.ts b/src/app/main/electron.ts index f4b229a..ffce67d 100644 --- a/src/app/main/electron.ts +++ b/src/app/main/electron.ts @@ -23,6 +23,7 @@ export type BrowserWindow = import('electron').BrowserWindow; export type BrowserWindowConstructorOptions = import('electron').BrowserWindowConstructorOptions; export type IpcMain = import('electron').IpcMain; export type IpcMainInvokeEvent = import('electron').IpcMainInvokeEvent; +export type KeyboardEvent = import('electron').KeyboardEvent; export type LoginItemSettings = import('electron').LoginItemSettings; export type LoginItemSettingsOptions = import('electron').LoginItemSettingsOptions; export type Menu = import('electron').Menu; diff --git a/src/app/main/index.ts b/src/app/main/index.ts index 04b1eba..514b062 100644 --- a/src/app/main/index.ts +++ b/src/app/main/index.ts @@ -19,6 +19,7 @@ import { dev, dir } from '../../util/product.js'; import { addUserAgent } from '../../util/useragent.js'; import { askUserForUri } from './util.js'; import { setAppInstance } from './app-menu.js'; +import { handleAuthUri } from './na-auth.js'; const debug = createDebug('app:main'); @@ -174,6 +175,11 @@ export async function init() { } function tryHandleUrl(app: App, url: string) { + if (url.match(/^npf[0-9a-f]{16}:\/\/auth($|\?|\#)/i)) { + handleAuthUri(url); + return true; + } + if (url.match(/^com\.nintendo\.znca:\/\/(znca\/)?game\/(\d+)\/?($|\?|\#)/i)) { handleOpenWebServiceUri(app.store, url); return true; diff --git a/src/app/main/ipc.ts b/src/app/main/ipc.ts index 1aa596b..caca4f3 100644 --- a/src/app/main/ipc.ts +++ b/src/app/main/ipc.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, clipboard, dialog, IpcMain, LoginItemSettings, Menu, MenuItem, Settings, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js'; +import { app, BrowserWindow, clipboard, dialog, IpcMain, KeyboardEvent, LoginItemSettings, Menu, MenuItem, Settings, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js'; import * as util from 'node:util'; import createDebug from 'debug'; import { User } from 'discord-rpc'; @@ -171,8 +171,12 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) { (buildUserMenu(appinstance, user, nso, moon, BrowserWindow.fromWebContents(e.sender) ?? undefined) .popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined)); ipcMain.handle('nxapi:menu:add-user', e => (Menu.buildFromTemplate([ - new MenuItem({label: 'Add Nintendo Switch Online account', click: () => askAddNsoAccount(storage)}), - new MenuItem({label: 'Add Nintendo Switch Parental Controls account', click: () => askAddPctlAccount(storage)}), + new MenuItem({label: 'Add Nintendo Switch Online account', click: + (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) => + askAddNsoAccount(storage, !event.shiftKey)}), + new MenuItem({label: 'Add Nintendo Switch Parental Controls account', click: + (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) => + askAddPctlAccount(storage, !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 bdea303..f187b9b 100644 --- a/src/app/main/menu.ts +++ b/src/app/main/menu.ts @@ -1,4 +1,4 @@ -import { app, dialog, Menu, Tray, nativeImage, MenuItem } from './electron.js'; +import { app, dialog, Menu, Tray, nativeImage, MenuItem, BrowserWindow, KeyboardEvent } from './electron.js'; import path from 'node:path'; import * as util from 'node:util'; import createDebug from 'debug'; @@ -115,8 +115,10 @@ export default class MenuApp { this.tray.setContextMenu(menu); } - addNsoAccount = () => askAddNsoAccount(this.app.store.storage); - addPctlAccount = () => askAddPctlAccount(this.app.store.storage); + addNsoAccount = (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) => + askAddNsoAccount(this.app.store.storage, !event.shiftKey); + addPctlAccount = (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) => + askAddPctlAccount(this.app.store.storage, !event.shiftKey); // Hardcode these temporarily until they are cached webservices: WebService[] | null = [ diff --git a/src/app/main/na-auth.ts b/src/app/main/na-auth.ts index dfb8960..ee47a92 100644 --- a/src/app/main/na-auth.ts +++ b/src/app/main/na-auth.ts @@ -2,7 +2,7 @@ import process from 'node:process'; import * as crypto from 'node:crypto'; import createDebug from 'debug'; import * as persist from 'node-persist'; -import { BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell } from './electron.js'; +import { app, BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell } from './electron.js'; import { getNintendoAccountSessionToken, NintendoAccountSessionToken } from '../../api/na.js'; import { ZNCA_CLIENT_ID } from '../../api/coral.js'; import { ZNMA_CLIENT_ID } from '../../api/moon.js'; @@ -97,13 +97,13 @@ export class AuthoriseCancelError extends AuthoriseError { } } -export function getSessionTokenCode(client_id: string, scope: string | string[], close_window: false): +export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window: false): Promise -export function getSessionTokenCode(client_id: string, scope: string | string[], close_window: true): +export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window: true): Promise -export function getSessionTokenCode(client_id: string, scope: string | string[], close_window?: boolean): +export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window?: boolean): Promise -export function getSessionTokenCode(client_id: string, scope: string | string[], close_window = true) { +export function getSessionTokenCodeByInAppBrowser(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(); @@ -199,6 +199,96 @@ export function getSessionTokenCode(client_id: string, scope: string | string[], }); } +export function getSessionTokenCodeByDefaultBrowser(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 handleAuthUrl = (url: URL) => { + const authorisedparams = new URLSearchParams(url.hash.substr(1)); + debug('Redirect URL parameters', [...authorisedparams.entries()]); + + if (authorisedparams.get('state') !== state) { + rj(new Error('Invalid state')); + return; + } + + if (authorisedparams.has('error')) { + rj(AuthoriseError.fromSearchParams(authorisedparams)); + return; + } + + if (!authorisedparams.has('session_token_code')) { + rj(new Error('Response didn\'t include a session token code')); + return; + } + + const code = authorisedparams.get('session_token_code')!; + const [jwt, sig] = Jwt.decode(code); + + debug('code', code, jwt, sig); + + rs({code, verifier}); + }; + + debug('Prompting user for Nintendo Account authorisation', { + authoriseurl, + state, + verifier, + challenge, + }); + + const protocol = 'npf' + client_id; + + if (app.isDefaultProtocolClient(protocol)) { + debug('App is already default protocol handler, opening browser'); + auth_state.set(state, [handleAuthUrl, rj, protocol]); + shell.openExternal(authoriseurl); + } else { + const registered_app = app.getApplicationNameForProtocol(protocol); + + if (registered_app || !app.setAsDefaultProtocolClient(protocol)) { + debug('App is now default protocol handler, opening browser'); + auth_state.set(state, [handleAuthUrl, rj, protocol]); + shell.openExternal(authoriseurl); + } else { + debug('Another app is using the auth protocol or registration failed, prompting for redirect URI'); + askUserForRedirectUri(authoriseurl, handleAuthUrl, rj); + } + } + }); +} + +const auth_state = new Map void, rj: (reason: any) => void, protocol: string]>(); + +export function handleAuthUri(url_string: string) { + const url = new URL(url_string); + const qs = new URLSearchParams(url.hash.substr(1)); + + debug('Received auth URL', url, qs.entries()); + + const state_str = qs.get('state'); + if (!state_str) return; + const state = auth_state.get(state_str); + if (!state) return; + + debug('Received valid auth URL with state', state_str); + + auth_state.delete(state_str); + const [rs, rj, protocol] = state; + + rs(url); +} + +app.on('quit', () => { + for (const [,, protocol] of auth_state.values()) { + app.removeAsDefaultProtocolClient(protocol); + } +}); + +function askUserForRedirectUri(authoriseurl: string, rs: (url: URL) => void, rj: (reason: any) => void) { + rj(new Error('Not implemented')); +} + const NSO_SCOPE = [ 'openid', 'user', @@ -207,11 +297,13 @@ const NSO_SCOPE = [ 'user.screenName', ]; -export async function addNsoAccount(storage: persist.LocalStorage) { - const {code, verifier, window} = await getSessionTokenCode(ZNCA_CLIENT_ID, NSO_SCOPE, false); +export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_browser = true) { + const {code, verifier, window} = use_in_app_browser ? + await getSessionTokenCodeByInAppBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false) : + await getSessionTokenCodeByDefaultBrowser(ZNCA_CLIENT_ID, NSO_SCOPE); - window.setFocusable(false); - window.blurWebView(); + window?.setFocusable(false); + window?.blurWebView(); try { const [jwt, sig] = Jwt.decode(code); @@ -252,13 +344,13 @@ export async function addNsoAccount(storage: persist.LocalStorage) { return {nso, data}; } finally { - window.close(); + window?.close(); } } -export async function askAddNsoAccount(storage: persist.LocalStorage) { +export async function askAddNsoAccount(storage: persist.LocalStorage, iab = true) { try { - return await addNsoAccount(storage); + return await addNsoAccount(storage, iab); } catch (err: any) { if (err instanceof AuthoriseError && err.code === 'access_denied') return; @@ -344,8 +436,10 @@ const MOON_SCOPE = [ 'moonMonthlySummary', ]; -export async function addPctlAccount(storage: persist.LocalStorage) { - const {code, verifier, window} = await getSessionTokenCode(ZNMA_CLIENT_ID, MOON_SCOPE, false); +export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_browser = true) { + const {code, verifier, window} = use_in_app_browser ? + await getSessionTokenCodeByInAppBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false) : + await getSessionTokenCodeByDefaultBrowser(ZNMA_CLIENT_ID, MOON_SCOPE); window?.setFocusable(false); window?.blurWebView(); @@ -389,9 +483,9 @@ export async function addPctlAccount(storage: persist.LocalStorage) { } } -export async function askAddPctlAccount(storage: persist.LocalStorage) { +export async function askAddPctlAccount(storage: persist.LocalStorage, iab = true) { try { - return await addPctlAccount(storage); + return await addPctlAccount(storage, iab); } catch (err: any) { if (err instanceof AuthoriseError && err.code === 'access_denied') return;