Merge branch 'main' into i18n

# Conflicts:
#	package-lock.json
#	package.json
#	src/app/browser/main/discord-setup.tsx
#	src/app/browser/main/discord.tsx
#	src/app/main/app-menu.ts
#	src/app/main/index.ts
#	src/app/main/ipc.ts
#	src/app/main/menu.ts
#	src/app/main/monitor.ts
#	src/app/main/na-auth.ts
#	src/app/main/util.ts
#	src/app/main/webservices.ts
#	src/app/main/windows.ts
This commit is contained in:
Samuel Elliott
2024-10-15 23:31:28 +01:00
179 changed files with 13955 additions and 6527 deletions

View File

@@ -34,6 +34,6 @@ export function NintendoSwitchUsers(props: {
const styles = StyleSheet.create({
userImage: {
borderRadius: 8,
textAlignVertical: -3,
verticalAlign: -3,
},
});

View File

@@ -1,4 +1,4 @@
import { EventEmitter } from 'events';
import { EventEmitter } from 'node:events';
import createDebug from 'debug';
import type { NxapiElectronIpc } from '../preload/index.js';

View File

@@ -1,25 +1,41 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Trans, useTranslation } from 'react-i18next';
import { User } from 'discord-rpc';
import ipc, { events } from '../ipc.js';
import { RequestState, useAsync, useEventListener } from '../util.js';
import { DiscordPresenceSource, DiscordPresenceSourceUrl, DiscordPresenceSourceCoral } from '../../common/types.js';
import { DiscordPresenceSource, DiscordPresenceSourceUrl, DiscordPresenceSourceCoral, DiscordStatus } from '../../common/types.js';
import { DiscordPresence } from '../../../discord/types.js';
import { DISCORD_COLOUR, TEXT_COLOUR_DARK } from '../constants.js';
import { NintendoSwitchUser } from '../components/index.js';
import Warning from '../components/icons/warning.js';
export default function DiscordPresenceSource(props: {
source: DiscordPresenceSource | null;
presence: DiscordPresence | null;
user: User | null;
}) {
const [status, setStatus] = useState<DiscordStatus | null>(null);
useEffect(() => {
ipc.getDiscordStatus().then(setStatus);
}, [ipc]);
useEventListener(events, 'update-discord-status', setStatus, []);
const showErrorDetails = useCallback(() => {
ipc.showDiscordLastUpdateError();
}, [ipc]);
if (!props.source) return null;
return <TouchableOpacity onPress={() => ipc.showDiscordModal()}>
<View style={[styles.discord, !props.source ? styles.discordInactive : null]}>
{renderDiscordPresenceSource(props.source)}
{props.presence || props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
{status?.error_message ?
<DiscordPresenceError message={status?.error_message} onPress={showErrorDetails} /> : null}
</View>
</TouchableOpacity>;
}
@@ -99,7 +115,7 @@ function DiscordPresence(props: {
const user_image_url = props.user ?
props.user.avatar ? 'https://cdn.discordapp.com/avatars/' + props.user.id + '/' + props.user.avatar + '.png' :
!props.user.discriminator || props.user.discriminator === '0' ?
'https://cdn.discordapp.com/embed/avatars/' + ((parseInt(props.user.id) >> 22) % 5) + '.png' :
'https://cdn.discordapp.com/embed/avatars/' + ((parseInt(props.user.id) >> 22) % 6) + '.png' :
'https://cdn.discordapp.com/embed/avatars/' + (parseInt(props.user.discriminator) % 5) + '.png' : undefined;
return <View style={styles.discordPresenceContainer}>
@@ -111,7 +127,9 @@ function DiscordPresence(props: {
{props.user ? <View style={styles.discordUser}>
<Image source={{uri: user_image_url, width: 18, height: 18}} style={styles.discordUserImage} />
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">
{props.user.username}<Text style={styles.discordUserDiscriminator}>#{props.user.discriminator}</Text>
{props.user.username}
{props.user.discriminator && props.user.discriminator !== '0' ?
<Text style={styles.discordUserDiscriminator}>#{props.user.discriminator}</Text> : null}
</Text>
</View> : <View style={styles.discordUser}>
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">{t('discord_not_connected')}</Text>
@@ -119,6 +137,18 @@ function DiscordPresence(props: {
</View>;
}
function DiscordPresenceError(props: {
message: string;
onPress?: () => void;
}) {
return <TouchableOpacity onPress={props.onPress} style={styles.errorTouchable}>
<View style={styles.error}>
<Text style={styles.icon}><Warning /></Text>
<Text style={styles.errorText} numberOfLines={1} ellipsizeMode="tail">{props.message}</Text>
</View>
</TouchableOpacity>;
}
const styles = StyleSheet.create({
discord: {
backgroundColor: DISCORD_COLOUR,
@@ -172,4 +202,23 @@ const styles = StyleSheet.create({
discordUserDiscriminator: {
opacity: 0.7,
},
errorTouchable: {
marginVertical: -16,
marginHorizontal: -20,
marginTop: 6,
paddingVertical: 16,
paddingHorizontal: 20,
paddingTop: 10,
},
error: {
flexDirection: 'row',
},
icon: {
marginRight: 10,
color: TEXT_COLOUR_DARK,
},
errorText: {
color: TEXT_COLOUR_DARK,
},
});

View File

@@ -116,7 +116,8 @@ function _Preferences(props: {
value={discord_options.user} />);
}
for (const user of discord_users ?? []) {
discord_user_picker.push(<Picker.Item key={user.id} label={user.username + '#' + user.discriminator}
discord_user_picker.push(<Picker.Item key={user.id}
label={user.username + (user.discriminator && user.discriminator !== '0' ? '#' + user.discriminator : '')}
value={user.id} />);
}

View File

@@ -146,9 +146,11 @@ function WindowTitle(props: {
const styles = StyleSheet.create({
app: {
// @ts-expect-error vh unit only supported on web
height: Platform.OS === 'web' ? '100vh' : '100%',
},
appScrollable: {
// @ts-expect-error vh unit only supported on web
minHeight: Platform.OS === 'web' ? '100vh' : '100%',
},
});

View File

@@ -48,6 +48,10 @@ export interface DiscordPresenceExternalMonitorsConfiguration {
enable_splatnet3_monitoring?: boolean;
}
export interface DiscordStatus {
error_message: string | null;
}
export interface LoginItem {
supported: boolean;
startup_enabled: boolean;

View File

@@ -1,6 +1,6 @@
import { i18n } from 'i18next';
import { GITHUB_MIRROR_URL, GITLAB_URL, ISSUES_URL } from '../../common/constants.js';
import { app, BrowserWindow, Menu, MenuItem, shell } from './electron.js';
import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron';
import { App } from './index.js';
let appinstance: App | null;

View File

@@ -1,37 +0,0 @@
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const electron = require('electron');
export const app = electron.app;
export const BrowserWindow = electron.BrowserWindow;
export const clipboard = electron.clipboard;
export const dialog = electron.dialog;
export const ipcMain = electron.ipcMain;
export const Menu = electron.Menu;
export const MenuItem = electron.MenuItem;
export const nativeImage = electron.nativeImage;
export const nativeTheme = electron.nativeTheme;
export const Notification = electron.Notification;
export const session = electron.session;
export const ShareMenu = electron.ShareMenu;
export const shell = electron.shell;
export const systemPreferences = electron.systemPreferences;
export const Tray = electron.Tray;
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;
export type MenuItem = import('electron').MenuItem;
export type MessageBoxOptions = import('electron').MessageBoxOptions;
export type Notification = import('electron').Notification;
export type Settings = import('electron').Settings;
export type ShareMenu = import('electron').ShareMenu;
export type SharingItem = import('electron').SharingItem;
export type Tray = import('electron').Tray;
export type WebContents = import('electron').WebContents;

View File

@@ -1,7 +1,8 @@
import { app, BrowserWindow, dialog, ipcMain, LoginItemSettingsOptions, Menu } from './electron.js';
import { app, BrowserWindow, ipcMain, session, Settings } from 'electron';
import process from 'node:process';
import * as path from 'node:path';
import { EventEmitter } from 'node:events';
import { setGlobalDispatcher } from 'undici';
import * as persist from 'node-persist';
import { i18n } from 'i18next';
import MenuApp from './menu.js';
@@ -9,7 +10,7 @@ import { handleOpenWebServiceUri } from './webservices.js';
import { EmbeddedPresenceMonitor, PresenceMonitorManager } from './monitor.js';
import { createModalWindow, createWindow } from './windows.js';
import { sendToAllWindows, setupIpc } from './ipc.js';
import { askUserForUri } from './util.js';
import { askUserForUri, buildElectronProxyAgent, showErrorDialog } from './util.js';
import { setAppInstance, updateMenuLanguage } from './app-menu.js';
import { handleAuthUri } from './na-auth.js';
import { DiscordPresenceConfiguration, LoginItem, LoginItemOptions, WindowType } from '../common/types.js';
@@ -22,6 +23,7 @@ import { dev, dir, git, release, version } from '../../util/product.js';
import { addUserAgent } from '../../util/useragent.js';
import { initStorage, paths } from '../../util/storage.js';
import createI18n, { languages } from '../i18n/index.js';
import { CoralApiInterface } from '../../api/coral.js';
const debug = createDebug('app:main');
@@ -31,7 +33,7 @@ export const protocol_registration_options = dev && process.platform === 'win32'
path.join(dir, 'dist', 'app', 'app-entry.cjs'),
],
} : null;
export const login_item_options: LoginItemSettingsOptions = {
export const login_item_options: Settings = {
path: process.execPath,
args: dev ? [
path.join(dir, 'dist', 'app', 'app-entry.cjs'),
@@ -164,6 +166,12 @@ export async function init() {
addUserAgent('nxapi-app (Chromium ' + process.versions.chrome + '; Electron ' + process.versions.electron + ')');
setAboutPanelOptions();
const agent = buildElectronProxyAgent({
session: session.defaultSession,
});
setGlobalDispatcher(agent);
app.configureHostResolver({enableBuiltInResolver: false});
const [storage, i18n] = await Promise.all([
@@ -319,7 +327,7 @@ interface SavedMonitorState {
}
export class Store extends EventEmitter {
readonly users: Users<CoralUser>;
readonly users: Users<CoralUser<CoralApiInterface>>;
constructor(
readonly app: App,
@@ -471,10 +479,9 @@ export class Store extends EventEmitter {
} catch (err) {
debug('Error restoring monitor for user %s', user.id, err);
const {response} = await dialog.showMessageBox({
const {response} = await showErrorDialog({
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for user ' + user.id,
detail: err instanceof Error ? err.stack ?? err.message : err as any,
type: 'error',
error: err,
buttons: ['OK', 'Retry'],
defaultId: 1,
});
@@ -500,10 +507,10 @@ export class Store extends EventEmitter {
} catch (err) {
debug('Error restoring monitor for presence URL %s', state.discord_presence.source.url, err);
const {response} = await dialog.showMessageBox({
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for presence URL ' + state.discord_presence.source.url,
detail: err instanceof Error ? err.stack ?? err.message : err as any,
type: 'error',
const {response} = await showErrorDialog({
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for presence URL ' +
state.discord_presence.source.url,
error: err,
buttons: ['OK', 'Retry'],
defaultId: 1,
});

View File

@@ -1,12 +1,11 @@
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, IpcMainInvokeEvent, KeyboardEvent, Menu, MenuItem, ShareMenu, SharingItem, shell, systemPreferences } from 'electron';
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';
import { DiscordPresenceConfiguration, DiscordPresenceSource, LoginItemOptions, WindowType } from '../common/types.js';
import { DiscordPresenceConfiguration, DiscordPresenceSource, DiscordStatus, LoginItemOptions, WindowType } from '../common/types.js';
import { CurrentUser, Friend, Game, PresenceState, WebService } from '../../api/coral-types.js';
import { NintendoAccountUser } from '../../api/na.js';
import createDebug from '../../util/debug.js';
@@ -16,6 +15,8 @@ import { defaultTitle } from '../../discord/titles.js';
import type { FriendProps } from '../browser/friend/index.js';
import type { DiscordSetupProps } from '../browser/discord/index.js';
import type { AddFriendProps } from '../browser/add-friend/index.js';
import { MembershipRequiredError } from '../../common/auth/util.js';
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../../util/errors.js';
const debug = createDebug('app:main:ipc');
@@ -39,70 +40,80 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
sendToAllWindows('nxapi:systemPreferences:accent-colour', accent_colour);
});
ipcMain.handle('nxapi:systemPreferences:getloginitem', () => appinstance.store.getLoginItem());
ipcMain.handle('nxapi:systemPreferences:setloginitem', (e, settings: LoginItemOptions) => appinstance.store.setLoginItem(settings));
const handle = (channel: string, listener: (event: IpcMainInvokeEvent, ...args: any[]) => unknown) => ipcMain.handle('nxapi:' + channel, async (event, ...args) => {
try {
return {result: await listener.call(null, event, ...args)};
} catch (err) {
debug('Error invoking IPC method', channel, err);
ipcMain.handle('nxapi:update:get', () => appinstance.updater.cache ?? appinstance.updater.check());
ipcMain.handle('nxapi:update:check', () => appinstance.updater.check());
if (!(err instanceof Error)) err = new Error(ErrorDescription.getErrorDescription(err));
const description = err instanceof HasErrorDescription ? err[ErrorDescriptionSymbol] : null;
return {
error_type: (err as Error).constructor.name,
message: (err as Error).message,
type: description?.type,
description: ErrorDescription.getErrorDescription(err),
data: err,
};
}
});
handle('systemPreferences:getloginitem', () => appinstance.store.getLoginItem());
handle('systemPreferences:setloginitem', (e, settings: LoginItemOptions) => appinstance.store.setLoginItem(settings));
handle('update:get', () => appinstance.updater.cache ?? appinstance.updater.check());
handle('update:check', () => appinstance.updater.check());
setTimeout(async () => {
const update = await appinstance.updater.check();
if (update) sendToAllWindows('nxapi:update:latest', update);
}, 60 * 60 * 1000);
ipcMain.handle('nxapi:accounts:list', () => storage.getItem('NintendoAccountIds'));
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));
handle('accounts:list', () => storage.getItem('NintendoAccountIds'));
handle('accounts:add-coral', () => askAddNsoAccount(appinstance).then(u => u?.data.user.id));
handle('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));
ipcMain.handle('nxapi:coral:announcements', (e, token: string) => store.users.get(token).then(u => u.announcements.result));
ipcMain.handle('nxapi:coral:friends', (e, token: string) => store.users.get(token).then(u => u.getFriends()));
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) =>
handle('coral:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id));
handle('coral:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token));
handle('coral:announcements', (e, token: string) => store.users.get(token).then(u => u.announcements.result));
handle('coral:friends', (e, token: string) => store.users.get(token).then(u => u.getFriends()));
handle('coral:webservices', (e, token: string) => store.users.get(token).then(u => u.getWebServices()));
handle('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)));
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)));
ipcMain.handle('nxapi:coral:addfriend', (e, token: string, nsaid: string) => store.users.get(token).then(u => u.addFriend(nsaid)));
.catch(err => err instanceof WebServiceValidationError || err instanceof MembershipRequiredError ?
handleOpenWebServiceError(err, webservice, qs, u.data, BrowserWindow.fromWebContents(e.sender)!) :
null)));
handle('coral:activeevent', (e, token: string) => store.users.get(token).then(u => u.getActiveEvent()));
handle('coral:friendcodeurl', (e, token: string) => store.users.get(token).then(u => u.nso.getFriendCodeUrl()));
handle('coral:friendcode', (e, token: string, friendcode: string, hash?: string) => store.users.get(token).then(u => u.nso.getUserByFriendCode(friendcode, hash)));
handle('coral:addfriend', (e, token: string, nsaid: string) => store.users.get(token).then(u => u.addFriend(nsaid)));
ipcMain.handle('nxapi:window:showpreferences', () => appinstance.showPreferencesWindow().id);
ipcMain.handle('nxapi:window:showfriend', (e, props: FriendProps) =>
handle('window:showpreferences', () => appinstance.showPreferencesWindow().id);
handle('window:showfriend', (e, props: FriendProps) =>
createModalWindow(WindowType.FRIEND, props, e.sender).id);
ipcMain.handle('nxapi:window:discord', (e, props: DiscordSetupProps) =>
handle('window:discord', (e, props: DiscordSetupProps) =>
createModalWindow(WindowType.DISCORD_PRESENCE, props).id);
ipcMain.handle('nxapi:window:addfriend', (e, props: AddFriendProps) =>
handle('window:addfriend', (e, props: AddFriendProps) =>
createModalWindow(WindowType.ADD_FRIEND, props, e.sender).id);
ipcMain.handle('nxapi:window:setheight', (e, height: number) => {
handle('window:setheight', (e, height: number) => {
const window = BrowserWindow.fromWebContents(e.sender)!;
setWindowHeight(window, height);
});
ipcMain.handle('nxapi:discord:config', () => appinstance.monitors.getDiscordPresenceConfiguration());
ipcMain.handle('nxapi:discord:setconfig', (e, config: DiscordPresenceConfiguration | null) => appinstance.monitors.setDiscordPresenceConfiguration(config));
ipcMain.handle('nxapi:discord:options', () => appinstance.monitors.getActiveDiscordPresenceOptions() ?? appinstance.store.getSavedDiscordPresenceOptions());
ipcMain.handle('nxapi:discord:savedoptions', () => appinstance.store.getSavedDiscordPresenceOptions());
ipcMain.handle('nxapi:discord:setoptions', (e, options: Omit<DiscordPresenceConfiguration, 'source'>) => appinstance.monitors.setDiscordPresenceOptions(options));
ipcMain.handle('nxapi:discord:source', () => appinstance.monitors.getDiscordPresenceSource());
ipcMain.handle('nxapi:discord:setsource', (e, source: DiscordPresenceSource | null) => appinstance.monitors.setDiscordPresenceSource(source));
ipcMain.handle('nxapi:discord:presence', () => appinstance.monitors.getDiscordPresence());
ipcMain.handle('nxapi:discord:user', () => appinstance.monitors.getActiveDiscordPresenceMonitor()?.discord.rpc?.client.user ?? null);
ipcMain.handle('nxapi:discord:users', async () => {
handle('discord:config', () => appinstance.monitors.getDiscordPresenceConfiguration());
handle('discord:setconfig', (e, config: DiscordPresenceConfiguration | null) => appinstance.monitors.setDiscordPresenceConfiguration(config));
handle('discord:options', () => appinstance.monitors.getActiveDiscordPresenceOptions() ?? appinstance.store.getSavedDiscordPresenceOptions());
handle('discord:savedoptions', () => appinstance.store.getSavedDiscordPresenceOptions());
handle('discord:setoptions', (e, options: Omit<DiscordPresenceConfiguration, 'source'>) => appinstance.monitors.setDiscordPresenceOptions(options));
handle('discord:source', () => appinstance.monitors.getDiscordPresenceSource());
handle('discord:setsource', (e, source: DiscordPresenceSource | null) => appinstance.monitors.setDiscordPresenceSource(source));
handle('discord:presence', () => appinstance.monitors.getDiscordPresence());
handle('discord:status', () => appinstance.monitors.getDiscordStatus());
handle('discord:showerror', () => appinstance.monitors.showDiscordPresenceLastUpdateError());
handle('discord:user', () => appinstance.monitors.getActiveDiscordPresenceMonitor()?.discord.rpc?.client.user ?? null);
handle('discord:users', async () => {
const users: User[] = [];
for (const client of await getDiscordRpcClients()) {
@@ -117,17 +128,17 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
return users;
});
ipcMain.handle('nxapi:moon:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken-pctl.' + id));
ipcMain.handle('nxapi:moon:getcachedtoken', (e, token: string) => storage.getItem('MoonToken.' + token));
handle('moon:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken-pctl.' + id));
handle('moon:getcachedtoken', (e, token: string) => storage.getItem('MoonToken.' + token));
ipcMain.handle('nxapi:misc:open-url', (e, url: string) => shell.openExternal(url));
ipcMain.handle('nxapi:misc:share', (e, item: SharingItem) =>
handle('misc:open-url', (e, url: string) => shell.openExternal(url));
handle('misc:share', (e, item: SharingItem) =>
new ShareMenu(item).popup({window: BrowserWindow.fromWebContents(e.sender)!}));
ipcMain.handle('nxapi:menu:user', (e, user: NintendoAccountUser, nso?: CurrentUser, moon?: boolean) =>
handle('menu:user', (e, user: NintendoAccountUser, nso?: CurrentUser, moon?: boolean) =>
(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([
handle('menu:add-user', e => (Menu.buildFromTemplate([
new MenuItem({label: t('add_account.add_account_coral')!, click:
(item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddNsoAccount(appinstance, !event.shiftKey)}),
@@ -135,7 +146,7 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
(item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddPctlAccount(appinstance, !event.shiftKey)}),
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:friend-code', (e, fc: CurrentUser['links']['friendCode']) => (Menu.buildFromTemplate([
handle('menu:friend-code', (e, fc: CurrentUser['links']['friendCode']) => (Menu.buildFromTemplate([
new MenuItem({label: 'SW-' + fc.id, enabled: false}),
new MenuItem({label: t('friend_code.share')!, role: 'shareMenu', sharingItem: {texts: ['SW-' + fc.id]}}),
new MenuItem({label: t('friend_code.copy')!, click: () => clipboard.writeText('SW-' + fc.id)}),
@@ -146,7 +157,7 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
formatParams: { date: { dateStyle: 'short', timeStyle: 'medium' } },
})!, enabled: false}),
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:friend', (e, user: NintendoAccountUser, nso: CurrentUser, friend: Friend) =>
handle('menu:friend', (e, user: NintendoAccountUser, nso: CurrentUser, friend: Friend) =>
(buildFriendMenu(appinstance, user, nso, friend)
.popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
@@ -163,11 +174,13 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
ipcMain.handle('nxapi:webserviceapi:copyToClipboard', (e, data: string) => webserviceipc.copyToClipboard(e, data));
ipcMain.handle('nxapi:webserviceapi:downloadImages', (e, data: string) => webserviceipc.downloadImages(e, data));
ipcMain.handle('nxapi:webserviceapi:completeLoading', e => webserviceipc.completeLoading(e));
ipcMain.handle('nxapi:webserviceapi:clearUnreadFlag', e => webserviceipc.clearUnreadFlag(e));
store.on('update-nintendo-accounts', () => sendToAllWindows('nxapi:accounts:shouldrefresh'));
store.on('update-discord-presence-source', () => sendToAllWindows('nxapi:discord:shouldrefresh'));
store.on('update-discord-presence', (p: DiscordPresence) => sendToAllWindows('nxapi:discord:presence', p));
store.on('update-discord-user', (u: User) => sendToAllWindows('nxapi:discord:user', u));
store.on('update-discord-status', (s: DiscordStatus | null) => sendToAllWindows('nxapi:discord:status', s));
}
export function sendToAllWindows(channel: string, ...args: any[]) {

View File

@@ -1,19 +1,19 @@
import { app, dialog, Menu, Tray, nativeImage, MenuItem, BrowserWindow, KeyboardEvent } from './electron.js';
import { app, Menu, Tray, nativeImage, MenuItem, BrowserWindow, KeyboardEvent } from 'electron';
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';
import { CachedWebServicesList } from '../../common/users.js';
import createDebug from '../../util/debug.js';
import { dev, dir, git } from '../../util/product.js';
import { MembershipRequiredError } from '../../common/auth/util.js';
import { languages } from '../i18n/index.js';
const debug = createDebug('app:main:menu');
@@ -171,11 +171,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);
}
},
}));
@@ -184,26 +180,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;
if (!(err instanceof WebServiceValidationError) && !(err instanceof MembershipRequiredError)) 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,9 +1,9 @@
import { dialog, Notification } from './electron.js';
import { Notification } from 'electron';
import { i18n } from 'i18next';
import { App } from './index.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { DiscordPresenceConfiguration, DiscordPresenceExternalMonitorsConfiguration, DiscordPresenceSource } from '../common/types.js';
import { CurrentUser, Friend, Game, CoralErrorResponse } from '../../api/coral-types.js';
import { showErrorDialog, tryGetNativeImageFromUrl } from './util.js';
import { DiscordPresenceConfiguration, DiscordPresenceExternalMonitorsConfiguration, DiscordPresenceSource, DiscordStatus } from '../common/types.js';
import { CurrentUser, Friend, Game, CoralError } from '../../api/coral-types.js';
import { ErrorResponse } from '../../api/util.js';
import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../../common/presence.js';
import { NotificationManager } from '../../common/notify.js';
@@ -12,6 +12,10 @@ import { LoopResult } from '../../util/loop.js';
import { DiscordPresence, DiscordPresencePlayTime, ErrorResult } from '../../discord/types.js';
import { DiscordRpcClient } from '../../discord/rpc.js';
import SplatNet3Monitor, { getConfigFromAppConfig as getSplatNet3MonitorConfigFromAppConfig } from '../../discord/monitor/splatoon3.js';
import { ErrorDescription } from '../../util/errors.js';
import { CoralErrorResponse } from '../../api/coral.js';
import { NintendoAccountAuthErrorResponse, NintendoAccountErrorResponse } from '../../api/na.js';
import { InvalidNintendoAccountTokenError } from '../../common/auth/na.js';
const debug = createDebug('app:main:monitor');
@@ -54,10 +58,9 @@ export class PresenceMonitorManager {
this.app.store.emit('update-discord-user', client?.user ?? null);
};
i.discord.onMonitorError = async (monitor, instance, err) => {
const {response} = await dialog.showMessageBox({
const {response} = await showErrorDialog({
message: err.name + ' in external monitor ' + monitor.name,
detail: err.stack ?? err.message,
type: 'error',
error: err,
buttons: ['OK', 'Retry', 'Stop'],
defaultId: 0,
});
@@ -72,6 +75,19 @@ export class PresenceMonitorManager {
return ErrorResult.IGNORE;
};
i.discord.onUpdateError = err => {
const status: DiscordStatus = {
error_message: err instanceof Error ?
err.name + ': ' + err.message :
ErrorDescription.getErrorDescription(err),
};
this.app.store.emit('update-discord-status', status);
};
i.discord.onUpdateSuccess = () => {
const status: DiscordStatus = {error_message: null};
this.app.store.emit('update-discord-status', status);
};
i.onError = err => this.handleError(i, err);
this.monitors.push(i);
@@ -101,6 +117,19 @@ export class PresenceMonitorManager {
this.app.store.emit('update-discord-user', client?.user ?? null);
};
i.discord.onUpdateError = err => {
const status: DiscordStatus = {
error_message: err instanceof Error ?
err.name + ': ' + err.message :
ErrorDescription.getErrorDescription(err),
};
this.app.store.emit('update-discord-status', status);
};
i.discord.onUpdateSuccess = () => {
const status: DiscordStatus = {error_message: null};
this.app.store.emit('update-discord-status', status);
};
i.onError = err => this.handleError(i, err);
this.monitors.push(i);
@@ -321,6 +350,8 @@ export class PresenceMonitorManager {
this.app.store.saveMonitorState(this);
this.app.menu?.updateMenu();
this.app.store.emit('update-discord-presence-source', source);
} else {
this.app.store.emit('update-discord-status', null);
}
}
@@ -349,12 +380,15 @@ export class PresenceMonitorManager {
async handleError(
monitor: EmbeddedPresenceMonitor | EmbeddedProxyPresenceMonitor,
err: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException
err: ErrorResponse<CoralError> | NodeJS.ErrnoException
): Promise<LoopResult> {
const {response} = await dialog.showMessageBox({
if (monitor instanceof EmbeddedProxyPresenceMonitor || checkShouldIgnorePresenceMonitorError(err)) {
return LoopResult.OK;
}
const {response} = await showErrorDialog({
message: err.name + ' updating presence monitor',
detail: err.stack ?? err.message,
type: 'error',
error: err,
buttons: ['OK', 'Retry'],
defaultId: 0,
});
@@ -365,10 +399,33 @@ export class PresenceMonitorManager {
return LoopResult.OK;
}
async getDiscordStatus(): Promise<DiscordStatus | null> {
const monitor = this.getActiveDiscordPresenceMonitor();
if (!monitor) return null;
return {
error_message: monitor.discord.last_update_error ?
monitor.discord.last_update_error instanceof Error ?
monitor.discord.last_update_error.name + ': ' + monitor.discord.last_update_error.message :
ErrorDescription.getErrorDescription(monitor.discord.last_update_error) : null,
};
}
async showDiscordPresenceLastUpdateError() {
const monitor = this.getActiveDiscordPresenceMonitor();
const error = monitor?.discord.last_update_error;
if (!error) return;
await showErrorDialog({
message: error.name + ' updating presence monitor',
error,
});
}
}
export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
onError?: (error: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException) =>
onError?: (error: ErrorResponse<CoralError> | NodeJS.ErrnoException) =>
Promise<LoopResult | void> | LoopResult | void = undefined;
enable() {
@@ -411,7 +468,7 @@ export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
}
}
async handleError(err: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
async handleError(err: ErrorResponse<CoralError> | NodeJS.ErrnoException): Promise<LoopResult> {
try {
return await super.handleError(err);
} catch (err: any) {
@@ -427,7 +484,7 @@ export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
export class EmbeddedProxyPresenceMonitor extends ZncProxyDiscordPresence {
notifications: NotificationManager | null = null;
onError?: (error: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException) =>
onError?: (error: ErrorResponse<CoralError> | NodeJS.ErrnoException) =>
Promise<LoopResult | void> | LoopResult | void = undefined;
enable() {
@@ -470,7 +527,7 @@ export class EmbeddedProxyPresenceMonitor extends ZncProxyDiscordPresence {
}
}
async handleError(err: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
async handleError(err: ErrorResponse<CoralError> | NodeJS.ErrnoException): Promise<LoopResult> {
try {
return await super.handleError(err);
} catch (err: any) {
@@ -534,3 +591,31 @@ export class ElectronNotificationManager extends NotificationManager {
}).show();
}
}
function checkShouldIgnorePresenceMonitorError(err: Error): boolean {
// Invalid session token, the user needs to sign in again
if (err instanceof InvalidNintendoAccountTokenError) {
return false;
}
// Received error getting a Nintendo Account token; usually this means
// the session token is invalid and the user needs to sign in again
if (err instanceof NintendoAccountAuthErrorResponse && err.data) {
return false;
}
// Received error getting Nintendo Account user data
// This can only happen once when the app starts and there isn't a cached token
if (err instanceof NintendoAccountErrorResponse && err.data) {
return false;
}
// Received error from Coral (see CoralStatus in src/api/coral-types.ts)
// This usually should either not happen (e.g. BAD_REQUEST), is something the
// user needs to do (e.g. NSA_NOT_LINKED or UPGRADE_REQUIRED), or is permanent
if (err instanceof CoralErrorResponse && err.data) {
return false;
}
return true;
}

View File

@@ -1,52 +1,23 @@
import { app, BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell } from './electron.js';
import { app, BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell } from 'electron';
import process from 'node:process';
import * as crypto from 'node:crypto';
import * as persist from 'node-persist';
import { App, protocol_registration_options } from './index.js';
import { createModalWindow, createWindow } from './windows.js';
import { createModalWindow } from './windows.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { WindowType } from '../common/types.js';
import { getNintendoAccountSessionToken, NintendoAccountAuthError, NintendoAccountSessionToken } from '../../api/na.js';
import { ZNCA_CLIENT_ID } from '../../api/coral.js';
import { ZNMA_CLIENT_ID } from '../../api/moon.js';
import { ErrorResponse } from '../../api/util.js';
import { NintendoAccountAuthErrorResponse, NintendoAccountSessionAuthorisation, NintendoAccountSessionAuthorisationError, NintendoAccountSessionToken } from '../../api/na.js';
import { NintendoAccountSessionAuthorisationCoral } from '../../api/coral.js';
import { NintendoAccountSessionAuthorisationMoon } from '../../api/moon.js';
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_TEXT, ZNCA_API_USE_URL } from '../../common/constants.js';
import { InvalidNintendoAccountTokenError } from '../../common/auth/na.js';
const debug = createDebug('app:main:na-auth');
export type NintendoAccountAuthResult = NintendoAccountSessionToken;
export function getAuthUrl(client_id: string, scope: string | string[]) {
const state = crypto.randomBytes(36).toString('base64url');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest().toString('base64url');
const params = {
state,
redirect_uri: 'npf' + client_id + '://auth',
client_id,
scope: typeof scope === 'string' ? scope : scope.join(' '),
response_type: 'session_token_code',
session_token_code_challenge: challenge,
session_token_code_challenge_method: 'S256',
theme: 'login_form',
};
const url = 'https://accounts.nintendo.com/connect/1.0.0/authorize?' +
new URLSearchParams(params).toString();
return {
url,
state,
verifier,
challenge,
};
}
const css = `
html {
overflow-x: hidden;
@@ -79,57 +50,45 @@ export function createAuthWindow(app: App) {
}
export interface NintendoAccountSessionTokenCode {
authenticator: NintendoAccountSessionAuthorisation;
code: string;
verifier: string;
window?: BrowserWindow;
}
export class AuthoriseError extends Error {
constructor(readonly code: string, message?: string) {
super(message);
}
static fromSearchParams(qs: URLSearchParams) {
const code = qs.get('error') ?? 'unknown_error';
return new AuthoriseError(code, qs.get('error_description') ?? code);
}
}
export class AuthoriseCancelError extends AuthoriseError {
export class AuthoriseCancelError extends NintendoAccountSessionAuthorisationError {
constructor(message?: string) {
super('access_denied', message);
}
}
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window: false,
app: App, authenticator: NintendoAccountSessionAuthorisation, close_window: false,
): Promise<NintendoAccountSessionTokenCode & {window: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window: true,
app: App, authenticator: NintendoAccountSessionAuthorisation, close_window: true,
): Promise<NintendoAccountSessionTokenCode & {window?: never}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window?: boolean,
app: App, authenticator: NintendoAccountSessionAuthorisation, close_window?: boolean,
): Promise<NintendoAccountSessionTokenCode & {window?: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window = true,
app: App, authenticator: NintendoAccountSessionAuthorisation, close_window = true,
) {
return new Promise<NintendoAccountSessionTokenCode>((rs, rj) => {
const {url: authoriseurl, state, verifier, challenge} = getAuthUrl(client_id, scope);
const window = createAuthWindow(app);
const handleAuthUrl = (url: URL) => {
const authorisedparams = new URLSearchParams(url.hash.substr(1));
debug('Redirect URL parameters', [...authorisedparams.entries()]);
if (authorisedparams.get('state') !== state) {
if (authorisedparams.get('state') !== authenticator.state) {
rj(new Error('Invalid state'));
window.close();
return;
}
if (authorisedparams.has('error')) {
rj(AuthoriseError.fromSearchParams(authorisedparams));
rj(NintendoAccountSessionAuthorisationError.fromSearchParams(authorisedparams));
window.close();
return;
}
@@ -147,15 +106,15 @@ export function getSessionTokenCodeByInAppBrowser(
if (close_window) {
rs({
authenticator,
code,
verifier,
});
window.close();
} else {
rs({
authenticator,
code,
verifier,
window,
});
}
@@ -166,7 +125,7 @@ export function getSessionTokenCodeByInAppBrowser(
debug('will navigate', url);
if (url.protocol === 'npf' + client_id + ':' && url.host === 'auth') {
if (url.protocol === 'npf' + authenticator.client_id + ':' && url.host === 'auth') {
handleAuthUrl(url);
event.preventDefault();
} else if (url.origin === 'https://accounts.nintendo.com') {
@@ -189,7 +148,7 @@ export function getSessionTokenCodeByInAppBrowser(
debug('open', details);
if (url.protocol === 'npf' + client_id + ':' && url.host === 'auth') {
if (url.protocol === 'npf' + authenticator.client_id + ':' && url.host === 'auth') {
handleAuthUrl(url);
} else {
shell.openExternal(details.url);
@@ -198,40 +157,34 @@ export function getSessionTokenCodeByInAppBrowser(
return {action: 'deny'};
});
debug('Loading Nintendo Account authorisation', {
authoriseurl,
state,
verifier,
challenge,
});
debug('Loading Nintendo Account authorisation', authenticator);
window.loadURL(authoriseurl);
window.loadURL(authenticator.authorise_url);
});
}
const FORCE_MANUAL_AUTH_URI_ENTRY = process.env.NXAPI_FORCE_MANUAL_AUTH === '1';
export function getSessionTokenCodeByDefaultBrowser(
client_id: string, scope: string | string[],
authenticator: NintendoAccountSessionAuthorisation,
close_window = true,
force_manual = FORCE_MANUAL_AUTH_URI_ENTRY
) {
return new Promise<NintendoAccountSessionTokenCode>((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));
debug('Redirect URL parameters', [...authorisedparams.entries()]);
if (authorisedparams.get('state') !== state) {
if (authorisedparams.get('state') !== authenticator.state) {
rj(new Error('Invalid state'));
window?.close();
return;
}
if (authorisedparams.has('error')) {
rj(AuthoriseError.fromSearchParams(authorisedparams));
rj(NintendoAccountSessionAuthorisationError.fromSearchParams(authorisedparams));
window?.close();
return;
}
@@ -248,28 +201,23 @@ export function getSessionTokenCodeByDefaultBrowser(
debug('code', code, jwt, sig);
if (window && close_window) window.close();
else if (window) rs({code, verifier, window});
else rs({code, verifier});
else if (window) rs({authenticator, code, window});
else rs({authenticator, code});
};
debug('Prompting user for Nintendo Account authorisation', {
authoriseurl,
state,
verifier,
challenge,
});
debug('Prompting user for Nintendo Account authorisation', authenticator);
const protocol = 'npf' + client_id;
const protocol = 'npf' + authenticator.client_id;
if (force_manual) {
debug('Manual entry forced, prompting for redirect URI');
window = askUserForRedirectUri(authoriseurl, client_id, handleAuthUrl, rj);
window = askUserForRedirectUri(authenticator.authorise_url, authenticator.client_id, handleAuthUrl, rj);
} else if (app.isDefaultProtocolClient(protocol,
protocol_registration_options?.path, protocol_registration_options?.argv
)) {
debug('App is already default protocol handler, opening browser');
auth_state.set(state, [handleAuthUrl, rj, protocol]);
shell.openExternal(authoriseurl);
auth_state.set(authenticator.state, [handleAuthUrl, rj, protocol]);
shell.openExternal(authenticator.authorise_url);
} else {
const registered_app = app.getApplicationNameForProtocol(protocol);
@@ -277,11 +225,11 @@ export function getSessionTokenCodeByDefaultBrowser(
protocol_registration_options?.path, protocol_registration_options?.argv
)) {
debug('Another app is using the auth protocol or registration failed, prompting for redirect URI');
window = askUserForRedirectUri(authoriseurl, client_id, handleAuthUrl, rj);
window = askUserForRedirectUri(authenticator.authorise_url, authenticator.client_id, handleAuthUrl, rj);
} else {
debug('App is now default protocol handler, opening browser');
auth_state.set(state, [handleAuthUrl, rj, protocol]);
shell.openExternal(authoriseurl);
auth_state.set(authenticator.state, [handleAuthUrl, rj, protocol]);
shell.openExternal(authenticator.authorise_url);
}
}
});
@@ -352,9 +300,10 @@ const NSO_SCOPE = [
];
export async function addNsoAccount(app: App, use_in_app_browser = true) {
const {code, verifier, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(app, ZNCA_CLIENT_ID, NSO_SCOPE, false) :
await getSessionTokenCodeByDefaultBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false);
const authenticator = NintendoAccountSessionAuthorisationCoral.create();
const {code, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(app, authenticator, false) :
await getSessionTokenCodeByDefaultBrowser(authenticator, false);
window?.setFocusable(false);
window?.blurWebView();
@@ -383,13 +332,12 @@ export async function addNsoAccount(app: App, use_in_app_browser = true) {
return {nso, data};
} catch (err) {
if (err instanceof ErrorResponse && err.response.url.startsWith('https://accounts.nintendo.com/')) {
const data: NintendoAccountAuthError = err.data;
if (data.error === 'invalid_grant') {
// The session token has expired/was revoked
return authenticateCoralSessionToken(app, code, verifier, true);
}
if (
(err instanceof InvalidNintendoAccountTokenError) ||
(err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant')
) {
// The session token has expired/was revoked
return authenticateCoralSessionToken(app, authenticator, code, true);
}
throw err;
@@ -398,7 +346,7 @@ export async function addNsoAccount(app: App, use_in_app_browser = true) {
await checkZncaApiUseAllowed(app, window);
return authenticateCoralSessionToken(app, code, verifier);
return authenticateCoralSessionToken(app, authenticator, code);
} finally {
window?.close();
}
@@ -406,10 +354,10 @@ export async function addNsoAccount(app: App, use_in_app_browser = true) {
async function authenticateCoralSessionToken(
app: App,
code: string, verifier: string,
authenticator: NintendoAccountSessionAuthorisation, code: string,
reauthenticate = false,
) {
const token = await getNintendoAccountSessionToken(code, verifier, ZNCA_CLIENT_ID);
const token = await authenticator.getSessionToken(code);
debug('session token', token);
@@ -440,7 +388,7 @@ export async function askAddNsoAccount(app: App, iab = true) {
try {
return await addNsoAccount(app, iab);
} catch (err: any) {
if (err instanceof AuthoriseError && err.code === 'access_denied') return;
if (err instanceof NintendoAccountSessionAuthorisationError && err.code === 'access_denied') return;
dialog.showErrorBox(app.i18n.t('na_auth:error.title') ?? 'Error adding account',
err.stack || err.message);
@@ -521,9 +469,10 @@ const MOON_SCOPE = [
];
export async function addPctlAccount(app: App, use_in_app_browser = true) {
const {code, verifier, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(app, ZNMA_CLIENT_ID, MOON_SCOPE, false) :
await getSessionTokenCodeByDefaultBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false);
const authenticator = NintendoAccountSessionAuthorisationMoon.create();
const {code, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(app, authenticator, false) :
await getSessionTokenCodeByDefaultBrowser(authenticator, false);
window?.setFocusable(false);
window?.blurWebView();
@@ -549,20 +498,19 @@ export async function addPctlAccount(app: App, use_in_app_browser = true) {
return {moon, data};
} catch (err) {
if (err instanceof ErrorResponse && err.response.url.startsWith('https://accounts.nintendo.com/')) {
const data: NintendoAccountAuthError = err.data;
if (data.error === 'invalid_grant') {
// The session token has expired/was revoked
return authenticateMoonSessionToken(app, code, verifier, true);
}
if (
(err instanceof InvalidNintendoAccountTokenError) ||
(err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant')
) {
// The session token has expired/was revoked
return authenticateMoonSessionToken(app, authenticator, code, true);
}
throw err;
}
}
return authenticateMoonSessionToken(app, code, verifier);
return authenticateMoonSessionToken(app, authenticator, code);
} finally {
window?.close();
}
@@ -570,10 +518,10 @@ export async function addPctlAccount(app: App, use_in_app_browser = true) {
async function authenticateMoonSessionToken(
app: App,
code: string, verifier: string,
authenticator: NintendoAccountSessionAuthorisation, code: string,
reauthenticate = false,
) {
const token = await getNintendoAccountSessionToken(code, verifier, ZNMA_CLIENT_ID);
const token = await authenticator.getSessionToken(code);
debug('session token', token);
@@ -600,7 +548,7 @@ export async function askAddPctlAccount(app: App, iab = true) {
try {
return await addPctlAccount(app, iab);
} catch (err: any) {
if (err instanceof AuthoriseError && err.code === 'access_denied') return;
if (err instanceof NintendoAccountSessionAuthorisationError && err.code === 'access_denied') return;
dialog.showErrorBox(app.i18n.t('na_auth:error.title') ?? 'Error adding account',
err.stack || err.message);

View File

@@ -1,10 +1,15 @@
import { BrowserWindow, Menu, MenuItem, nativeImage } from './electron.js';
import { BrowserWindow, dialog, Menu, MenuItem, MessageBoxOptions, nativeImage, Session } from 'electron';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import fetch from 'node-fetch';
import createDebug from '../../util/debug.js';
import { fetch } from 'undici';
import { dir } from '../../util/product.js';
import { App } from './index.js';
import { SavedToken } from '../../common/auth/coral.js';
import { ErrorDescription } from '../../util/errors.js';
import { buildProxyAgent, ProxyAgentOptions } from '../../util/undici-proxy.js';
const debug = createDebug('app:main:util');
export const bundlepath = path.resolve(dir, 'dist', 'app', 'bundle');
@@ -65,3 +70,56 @@ export async function askUserForUri(app: App, uri: string, prompt: string): Prom
return selected_user;
}
interface ErrorBoxOptions extends MessageBoxOptions {
error: Error | unknown;
app?: App;
window?: BrowserWindow;
}
export function showErrorDialog(options: ErrorBoxOptions) {
const {error, app, window, ...message_box_options} = options;
const detail = ErrorDescription.getErrorDescription(error);
message_box_options.detail = message_box_options.detail ?
detail + '\n\n' + message_box_options.detail :
detail;
if (!message_box_options.type) message_box_options.type = 'error';
return window ?
dialog.showMessageBox(window, message_box_options) :
dialog.showMessageBox(message_box_options);
}
export function buildElectronProxyAgent(options: ProxyAgentOptions & {
session: Session;
}) {
let warned_proxy_unsupported: string | null = null;
return buildProxyAgent({
...options,
resolveProxy: async origin => {
// https://chromium.googlesource.com/chromium/src/+/HEAD/net/docs/proxy.md
const proxies = await options.session.resolveProxy(origin);
const proxy = proxies.split(';')[0].trim();
if (proxy === 'DIRECT') return null;
if (proxy.startsWith('PROXY ')) {
return new URL('http://' + proxy.substr(6));
}
if (proxy.startsWith('HTTPS ')) {
return new URL('https://' + proxy.substr(6));
}
if (warned_proxy_unsupported !== proxy) {
warned_proxy_unsupported = proxy;
debug('Unsupported proxy', proxy);
}
return null;
},
});
}

View File

@@ -1,27 +1,28 @@
import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, nativeImage, nativeTheme, Notification, ShareMenu, shell, WebContents } from './electron.js';
import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, nativeImage, nativeTheme, Notification, ShareMenu, shell, WebContents } from 'electron';
import * as path from 'node:path';
import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import { Buffer } from 'node:buffer';
import * as util from 'node:util';
import fetch from 'node-fetch';
import { fetch } from 'undici';
import mimetypes from 'mime-types';
import { App, Store } from './index.js';
import { createWebServiceWindow } from './windows.js';
import { askUserForUri } from './util.js';
import { askUserForUri, showErrorDialog } 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 { CurrentUser, WebService, WebServiceToken } from '../../api/coral-types.js';
import { NintendoAccountUser } from '../../api/na.js';
import { CoralApiInterface, CoralAuthData } from '../../api/coral.js';
import { WebService, WebServiceToken } from '../../api/coral-types.js';
import { SavedToken } from '../../common/auth/coral.js';
import { checkMembershipActive } from '../../common/auth/util.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;
@@ -41,12 +42,7 @@ export default async function openWebService(
}
const verifymembership = webservice.customAttributes.find(a => a.attrKey === 'verifyMembership');
if (verifymembership?.attrValue === 'true') {
const membership = data.nsoAccount.user.links.nintendoAccount.membership;
const active = typeof membership.active === 'object' ? membership.active.active : membership.active;
if (!active) throw new WebServiceValidationError('Nintendo Switch Online membership required');
}
if (verifymembership?.attrValue === 'true') checkMembershipActive(data);
const user_title_prefix = '[' + data.user.nickname +
(data.nsoAccount.user.name !== data.user.nickname ? '/' + data.nsoAccount.user.name : '') + '] ';
@@ -54,7 +50,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 +81,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 +116,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();
@@ -160,13 +144,19 @@ function isWebServiceUrlAllowed(webservice: WebService, url: string | URL) {
if (typeof url === 'string') url = new URL(url);
for (const host of webservice.whiteList) {
for (const allowed of webservice.whiteList) {
const host = allowed.includes('/') ? allowed.substr(0, allowed.indexOf('/')) : allowed;
const path = allowed.includes('/') ? allowed.substr(allowed.indexOf('/')) : null;
if (path && url.pathname !== path && !url.pathname.startsWith(path + '/')) continue;
if (host.startsWith('*.')) {
return url.hostname === host.substr(2) ||
url.hostname.endsWith(host.substr(1));
if (url.hostname === host.substr(2) ||
url.hostname.endsWith(host.substr(1))
) return true;
}
return url.hostname === host;
if (url.hostname === host) return true;
}
return false;
@@ -199,6 +189,32 @@ function askUserForWebServiceUri(app: App, uri: string) {
return askUserForUri(app, uri, app.i18n.t('handle_uri:web_service_select'));
}
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,
};
return showErrorDialog({
message: (err instanceof Error ? err.name : 'Error') + ' opening web service',
error: err,
detail: util.inspect(data, {compact: true}),
buttons,
window,
});
}
export interface WebServiceData {
webservice: WebService;
url: string;
@@ -225,6 +241,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,
@@ -273,16 +290,8 @@ export class WebServiceIpc {
const dir = app.getPath('downloads');
const basename = path.basename(new URL(image_url).pathname);
const extname = path.extname(basename);
let filename;
let i = 0;
do {
i++;
filename = i === 1 ? basename : basename.substr(0, basename.length - extname.length) + ' ' + i + extname;
} while (await this.pathExists(path.join(dir, filename)));
debug('Downloading image %s to %s as %s', image_url, dir, filename);
debug('Downloading image %s to %s', image_url, dir);
const response = await fetch(image_url, {
headers: {
@@ -290,6 +299,22 @@ export class WebServiceIpc {
},
});
const image = await response.arrayBuffer();
const type = response.headers.get('Content-Type');
const ext = type ? mimetypes.extension(type) : null;
let filename;
let i = 0;
do {
i++;
filename = basename.substr(0, basename.length - extname.length) +
(i === 1 ? basename : ' ' + i) +
(ext ? '.' + ext : extname);
} while (await this.pathExists(path.join(dir, filename)));
debug('Writing image %s to %s as %s', image_url, dir, filename);
await fs.writeFile(path.join(dir, filename), Buffer.from(image));
return path.join(dir, filename);
@@ -321,12 +346,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;
}
@@ -433,4 +458,10 @@ export class WebServiceIpc {
debug('Web service %s, user %s, called completeLoading', webservice.name, nsoAccount.user.name);
}
async clearUnreadFlag(event: IpcMainInvokeEvent): Promise<void> {
const {nsoAccount, webservice} = this.getWindowData(event.sender);
debug('Web service %s, user %s, called clearUnreadFlag', webservice.name, nsoAccount.user.name);
}
}

View File

@@ -1,4 +1,4 @@
import { BrowserWindow, BrowserWindowConstructorOptions, nativeTheme, session, WebContents } from './electron.js';
import { BrowserWindow, BrowserWindowConstructorOptions, nativeTheme, session, WebContents } from 'electron';
import * as path from 'node:path';
import { dev } from '../../util/product.js';
import { WindowConfiguration, WindowType } from '../common/types.js';

View File

@@ -1,5 +1,5 @@
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { EventEmitter } from 'events';
import { EventEmitter } from 'node:events';
import createDebug from 'debug';
import { QrCodeReaderOptions, WebServiceData } from '../main/webservices.js';
@@ -20,6 +20,7 @@ const ipc = {
copyToClipboard: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:copyToClipboard', data) as Promise<void>,
downloadImages: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:downloadImages', data) as Promise<void>,
completeLoading: () => ipcRenderer.invoke('nxapi:webserviceapi:completeLoading') as Promise<void>,
clearUnreadFlag: () => ipcRenderer.invoke('nxapi:webserviceapi:clearUnreadFlag') as Promise<void>,
};
export default ipc;

View File

@@ -5,7 +5,7 @@ const debug = createDebug('app:preload-webservice:quirks:splatnet3');
const SPLATNET3_WEBSERVICE_ID = 4834290508791808;
if (webservice.id === SPLATNET3_WEBSERVICE_ID) {
if (webservice.id === SPLATNET3_WEBSERVICE_ID && location.hostname.endsWith('.av5ja.srv.nintendo.net')) {
const style = window.document.createElement('style');
style.textContent = `
@@ -57,3 +57,17 @@ if (webservice.id === SPLATNET3_WEBSERVICE_ID) {
}
});
}
if (webservice.id === SPLATNET3_WEBSERVICE_ID && location.hostname === 'c.nintendo.com' && location.pathname.match(/^\/splatoon3-tournament(\/|$)/i)) {
const style = window.document.createElement('style');
style.textContent = `
[class*=AppHeader_closeWebView] {
display: none;
}
`;
document.addEventListener('DOMContentLoaded', () => {
window.document.head.appendChild(style);
});
}

View File

@@ -142,6 +142,16 @@ interface WebServiceJsApi {
* Used by SplatNet 3.
*/
reloadExtension(): void;
/**
* Clears the unread notifications flag.
*/
clearUnreadFlag(): void;
/**
* Opens a URL in the default browser.
*/
openExternalBrowser(url: string): void;
}
//
@@ -339,6 +349,16 @@ function reloadExtension() {
debug('reloadExtension called');
}
function clearUnreadFlag() {
debug('clearUnreadFlag called');
ipc.clearUnreadFlag();
}
function openExternalBrowser(url: string) {
debug('openExternalBrowser called', url);
window.open(url);
}
const api: WebServiceJsApi = {
invokeNativeShare,
invokeNativeShareUrl,
@@ -356,6 +376,8 @@ const api: WebServiceJsApi = {
completeLoading,
closeWebView,
reloadExtension,
clearUnreadFlag,
openExternalBrowser,
};
window.jsBridge = api;

View File

@@ -1,9 +1,8 @@
import { contextBridge, ipcRenderer } from 'electron';
import { EventEmitter } from 'events';
import { contextBridge, ipcRenderer, SharingItem } from 'electron';
import { EventEmitter } from 'node:events';
import createDebug from 'debug';
import type { User } from 'discord-rpc';
import type { SharingItem } from '../main/electron.js';
import type { DiscordPresenceConfiguration, DiscordPresenceSource, LoginItem, LoginItemOptions, WindowConfiguration } from '../common/types.js';
import type { DiscordPresenceConfiguration, DiscordPresenceSource, DiscordStatus, LoginItem, LoginItemOptions, WindowConfiguration } from '../common/types.js';
import type { SavedToken } from '../../common/auth/coral.js';
import type { SavedMoonToken } from '../../common/auth/moon.js';
import type { UpdateCacheData } from '../../common/update.js';
@@ -19,8 +18,23 @@ import type { AddFriendProps } from '../browser/add-friend/index.js';
const debug = createDebug('app:preload');
const inv = <T = void>(channel: string, ...args: any[]) =>
ipcRenderer.invoke('nxapi:' + channel, ...args) as Promise<T>;
const inv = async <T = void>(channel: string, ...args: any[]) => {
const data: {
result: T;
} | {
error_type: string;
message: string;
type?: string;
description: string;
data: unknown;
} = await ipcRenderer.invoke('nxapi:' + channel, ...args);
if ('result' in data) return data.result;
// Context isolation removes all other properties of Error objects
throw new Error(data.description.replace(/^Error\: /, ''));
};
const invSync = <T = void>(channel: string, ...args: any[]) =>
ipcRenderer.sendSync('nxapi:' + channel, ...args) as T;
@@ -59,6 +73,8 @@ const ipc = {
getDiscordPresenceSource: () => inv<DiscordPresenceSource | null>('discord:source'),
setDiscordPresenceSource: (source: DiscordPresenceSource | null) => inv<void>('discord:setsource', source),
getDiscordPresence: () => inv<DiscordPresence | null>('discord:presence'),
getDiscordStatus: () => inv<DiscordStatus | null>('discord:status'),
showDiscordLastUpdateError: () => inv('discord:showerror'),
getDiscordUser: () => inv<User | null>('discord:user'),
getDiscordUsers: () => inv<User[]>('discord:users'),
@@ -95,6 +111,7 @@ ipcRenderer.on('nxapi:accounts:shouldrefresh', () => events.emit('update-nintend
ipcRenderer.on('nxapi:discord:shouldrefresh', () => events.emit('update-discord-presence-source'));
ipcRenderer.on('nxapi:discord:presence', (e, p: DiscordPresence) => events.emit('update-discord-presence', p));
ipcRenderer.on('nxapi:discord:user', (e, u: User) => events.emit('update-discord-user', u));
ipcRenderer.on('nxapi:discord:status', (e, s: DiscordStatus | null) => events.emit('update-discord-status', s));
let language: string | undefined = invSync('app:language');
ipcRenderer.on('nxapi:app:update-language', (event, l: string) => {