diff --git a/src/app/browser/add-account-manual/index.tsx b/src/app/browser/add-account-manual/index.tsx
new file mode 100644
index 0000000..75bf1b6
--- /dev/null
+++ b/src/app/browser/add-account-manual/index.tsx
@@ -0,0 +1,139 @@
+import React, { useCallback, useState } from 'react';
+import { StyleSheet, Text, TextInput, useColorScheme, View } from 'react-native';
+import { Button } from '../components/index.js';
+import { DEFAULT_ACCENT_COLOUR, HIGHLIGHT_COLOUR_DARK, HIGHLIGHT_COLOUR_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
+import ipc, { events } from '../ipc.js';
+import { Root, useEventListener } from '../util.js';
+
+export interface AddAccountManualPromptProps {
+ authoriseurl: string;
+ client_id: string;
+}
+
+export default function AddAccountManualPrompt(props: AddAccountManualPromptProps) {
+ const colour_scheme = useColorScheme();
+ const theme = colour_scheme === 'light' ? light : dark;
+
+ const [accent_colour, setAccentColour] = React.useState(() => ipc.getAccentColour());
+ useEventListener(events, 'systemPreferences:accent-colour', setAccentColour, []);
+
+ useEventListener(events, 'window:refresh', () => true, []);
+
+ const [callback_url, setCallbackUrl] = useState('');
+ const callback_url_valid = callback_url.startsWith('npf' + props.client_id + '://auth');
+
+ const save = useCallback(() => {
+ if (callback_url_valid) {
+ location.href = callback_url;
+ }
+ }, [callback_url, callback_url_valid]);
+
+ return
+
+ 1. Login to your Nintendo Account
+ Do not select an account yet.
+
+
+
+
+ 2. Enter the callback link
+ On the "Linking an External Account" page, right click "Select this person" and copy the link. It should start with "npf{props.client_id}://auth".
+ If you are adding a child account linked to your account, click "Select this person" next to their account to sign in as the child account, then with only the child account showing right click "Select this person" and copy the link.
+
+
+
+
+
+
+ {callback_url_valid ?
+
+ : null}
+
+
+ ;
+}
+
+const styles = StyleSheet.create({
+ loading: {
+ flex: 1,
+ paddingVertical: 50,
+ paddingHorizontal: 20,
+ justifyContent: 'center',
+ },
+
+ main: {
+ flex: 1,
+ paddingVertical: 20,
+ paddingHorizontal: 20,
+ },
+
+ header: {
+ marginTop: 12,
+ },
+ help: {
+ marginTop: 8,
+ fontSize: 13,
+ opacity: 0.7,
+ },
+
+ textInput: {
+ marginTop: 8,
+ paddingVertical: 5,
+ paddingHorizontal: 10,
+ borderRadius: 3,
+ fontSize: 13,
+ },
+
+ buttons: {
+ marginTop: 20,
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ },
+ button: {
+ marginLeft: 10,
+ },
+ buttonSingle: {
+ marginTop: 10,
+ marginRight: 10,
+ flexDirection: 'row',
+ },
+});
+
+const light = StyleSheet.create({
+ text: {
+ color: TEXT_COLOUR_LIGHT,
+ },
+ picker: {
+ backgroundColor: HIGHLIGHT_COLOUR_LIGHT,
+ color: TEXT_COLOUR_LIGHT,
+ },
+ textInput: {
+ backgroundColor: HIGHLIGHT_COLOUR_LIGHT,
+ color: TEXT_COLOUR_LIGHT,
+ },
+});
+
+const dark = StyleSheet.create({
+ text: {
+ color: TEXT_COLOUR_DARK,
+ },
+ picker: {
+ backgroundColor: HIGHLIGHT_COLOUR_DARK,
+ color: TEXT_COLOUR_DARK,
+ },
+ textInput: {
+ backgroundColor: HIGHLIGHT_COLOUR_DARK,
+ color: TEXT_COLOUR_DARK,
+ },
+});
diff --git a/src/app/browser/index.ts b/src/app/browser/index.ts
index 189cec4..c1d7f0c 100644
--- a/src/app/browser/index.ts
+++ b/src/app/browser/index.ts
@@ -5,12 +5,14 @@ import Friend from './friend/index.js';
import DiscordSetup from './discord/index.js';
import AddFriend from './add-friend/index.js';
import Preferences from './preferences/index.js';
+import AddAccountManualPrompt from './add-account-manual/index.js';
AppRegistry.registerComponent('App', () => App);
AppRegistry.registerComponent('Friend', () => Friend);
AppRegistry.registerComponent('DiscordPresence', () => DiscordSetup);
AppRegistry.registerComponent('AddFriend', () => AddFriend);
AppRegistry.registerComponent('Preferences', () => Preferences);
+AppRegistry.registerComponent('AddAccountManualPrompt', () => AddAccountManualPrompt);
const style = window.document.createElement('style');
diff --git a/src/app/common/types.ts b/src/app/common/types.ts
index bbc6972..347cdc9 100644
--- a/src/app/common/types.ts
+++ b/src/app/common/types.ts
@@ -6,6 +6,7 @@ export enum WindowType {
DISCORD_PRESENCE = 'DiscordPresence',
ADD_FRIEND = 'AddFriend',
PREFERENCES = 'Preferences',
+ ADD_ACCOUNT_MANUAL_PROMPT = 'AddAccountManualPrompt',
}
interface WindowProps {
@@ -14,6 +15,7 @@ interface WindowProps {
[WindowType.DISCORD_PRESENCE]: import('../browser/discord/index.js').DiscordSetupProps;
[WindowType.ADD_FRIEND]: import('../browser/add-friend/index.js').AddFriendProps;
[WindowType.PREFERENCES]: import('../browser/preferences/index.js').PreferencesProps;
+ [WindowType.ADD_ACCOUNT_MANUAL_PROMPT]: import('../browser/add-account-manual/index.js').AddAccountManualPromptProps;
}
export interface WindowConfiguration {
diff --git a/src/app/main/na-auth.ts b/src/app/main/na-auth.ts
index ee47a92..df769d6 100644
--- a/src/app/main/na-auth.ts
+++ b/src/app/main/na-auth.ts
@@ -11,6 +11,8 @@ import { getPctlToken, SavedMoonToken } from '../../common/auth/moon.js';
import { Jwt } from '../../util/jwt.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { ZNCA_API_USE_URL } from '../../common/constants.js';
+import { createWindow } from './windows.js';
+import { WindowType } from '../common/types.js';
const debug = createDebug('app:main:na-auth');
@@ -199,9 +201,16 @@ export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: stri
});
}
-export function getSessionTokenCodeByDefaultBrowser(client_id: string, scope: string | string[], close_window = true) {
+const FORCE_MANUAL_AUTH_URI_ENTRY = process.env.NXAPI_FORCE_MANUAL_AUTH === '1';
+
+export function getSessionTokenCodeByDefaultBrowser(
+ client_id: string, scope: string | string[],
+ close_window = true,
+ force_manual = FORCE_MANUAL_AUTH_URI_ENTRY
+) {
return new Promise((rs, rj) => {
const {url: authoriseurl, state, verifier, challenge} = getAuthUrl(client_id, scope);
+ let window: BrowserWindow | undefined = undefined;
const handleAuthUrl = (url: URL) => {
const authorisedparams = new URLSearchParams(url.hash.substr(1));
@@ -209,16 +218,19 @@ export function getSessionTokenCodeByDefaultBrowser(client_id: string, scope: st
if (authorisedparams.get('state') !== state) {
rj(new Error('Invalid state'));
+ window?.close();
return;
}
if (authorisedparams.has('error')) {
rj(AuthoriseError.fromSearchParams(authorisedparams));
+ window?.close();
return;
}
if (!authorisedparams.has('session_token_code')) {
rj(new Error('Response didn\'t include a session token code'));
+ window?.close();
return;
}
@@ -227,7 +239,9 @@ export function getSessionTokenCodeByDefaultBrowser(client_id: string, scope: st
debug('code', code, jwt, sig);
- rs({code, verifier});
+ if (window && close_window) window.close();
+ else if (window) rs({code, verifier, window});
+ else rs({code, verifier});
};
debug('Prompting user for Nintendo Account authorisation', {
@@ -239,7 +253,10 @@ export function getSessionTokenCodeByDefaultBrowser(client_id: string, scope: st
const protocol = 'npf' + client_id;
- if (app.isDefaultProtocolClient(protocol)) {
+ if (force_manual) {
+ debug('Manual entry forced, prompting for redirect URI');
+ window = askUserForRedirectUri(authoriseurl, client_id, handleAuthUrl, rj);
+ } else if (app.isDefaultProtocolClient(protocol)) {
debug('App is already default protocol handler, opening browser');
auth_state.set(state, [handleAuthUrl, rj, protocol]);
shell.openExternal(authoriseurl);
@@ -247,12 +264,12 @@ export function getSessionTokenCodeByDefaultBrowser(client_id: string, scope: st
const registered_app = app.getApplicationNameForProtocol(protocol);
if (registered_app || !app.setAsDefaultProtocolClient(protocol)) {
+ debug('Another app is using the auth protocol or registration failed, prompting for redirect URI');
+ window = askUserForRedirectUri(authoriseurl, client_id, handleAuthUrl, rj);
+ } else {
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);
}
}
});
@@ -285,8 +302,42 @@ app.on('quit', () => {
}
});
-function askUserForRedirectUri(authoriseurl: string, rs: (url: URL) => void, rj: (reason: any) => void) {
- rj(new Error('Not implemented'));
+function askUserForRedirectUri(
+ authoriseurl: string, client_id: string,
+ handleAuthUrl: (url: URL) => void, rj: (reason: any) => void
+) {
+ const window = createWindow(WindowType.ADD_ACCOUNT_MANUAL_PROMPT, {
+ authoriseurl,
+ client_id,
+ }, {
+ show: false,
+ maximizable: false,
+ minimizable: false,
+ width: 560,
+ height: 300,
+ minWidth: 450,
+ maxWidth: 700,
+ minHeight: 300,
+ maxHeight: 300,
+ });
+
+ window.webContents.on('will-navigate', (event, url_string) => {
+ event.preventDefault();
+
+ const url = new URL(url_string);
+
+ debug('will navigate', url);
+
+ if (url.protocol === 'npf' + client_id + ':' && url.host === 'auth') {
+ handleAuthUrl(url);
+ }
+ });
+
+ window.on('closed', () => {
+ rj(new AuthoriseCancelError('Canceled'));
+ });
+
+ return window;
}
const NSO_SCOPE = [
@@ -300,7 +351,7 @@ const NSO_SCOPE = [
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);
+ await getSessionTokenCodeByDefaultBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false);
window?.setFocusable(false);
window?.blurWebView();
@@ -439,7 +490,7 @@ const MOON_SCOPE = [
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);
+ await getSessionTokenCodeByDefaultBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false);
window?.setFocusable(false);
window?.blurWebView();