mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-10-07 01:57:44 -05:00
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:
@@ -34,6 +34,6 @@ export function NintendoSwitchUsers(props: {
|
||||
const styles = StyleSheet.create({
|
||||
userImage: {
|
||||
borderRadius: 8,
|
||||
textAlignVertical: -3,
|
||||
verticalAlign: -3,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import createDebug from 'debug';
|
||||
import type { NxapiElectronIpc } from '../preload/index.js';
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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} />);
|
||||
}
|
||||
|
||||
|
||||
@@ -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%',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user