Merge branch 'i18n' into pull/63

# Conflicts:
#	src/app/i18n/index.ts
This commit is contained in:
Samuel Elliott
2023-06-02 17:13:02 +01:00
147 changed files with 2312 additions and 800 deletions

View File

@@ -2,7 +2,11 @@ const electron = require('electron');
// Do anything that must be run before the app is ready...
electron.app.whenReady()
Promise.all([
// @ts-expect-error
typeof __NXAPI_BUNDLE_APP_INIT__ !== 'undefined' ? import(__NXAPI_BUNDLE_APP_INIT__) : import('./app-init.js'),
electron.app.whenReady(),
])
// @ts-expect-error
.then(() => typeof __NXAPI_BUNDLE_APP_MAIN__ !== 'undefined' ? import(__NXAPI_BUNDLE_APP_MAIN__) : import('./main/index.js'))
.then(m => m.init.call(null))

5
src/app/app-init.ts Normal file
View File

@@ -0,0 +1,5 @@
import { join } from 'node:path';
import { init as initDebug } from '../util/debug.js';
import { paths } from '../util/product.js';
await initDebug(join(paths.log, 'app'));

View File

@@ -19,7 +19,7 @@ export default function DiscordPresenceSource(props: {
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}
{props.presence || props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
</View>
</TouchableOpacity>;
}
@@ -86,28 +86,37 @@ function DiscordPresenceInactive() {
}
function DiscordPresence(props: {
presence: DiscordPresence;
user: User;
presence: DiscordPresence | null;
user: User | null;
}) {
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'sidebar' });
const large_image_url = props.presence.activity.largeImageKey?.match(/^\d{16}$/) ?
const large_image_url = props.presence ? props.presence.activity.largeImageKey?.match(/^\d{16}$/) ?
'https://cdn.discordapp.com/app-assets/' + props.presence.id + '/' +
props.presence.activity.largeImageKey + '.png' :
props.presence.activity.largeImageKey;
const user_image_url = 'https://cdn.discordapp.com/avatars/' + props.user.id + '/' + props.user.avatar + '.png';
props.presence.activity.largeImageKey : undefined;
return <>
<View style={styles.discordPresence}>
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.discriminator) % 5) + '.png' : undefined;
return <View style={styles.discordPresenceContainer}>
{props.presence ? <View style={styles.discordPresence}>
<Image source={{uri: large_image_url, width: 18, height: 18}} style={styles.discordPresenceImage} />
<Text style={styles.discordPresenceText} numberOfLines={1} ellipsizeMode="tail">{t('discord_playing')}</Text>
</View>
</View> : null}
<View style={styles.discordUser}>
{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}#{props.user.discriminator}</Text>
</View>
</>;
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">
{props.user.username}<Text style={styles.discordUserDiscriminator}>#{props.user.discriminator}</Text>
</Text>
</View> : <View style={styles.discordUser}>
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">{t('discord_not_connected')}</Text>
</View>}
</View>;
}
const styles = StyleSheet.create({
@@ -131,8 +140,12 @@ const styles = StyleSheet.create({
userSelect: 'all',
},
discordPresenceContainer: {
marginTop: 2,
},
discordPresence: {
marginTop: 12,
marginTop: 10,
flexDirection: 'row',
alignItems: 'center',
},
@@ -156,4 +169,7 @@ const styles = StyleSheet.create({
discordUserText: {
color: TEXT_COLOUR_DARK,
},
discordUserDiscriminator: {
opacity: 0.7,
},
});

View File

@@ -13,6 +13,13 @@ export const languages = {
['Samuel Elliott', 'https://gitlab.fancy.org.uk/samuel', 'https://github.com/samuelthomas2774'],
],
},
'de-DE': {
name: 'Deutsch',
app: () => import('./locale/de-de.js'),
authors: [
['Inkception', 'https://github.com/Inkception'],
],
},
'ja-JP': {
name: 'Japanese',
app: () => import('./locale/ja-jp.js'),

View File

@@ -0,0 +1,393 @@
import { CREDITS_NOTICE, LICENCE_NOTICE } from '../../../common/constants.js';
export const app = {
default_title: 'Nintendo Switch Online',
licence: LICENCE_NOTICE,
credits: CREDITS_NOTICE,
translation_credits: '{{language}} Übersetzungen von {{authors, list}}.',
};
export const app_menu = {
preferences: 'Einstellungen',
view: 'Anschauen',
learn_more: 'Mehr erfahren',
learn_more_github: 'Mehr erfahren (GitHub)',
search_issues: 'Probleme untersuchen',
refresh: 'Aktualisieren',
};
export const menu_app = {
coral_heading: 'Nintendo Switch Online',
na_id: 'Nintendo Account ID: {{id}}',
coral_id: 'Coral ID: {{id}}',
nsa_id: 'NSA ID: {{id}}',
discord_presence_enable: 'Discord Rich Presence aktivieren',
user_notifications_enable: 'Aktiviere Benachrichtigungen für diesen User',
friend_notifications_enable: 'Aktiviere Benachrichtigungen für Freunde von diesen User',
refresh: 'Jetzt aktualisieren',
add_friend: 'Freund hinzufügen',
web_services: 'Web-Services',
moon_heading: 'Nintendo Switch-Altersbeschränkungen',
add_account: 'Account hinzufügen',
show_main_window: 'Zeige Hauptfenster',
preferences: 'Einstellungen',
quit: 'Beenden',
};
export const menus = {
add_account: {
add_account_coral: 'Nintendo Switch Online Account hinzufügen',
add_account_moon: 'Altersbeschränkter Nintendo Switch Account hinzufügen',
},
friend_code: {
share: 'Teilen',
copy: 'Kopieren',
friend_code_regenerable: 'Regeneriere durch Nintendo Switch Konsole',
friend_code_regenerable_at: 'Kann {{date, datetime}} neu generiert werden',
},
user: {
na_id: 'Nintendo Account ID: {{id}}',
coral_id: 'Coral ID: {{id}}',
nsa_id: 'NSA ID: {{id}}',
discord_disable: 'Deaktiviere Discord Rich Presence',
discord_enabled_for: 'Discord Rich Presence für {{name}} aktiviert',
discord_enabled_via: 'Discord Rich Presence durch {{name}} aktiviert',
discord_enable: 'Aktiviere Discord Rich Presence für diesen User...',
friend_notifications_enable: 'Aktiviere Freund-Benachrichtigungen',
refresh: 'Jetzt aktualisieren',
add_friend: 'Freund hinzufügen',
remove_help: 'Benutze den nxapi Befehl, um diesen User zu entfernen',
},
friend: {
presence_online: 'Online',
game_first_played: 'Zuerst gespielt: {{date, datetime}}',
game_play_time_h: 'Spielzeit: $t(friend.hours, {"count": {{hours}}})',
game_play_time_hm: 'Spielzeit: $t(friend.hours, {"count": {{hours}}}), $t(friend.minutes, {"count": {{minutes}}})',
game_play_time_m: 'Spielzeit: $t(friend.minutes, {"count": {{minutes}}})',
hours_one: '{{count}} Stunde',
hours_other: '{{count}} Stunden',
minutes_one: '{{count}} Minute',
minutes_other: '{{count}} Minuten',
presence_inactive: 'Offline (Konsole online)',
presence_offline: 'Offline',
presence_updated: 'Aktualisiert: {{date, datetime}}',
presence_logout_time: 'Ausgeloggt: {{date, datetime}}',
discord_presence_enable: 'Aktiviere Discord Rich Presence',
},
};
export const notifications = {
playing: 'Spielt {{name}}',
offline: 'Offline',
};
export const handle_uri = {
friend_code_select: 'Wähle einen User aus, um Freunde hinzuzufügen',
web_service_select: 'Wähle einen User aus, um diesen Service zu öffnen',
web_service_invalid_title: 'Unbekannter Titel',
web_service_invalid_detail: 'Die angegebene URL verwies nicht auf einen existierenden Web-Service.',
cancel: 'Abbrechen',
};
export const na_auth = {
window: {
title: 'Nintendo Account',
},
znca_api_use: {
title: 'Verwendung einer Drittanbieter-API',
text: `Um Zugriff auf die API der Nintendo Switch Online App zu erhalten, muss nxapi einige Daten an Drittanbieter-APIs senden. Dieser Schritt wird benötigt, um Daten zu generieren, damit Nintendo denkt, dass du die echte Nintendo Switch Online App verwendest.
Standardmäßig wird nxapi-znca-api.fancy.org.uk oder api.imink.app benutzt. Ein anderer Service kann ebenfalls benutzt werden, indem eine Umgebungsvariable gesetzt wird. Die standardmäßige API könnte sich jederzeit ohne Hinweis ändern, wenn du keinen spezifischen Service erzwingst.
Die gesendeten Daten beinhalten:
- Deine Nintendo Account ID
- Bei Authorisierung mit der Nintendo Switch Online App: Ein Nintendo Account ID Token, welcher dein Land beinhaltet und für 15 Minuten gültig ist
- Bei Authorisierung mit Spielspezifischen Services: Deine Coral (Nintendo Switch Online App) User ID und ein Coral ID Token, welcher deine aktuelle Nintendo Switch Online Mitgliedschaft und Altersbeschränkungsstatus beinhaltet und für 2 Stunden gültig ist`,
ok: 'OK',
cancel: 'Abbrechen',
more_information: 'Weitere Informationen',
},
notification_coral: {
title: 'Nintendo Switch Online',
body_existing: 'Bereits als {{name}} (Nintendo Account {{na_name}} / {{na_username}}) eingeloggt',
body_authenticated: 'Authentifiziert als {{name}} (Nintendo Account {{na_name}} / {{na_username}})',
body_reauthenticated: 'Erneut als {{name}} (Nintendo Account {{na_name}} / {{na_username}}) authentifiziert',
},
notification_moon: {
title: 'Nintendo Switch-Altersbeschränkungen',
body_existing: 'Bereits als {{na_name}} ({{na_username}}) eingeloggt',
body_authenticated: 'Authentifiziert als {{na_name}} ({{na_username}})',
body_reauthenticated: 'Erneut als {{na_name}} ({{na_username}}) authentifiziert',
},
error: {
title: 'Fehler beim Hinzufügen des Accounts',
},
};
export const time_since = {
default: {
now: 'jetzt',
seconds_one: 'Vor {{count}} Sekunde',
seconds_other: 'Vor {{count}} Sekunden',
minutes_one: 'Vor {{count}} Minute',
minutes_other: 'Vor {{count}} Minuten',
hours_one: 'Vor {{count}} Stunde',
hours_other: 'Vor {{count}} Stunden',
days_one: 'Vor {{count}} Tag',
days_other: 'Vor {{count}} Tagen',
},
short: {
now: 'jetzt',
seconds_one: '{{count}} Sek',
seconds_other: '{{count}} Sek',
minutes_one: '{{count}} Min',
minutes_other: '{{count}} Min',
hours_one: '{{count}} Std',
hours_other: '{{count}} Std',
days_one: '{{count}} Tg',
days_other: '{{count}} Tg',
},
};
export const main_window = {
sidebar: {
discord_active: 'Discord Rich Presence aktiv',
discord_active_friend: 'Discord Rich Presence aktiv: <0></0>',
discord_not_active: 'Discord Rich Presence nicht aktiv',
discord_playing: 'Spielt',
discord_not_connected: 'Nicht mit Discord verbunden',
add_user: 'User hinzufügen',
discord_setup: 'Discord Rich Presence einrichten',
enable_auto_refresh: 'Automatisch aktualisieren',
},
update: {
update_available: 'Update verfügbar: {{name}}',
download: 'Download',
error: 'Fehler beim Überprüfen von Updates aufgetreten: {{message}}',
retry: 'Erneut versuchen',
},
main_section: {
error: {
title: 'Fehler beim Laden der Daten',
message: 'Ein Fehler ist beim Laden der {{errors, list}} Daten aufgetreten.',
message_friends: 'freunde',
message_webservices: 'spielspezifische services',
message_event: 'sprachchat',
retry: 'Erneut versuchen',
view_details: 'Details anschauen',
},
moon_only_user: {
title: 'Nintendo Switch Online',
desc_1: 'Dieser User ist mit der Nintendo Switch-Altersbeschränkungen App angemeldet, aber nicht mit der Nintendo Switch Online App.',
desc_2: 'Logge dich mit der Nintendo Switch Online App ein, um Details einzusehen oder benutze den nxapi Befehl, um auf Altersbeschränkungen zuzugreifen.',
login: 'Login',
},
section_error: 'Fehler beim Aktualisieren der Daten',
},
discord_section: {
title: 'Discord Rich Presence',
setup_with_existing_user: 'Benutze einer dieser Account, um die Discord Rich Presence einzurichten: <0></0>.',
add_user: 'Füge einen Nintendo Switch Online Account mit diesem User als Freund hinzu, um die Discord Rich Presence einzurichten.',
active_self: 'Die Aktivität wird mit diesem User auf Discord geteilt.',
active_friend: '<0></0>\'s Aktivität wird mit diesem Account auf Discord geteilt.',
active_unknown: 'Die Aktivität eines unbekannten Users wird mit Discord geteilt.',
active_via: 'Die Aktivität wird mit diesem User auf Discord über <0></0> geteilt.',
setup: 'Einrichten',
disable: 'Deaktivieren',
},
friends_section: {
title: 'Freunde',
no_friends: 'Füge Freunde mit deiner Nintendo Switch Konsole hinzu.',
friend_code: 'Dein Freundescode: <0></0>',
presence_playing: 'Spielt',
presence_offline: 'Offline',
},
webservices_section: {
title: 'Spielspezifische Services',
},
event_section: {
title: 'Sprachchat',
members: '{{event}} im Spiel, {{voip}} im Sprachchat',
members_with_total: '{{event}} im Spiel, {{voip}} von {{total}} im Sprachkanal',
app_start: 'Benutze die Nintendo Switch Online App auf iOS oder Android, um einen Sprachchat zu starten.',
app_join: 'Benutze die Nintendo Switch Online App auf iOS oder Android, um einem Sprachchat beizutreten.',
share: 'Teilen',
},
};
export const preferences_window = {
title: 'Einstellungen',
startup: {
heading: 'Start',
login: 'Öffne beim Start',
background: 'Öffne im Hintergrund',
},
sleep: {
heading: 'Sleep',
},
discord: {
heading: 'Discord Rich Presence',
enabled: 'Discord Rich Presence ist aktiviert.',
disabled: 'Discord Rich Presence ist deaktiviert.',
setup: 'Discord Rich Presence Setup',
user: 'Discord User',
user_any: 'Zuerst gesehen',
friend_code: 'Freundescode',
friend_code_help: 'Wenn du deinen Freundescode hinzufügst, wird ebenfalls dein User Icon in Discord angezeigt.',
friend_code_self: 'Teile meinen Freundescode',
friend_code_custom: 'Eigenen Freundescode festlegen',
inactive_presence: 'Zeige inaktive Aktivität',
inactive_presence_help: 'Zeigt "Spielt nicht", wenn eine verbundene Konsole online ist, du aber nicht spielst.',
play_time: 'Spielzeit',
play_time_hidden: 'Spielzeit niemals anzeigen',
play_time_nintendo: 'Zeige Spielzeit wie sie auf der Nintendo Switch Konsole angezeigt wird',
play_time_approximate_play_time: 'Zeige ungefähre Spielzeit (nächste 5 Stunden) an',
play_time_approximate_play_time_since: 'Zeige ungefähre Spielzeit (nächste 5 Stunden) mit erstem Startdatum an',
play_time_hour_play_time: 'Zeige ungefähre Spielzeit (nächste Stunde) an',
play_time_hour_play_time_since: 'Zeige ungefähre Spielzeit (nächste Stunde) mit erstem Startdatum an',
play_time_detailed_play_time: 'Zeige exakte Spielzeit an',
play_time_detailed_play_time_since: 'Zeige exakte Spielzeit mit erstem Startdatum an',
},
splatnet3: {
heading: 'SplatNet 3',
discord: 'Aktiviere die erweiterte Discord Rich Presence für Splatoon 3',
discord_help_1: 'Benutzt SplatNet 3, um zusätzliche Informationen anzuzeigen, während du Splatoon 3 spielst. Du musst einen zweiten Nintendo Switch Account hinzufügen, welcher mit deinem Hauptaccount befreundet ist und Zugriff auf SplatNet 3 hat.',
discord_help_2: 'Wenn du eine Presence URL benutzt, werden die zusätzlichen Informationen angezeigt, ohne diese Einstellung zu berücksichtigen.',
},
};
export const friend_window = {
no_presence: 'Du hast keinen Zugriff auf die Aktivität von diesem User oder er war nie online.',
nsa_id: 'NSA ID',
coral_id: 'Coral user ID',
no_coral_user: 'Hat nie die Nintendo Switch Online App genutzt',
friends_since: 'Freunde seit: {{date, datetime}}',
presence_updated_at: 'Aktivität aktualisiert: {{date, datetime}}',
presence_logout_at: 'Zuletzt online: {{date, datetime}}',
presence_sharing: 'Dieser User kann deine Aktivität sehen.',
presence_not_sharing: 'Dieser User kann deine Aktivität nicht sehen.',
discord_presence: 'Aktivität auf Discord teilen',
close: 'Schließen',
presence_playing: 'Spielt {{game}}',
presence_offline: 'Offline',
presence_last_seen: 'Zuletzt gesehen: {{since_logout}}',
game_played_for_h: 'Gespielt für $t(hours, {"count": {{hours}}})',
game_played_for_hm: 'Gespielt für $t(hours, {"count": {{hours}}}), $t(minutes, {"count": {{minutes}}})',
game_played_for_m: 'Gespielt für $t(minutes, {"count": {{minutes}}})',
hours_one: '{{count}} Stunde',
hours_other: '{{count}} Stunden',
minutes_one: '{{count}} Minute',
minutes_other: '{{count}} Minuten',
game_first_played: 'Zuerst gespielt am {{date, datetime}}',
game_first_played_now: 'Zuerst gespielt jetzt',
game_title_id: 'Titel ID',
game_shop: 'Nintendo eShop',
};
export const addfriend_window = {
title: 'Freund hinzufügen',
help: 'Gebe oder füge einen Freundescode oder eine Freundescode-URL ein, um eine Freundschaftsanfrage zu senden.',
lookup_error: 'Fehler beim Aufrufen des Freundescodes aufgetreten: {{message}}',
nsa_id: 'NSA ID',
coral_id: 'Coral user ID',
no_coral_user: 'Hat nie die Nintendo Switch Online App genutzt',
send_added: 'Du bist nun mit diesem User befreundet.',
send_sent: 'Freundschaftsanfrage gesendet. {{user}} kann deine Freundschaftsanfrage über die Nintendo Switch Konsole annehmen oder dir eine Freundschaftsanfrage über die Nintendo Switch Online App oder nxapi senden.',
send_sending: 'Sende Freundschaftsanfrage...',
send_error: 'Fehler beim Senden der Freundschaftsanfrage aufgetreten: {{message}}',
already_friends: 'Du bist bereits mit diesem User befreundet',
close: 'Schließen',
send: 'Anfrage senden',
};
export const discordsetup_window = {
title: 'Discord Rich Presence Setup',
mode_heading: '1. Wähle den Modus aus',
mode_coral_friend: 'Wähle einen User, der mit dir befreundet ist, aus.',
mode_url: 'Gebe eine URL ein, die deine Aktivität zurückgibt.',
mode_none: 'Deaktivieren',
coral_user_heading: '2. Wähle den User aus',
coral_user_help: 'Der User muss mit dir befreundet sein, um die Aktivität zu teilen.',
coral_friend_heading: '3. Wähle einen Freund aus',
coral_friend_help: 'Das ist der User, den du teilen möchtest.',
url_heading: '2. Gebe eine Presence-URL ein',
url_help: 'Der Link muss eine HTTPS-URL sein, die ein JSON-Objekt mit einem User, Freund oder Aktivitätsschlüssel zurückgibt. Diese Funktion ist für nxapi\'s znc API Proxy vorgesehen.',
preferences_heading: 'Konfiguriere zusätzliche Optionen für die Discord Rich Presence',
preferences: 'Einstellungen',
cancel: 'Abbrechen',
save: 'Speichern',
};
export const addaccountmanual_window = {
title: 'Account hinzufügen',
authorise_heading: '1. Logge dich in deinen Nintendo Account ein.',
authorise_help: 'Wähle noch keinen Account aus.',
authorise_open: 'Öffne die Nintendo Account Authorisierung',
response_heading: '2. Gebe den Weiterleitungslink ein',
response_help_1: 'Rechtsklick auf der "Externen Account verlinken" Seite, Rechtsklick auf "Diese Person auswählen" und den Link kopieren. Der Link sollte mit "{{url}}". beginnen',
response_help_2: 'Wenn du einen Kinder-Account, welcher mit deinem Account verlinkt ist, hinzufügen möchtest, klicke auf \"Diese Person auswählen\". Wenn dann nur der Kinder-Account angezeigt wird, Rechtsklick auf \"Diese Person auswählen\" und den Link kopieren.',
cancel: 'Abbrechen',
save: 'Account hinzufügen',
};

View File

@@ -1,4 +1,4 @@
import { CREDITS_NOTICE, LICENCE_NOTICE } from '../../../common/constants.js';
import { CREDITS_NOTICE, LICENCE_NOTICE, ZNCA_API_USE_TEXT } from '../../../common/constants.js';
export const app = {
default_title: 'Nintendo Switch Online',
@@ -99,6 +99,41 @@ export const handle_uri = {
cancel: 'Cancel',
};
export const na_auth = {
window: {
title: 'Nintendo Account',
},
znca_api_use: {
title: 'Third-party API usage',
// This should be translated in other languages
text: ZNCA_API_USE_TEXT,
ok: 'OK',
cancel: 'Cancel',
more_information: 'More information',
},
notification_coral: {
title: 'Nintendo Switch Online',
body_existing: 'Already signed in as {{name}} (Nintendo Account {{na_name}} / {{na_username}})',
body_authenticated: 'Authenticated as {{name}} (Nintendo Account {{na_name}} / {{na_username}})',
body_reauthenticated: 'Reauthenticated to {{name}} (Nintendo Account {{na_name}} / {{na_username}})',
},
notification_moon: {
title: 'Nintendo Switch Parental Controls',
body_existing: 'Already signed in as {{na_name}} ({{na_username}})',
body_authenticated: 'Authenticated as {{na_name}} ({{na_username}})',
body_reauthenticated: 'Reauthenticated to {{na_name}} ({{na_username}})',
},
error: {
title: 'Error adding account',
},
};
export const time_since = {
default: {
now: 'just now',
@@ -131,6 +166,7 @@ export const main_window = {
discord_active_friend: 'Discord Rich Presence active: <0></0>',
discord_not_active: 'Discord Rich Presence not active',
discord_playing: 'Playing',
discord_not_connected: 'Not connected to Discord',
add_user: 'Add user',
discord_setup: 'Set up Discord Rich Presence',

View File

@@ -2,25 +2,25 @@ import { app, BrowserWindow, dialog, ipcMain, LoginItemSettingsOptions, Menu } f
import process from 'node:process';
import * as path from 'node:path';
import { EventEmitter } from 'node:events';
import createDebug from 'debug';
import * as persist from 'node-persist';
import { i18n } from 'i18next';
import { init as initGlobals } from '../../common/globals.js';
import MenuApp from './menu.js';
import { handleOpenWebServiceUri } from './webservices.js';
import { EmbeddedPresenceMonitor, PresenceMonitorManager } from './monitor.js';
import { createWindow } from './windows.js';
import { DiscordPresenceConfiguration, LoginItem, LoginItemOptions, WindowType } from '../common/types.js';
import { initStorage, paths } from '../../util/storage.js';
import { checkUpdates, UpdateCacheData } from '../../common/update.js';
import Users, { CoralUser } from '../../common/users.js';
import { createModalWindow, createWindow } from './windows.js';
import { sendToAllWindows, setupIpc } from './ipc.js';
import { dev, dir, git, release, version } from '../../util/product.js';
import { addUserAgent } from '../../util/useragent.js';
import { askUserForUri } 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';
import { init as initGlobals } from '../../common/globals.js';
import { CREDITS_NOTICE, GITLAB_URL, LICENCE_NOTICE } from '../../common/constants.js';
import { checkUpdates, UpdateCacheData } from '../../common/update.js';
import Users, { CoralUser } from '../../common/users.js';
import createDebug from '../../util/debug.js';
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';
const debug = createDebug('app:main');
@@ -101,17 +101,7 @@ export class App {
return this.preferences_window;
}
const window = createWindow(WindowType.PREFERENCES, {}, {
show: false,
maximizable: false,
minimizable: false,
width: 580,
height: 400,
minWidth: 580,
maxWidth: 580,
minHeight: 400,
maxHeight: 400,
});
const window = createModalWindow(WindowType.PREFERENCES, {});
window.on('closed', () => this.preferences_window = null);
@@ -125,7 +115,7 @@ export class App {
debug('Initialising i18n with language %s', language);
await i18n.init({lng: language ?? undefined});
await i18n.loadNamespaces(['app', 'app_menu', 'menus', 'handle_uri']);
await i18n.loadNamespaces(['app', 'app_menu', 'menus', 'handle_uri', 'na_auth']);
return i18n;
}
@@ -290,19 +280,9 @@ export async function handleOpenFriendCodeUri(app: App, uri: string) {
const selected_user = await askUserForUri(app, uri, app.i18n.t('handle_uri:friend_code_select'));
if (!selected_user) return;
createWindow(WindowType.ADD_FRIEND, {
createModalWindow(WindowType.ADD_FRIEND, {
user: selected_user[1].user.id,
friendcode,
}, {
// show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
});
}

View File

@@ -1,27 +1,24 @@
import { BrowserWindow, clipboard, dialog, IpcMain, KeyboardEvent, Menu, MenuItem, Settings, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
import { BrowserWindow, clipboard, dialog, IpcMain, KeyboardEvent, Menu, MenuItem, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
import * as util from 'node:util';
import createDebug from 'debug';
import { User } from 'discord-rpc';
import openWebService, { QrCodeReaderOptions, WebServiceIpc, WebServiceValidationError } from './webservices.js';
import { createWindow, getWindowConfiguration } from './windows.js';
import { DiscordPresenceConfiguration, DiscordPresenceSource, LoginItemOptions, WindowType } from '../common/types.js';
import { CurrentUser, Friend, Game, PresenceState, WebService } from '../../api/coral-types.js';
import { createModalWindow, createWindow, 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 { CurrentUser, Friend, Game, PresenceState, WebService } from '../../api/coral-types.js';
import { NintendoAccountUser } from '../../api/na.js';
import { hrduration } from '../../util/misc.js';
import createDebug from '../../util/debug.js';
import { DiscordPresence } from '../../discord/types.js';
import { getDiscordRpcClients } from '../../discord/rpc.js';
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 { EmbeddedPresenceMonitor } from './monitor.js';
const debug = createDebug('app:main:ipc');
const shown_modal_windows = new WeakSet<BrowserWindow>();
export function setupIpc(appinstance: App, ipcMain: IpcMain) {
const store = appinstance.store;
const storage = appinstance.store.storage;
@@ -54,8 +51,8 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
}, 60 * 60 * 1000);
ipcMain.handle('nxapi:accounts:list', () => storage.getItem('NintendoAccountIds'));
ipcMain.handle('nxapi:accounts:add-coral', () => askAddNsoAccount(store.storage).then(u => u?.data.user.id));
ipcMain.handle('nxapi:accounts:add-moon', () => askAddPctlAccount(store.storage).then(u => u?.data.user.id));
ipcMain.handle('nxapi:accounts:add-coral', () => askAddNsoAccount(appinstance).then(u => u?.data.user.id));
ipcMain.handle('nxapi:accounts:add-moon', () => askAddPctlAccount(appinstance).then(u => u?.data.user.id));
ipcMain.handle('nxapi:coral:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id));
ipcMain.handle('nxapi:coral:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token));
@@ -85,61 +82,15 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
ipcMain.handle('nxapi: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) => createWindow(WindowType.FRIEND, props, {
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
}).id);
ipcMain.handle('nxapi:window:discord', (e, props: DiscordSetupProps) => createWindow(WindowType.DISCORD_PRESENCE, props, {
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
}).id);
ipcMain.handle('nxapi:window:addfriend', (e, props: AddFriendProps) => createWindow(WindowType.ADD_FRIEND, props, {
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
}).id);
ipcMain.handle('nxapi:window:showfriend', (e, props: FriendProps) =>
createModalWindow(WindowType.FRIEND, props, e.sender).id);
ipcMain.handle('nxapi:window:discord', (e, props: DiscordSetupProps) =>
createModalWindow(WindowType.DISCORD_PRESENCE, props).id);
ipcMain.handle('nxapi:window:addfriend', (e, props: AddFriendProps) =>
createModalWindow(WindowType.ADD_FRIEND, props, e.sender).id);
ipcMain.handle('nxapi:window:setheight', (e, height: number) => {
const window = BrowserWindow.fromWebContents(e.sender)!;
const [curWidth, curHeight] = window.getSize();
const [curContentWidth, curContentHeight] = window.getContentSize();
const [minWidth, minHeight] = window.getMinimumSize();
const [maxWidth, maxHeight] = window.getMaximumSize();
if (height !== curContentHeight && curHeight === minHeight && curHeight === maxHeight) {
window.setMinimumSize(minWidth, height + (curHeight - curContentHeight));
window.setMaximumSize(maxWidth, height + (curHeight - curContentHeight));
}
window.setContentSize(curContentWidth, height);
if (!shown_modal_windows.has(window)) {
window.show();
shown_modal_windows.add(window);
}
setWindowHeight(window, height);
});
ipcMain.handle('nxapi:discord:config', () => appinstance.monitors.getDiscordPresenceConfiguration());
@@ -155,8 +106,12 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
const users: User[] = [];
for (const client of await getDiscordRpcClients()) {
await client.connect(defaultTitle.client);
if (client.user && !users.find(u => u.id === client.user!.id)) users.push(client.user);
try {
await client.connect(defaultTitle.client);
if (client.user && !users.find(u => u.id === client.user!.id)) users.push(client.user);
} finally {
await client.destroy();
}
}
return users;
@@ -175,10 +130,10 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
ipcMain.handle('nxapi:menu:add-user', e => (Menu.buildFromTemplate([
new MenuItem({label: t('add_account.add_account_coral')!, click:
(item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddNsoAccount(storage, !event.shiftKey)}),
askAddNsoAccount(appinstance, !event.shiftKey)}),
new MenuItem({label: t('add_account.add_account_moon')!, click:
(item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddPctlAccount(storage, !event.shiftKey)}),
askAddPctlAccount(appinstance, !event.shiftKey)}),
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:friend-code', (e, fc: CurrentUser['links']['friendCode']) => (Menu.buildFromTemplate([
new MenuItem({label: 'SW-' + fc.id, enabled: false}),
@@ -252,21 +207,9 @@ function buildUserMenu(app: App, user: NintendoAccountUser, nso?: CurrentUser, m
click: () => app.menu?.setActiveDiscordPresenceUser(null)}),
] : [
new MenuItem({label: t('discord_enable')!,
click: () => createWindow(WindowType.DISCORD_PRESENCE, {
click: () => createModalWindow(WindowType.DISCORD_PRESENCE, {
friend_nsa_id: nso.nsaId,
}, {
parent: window,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
})}),
}, window)}),
]),
new MenuItem({label: t('friend_notifications_enable')!, type: 'checkbox',
checked: monitor?.friend_notifications,
@@ -275,21 +218,9 @@ function buildUserMenu(app: App, user: NintendoAccountUser, nso?: CurrentUser, m
click: () => monitor?.skipIntervalInCurrentLoop(true)}),
new MenuItem({type: 'separator'}),
new MenuItem({label: t('add_friend')!,
click: () => createWindow(WindowType.ADD_FRIEND, {
click: () => createModalWindow(WindowType.ADD_FRIEND, {
user: user.id,
}, {
parent: window,
modal: true,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
})}),
}, window)}),
] : []),
new MenuItem({type: 'separator'}),
new MenuItem({label: t('remove_help')!, enabled: false}),

View File

@@ -1,23 +1,25 @@
import { app, dialog, Menu, Tray, nativeImage, MenuItem, BrowserWindow, KeyboardEvent } from './electron.js';
import path from 'node:path';
import * as util from 'node:util';
import createDebug from 'debug';
import { askAddNsoAccount, askAddPctlAccount } from './na-auth.js';
import { App } from './index.js';
import { WebService } from '../../api/coral-types.js';
import openWebService, { WebServiceValidationError } from './webservices.js';
import { SavedToken } from '../../common/auth/coral.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import { dev, dir } from '../../util/product.js';
import { EmbeddedPresenceMonitor, EmbeddedProxyPresenceMonitor } from './monitor.js';
import { createWindow } from './windows.js';
import { createModalWindow, createWindow } from './windows.js';
import { WindowType } from '../common/types.js';
import CoralApi 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 { languages } from '../i18n/index.js';
const debug = createDebug('app:main:menu');
const show_force_language_menu = dev || git?.branch?.match(/^(i18n$|trans-)/);
export default class MenuApp {
tray: Tray;
@@ -121,7 +123,7 @@ export default class MenuApp {
menu.append(new MenuItem({type: 'separator'}));
menu.append(new MenuItem({label: t('show_main_window')!, click: () => this.app.showMainWindow()}));
menu.append(new MenuItem({label: t('preferences')!, click: () => this.app.showPreferencesWindow()}));
if (dev) menu.append(new MenuItem({label: 'Language', submenu: Menu.buildFromTemplate([
if (show_force_language_menu) menu.append(new MenuItem({label: 'Language', submenu: Menu.buildFromTemplate([
...this.app.i18n.options.supportedLngs || ['cimode'],
].map(l => new MenuItem({
label: languages[l as keyof typeof languages]?.name ?? l,
@@ -139,9 +141,9 @@ export default class MenuApp {
}
addNsoAccount = (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddNsoAccount(this.app.store.storage, !event.shiftKey);
askAddNsoAccount(this.app, !event.shiftKey);
addPctlAccount = (item: MenuItem, window: BrowserWindow | undefined, event: KeyboardEvent) =>
askAddPctlAccount(this.app.store.storage, !event.shiftKey);
askAddPctlAccount(this.app, !event.shiftKey);
protected webservices = new Map</** language */ string, WebService[]>();
@@ -300,18 +302,8 @@ export default class MenuApp {
}
showAddFriendWindow(user: string) {
createWindow(WindowType.ADD_FRIEND, {
createModalWindow(WindowType.ADD_FRIEND, {
user,
}, {
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
});
}
}

View File

@@ -1,14 +1,14 @@
import { dialog, Notification } from './electron.js';
import createDebug from 'debug';
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 { ErrorResponse } from '../../api/util.js';
import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../../common/presence.js';
import { NotificationManager } from '../../common/notify.js';
import createDebug from '../../util/debug.js';
import { LoopResult } from '../../util/loop.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { App } from './index.js';
import { DiscordPresenceConfiguration, DiscordPresenceExternalMonitorsConfiguration, DiscordPresenceSource } from '../common/types.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';
@@ -45,6 +45,7 @@ export class PresenceMonitorManager {
i.presence_user = null;
i.user_notifications = false;
i.friend_notifications = false;
i.discord_preconnect = true;
i.discord.onUpdateActivity = (presence: DiscordPresence | null) => {
this.app.store.emit('update-discord-presence', presence ? {...presence, config: undefined} : null);
@@ -91,6 +92,7 @@ export class PresenceMonitorManager {
const i = new EmbeddedProxyPresenceMonitor(presence_url);
i.notifications = this.notifications;
i.discord_preconnect = true;
i.discord.onUpdateActivity = (presence: DiscordPresence | null) => {
this.app.store.emit('update-discord-presence', presence ? {...presence, config: undefined} : null);

View File

@@ -1,19 +1,20 @@
import { app, BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell } from './electron.js';
import process from 'node:process';
import * as crypto from 'node:crypto';
import createDebug from 'debug';
import * as persist from 'node-persist';
import { app, BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell } from './electron.js';
import { getNintendoAccountSessionToken, NintendoAccountSessionToken } from '../../api/na.js';
import { App, protocol_registration_options } from './index.js';
import { createModalWindow, createWindow } from './windows.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { WindowType } from '../common/types.js';
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 { getToken, SavedToken } from '../../common/auth/coral.js';
import { getPctlToken, SavedMoonToken } from '../../common/auth/moon.js';
import { ErrorResponse } from '../../api/util.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 { tryGetNativeImageFromUrl } from './util.js';
import { ZNCA_API_USE_URL } from '../../common/constants.js';
import { createWindow } from './windows.js';
import { WindowType } from '../common/types.js';
import { protocol_registration_options } from './index.js';
import { ZNCA_API_USE_TEXT, ZNCA_API_USE_URL } from '../../common/constants.js';
const debug = createDebug('app:main:na-auth');
@@ -54,7 +55,7 @@ html {
let i = 0;
export function createAuthWindow() {
export function createAuthWindow(app: App) {
const browser_session = session.defaultSession;
const window = new BrowserWindow({
@@ -63,7 +64,7 @@ export function createAuthWindow() {
resizable: false,
maximizable: false,
fullscreenable: false,
title: 'Nintendo Account',
title: app.i18n.t('na_auth:window.title') ?? 'Nintendo Account',
webPreferences: {
session: browser_session,
scrollBounce: true,
@@ -100,16 +101,22 @@ export class AuthoriseCancelError extends AuthoriseError {
}
}
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window: false):
Promise<NintendoAccountSessionTokenCode & {window: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window: true):
Promise<NintendoAccountSessionTokenCode & {window?: never}>
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window?: boolean):
Promise<NintendoAccountSessionTokenCode & {window?: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window = true) {
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window: false,
): Promise<NintendoAccountSessionTokenCode & {window: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window: true,
): Promise<NintendoAccountSessionTokenCode & {window?: never}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window?: boolean,
): Promise<NintendoAccountSessionTokenCode & {window?: BrowserWindow}>
export function getSessionTokenCodeByInAppBrowser(
app: App, client_id: string, scope: string | string[], close_window = true,
) {
return new Promise<NintendoAccountSessionTokenCode>((rs, rj) => {
const {url: authoriseurl, state, verifier, challenge} = getAuthUrl(client_id, scope);
const window = createAuthWindow();
const window = createAuthWindow(app);
const handleAuthUrl = (url: URL) => {
const authorisedparams = new URLSearchParams(url.hash.substr(1));
@@ -312,19 +319,9 @@ function askUserForRedirectUri(
authoriseurl: string, client_id: string,
handleAuthUrl: (url: URL) => void, rj: (reason: any) => void
) {
const window = createWindow(WindowType.ADD_ACCOUNT_MANUAL_PROMPT, {
const window = createModalWindow(WindowType.ADD_ACCOUNT_MANUAL_PROMPT, {
authoriseurl,
client_id,
}, {
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
});
window.webContents.on('will-navigate', (event, url_string) => {
@@ -354,9 +351,9 @@ const NSO_SCOPE = [
'user.screenName',
];
export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_browser = true) {
export async function addNsoAccount(app: App, use_in_app_browser = true) {
const {code, verifier, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false) :
await getSessionTokenCodeByInAppBrowser(app, ZNCA_CLIENT_ID, NSO_SCOPE, false) :
await getSessionTokenCodeByDefaultBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false);
window?.setFocusable(false);
@@ -365,101 +362,131 @@ export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_br
try {
const [jwt, sig] = Jwt.decode(code);
const nsotoken = await storage.getItem('NintendoAccountToken.' + jwt.payload.sub) as string | undefined;
const nsotoken = await app.store.storage.getItem('NintendoAccountToken.' + jwt.payload.sub) as string | undefined;
if (nsotoken) {
const data = await storage.getItem('NsoToken.' + nsotoken) as SavedToken | undefined;
debug('Already authenticated', jwt.payload);
debug('Already authenticated', data);
try {
const {nso, data} = await getToken(app.store.storage, nsotoken, process.env.ZNC_PROXY_URL, false);
new Notification({
title: 'Nintendo Switch Online',
body: 'Already signed in as ' + data?.nsoAccount.user.name + ' (' + data?.user.nickname + ')',
icon: await tryGetNativeImageFromUrl(data!.nsoAccount.user.imageUri),
}).show();
new Notification({
title: app.i18n.t('na_auth:notification_coral.title') ?? 'Nintendo Switch Online',
body: app.i18n.t('na_auth:notification_coral.body_existing', {
name: data.nsoAccount.user.name,
na_name: data.user.nickname,
na_username: data.user.screenName,
}) ?? 'Already signed in as ' + data.nsoAccount.user.name + ' (Nintendo Account ' +
data.user.nickname + ' / ' + data.user.screenName + ')',
icon: await tryGetNativeImageFromUrl(data.nsoAccount.user.imageUri),
}).show();
return getToken(storage, nsotoken, process.env.ZNC_PROXY_URL, false);
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);
}
}
throw err;
}
}
await checkZncaApiUseAllowed(storage, window);
await checkZncaApiUseAllowed(app, window);
const token = await getNintendoAccountSessionToken(code, verifier, ZNCA_CLIENT_ID);
debug('session token', token);
const {nso, data} = await getToken(storage, token.session_token, process.env.ZNC_PROXY_URL, false);
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await storage.setItem('NintendoAccountIds', [...users]);
new Notification({
title: 'Nintendo Switch Online',
body: 'Authenticated as ' + data.nsoAccount.user.name + ' (NSO ' + data.user.nickname + ')',
icon: await tryGetNativeImageFromUrl(data.nsoAccount.user.imageUri),
}).show();
return {nso, data};
return authenticateCoralSessionToken(app, code, verifier);
} finally {
window?.close();
}
}
export async function askAddNsoAccount(storage: persist.LocalStorage, iab = true) {
async function authenticateCoralSessionToken(
app: App,
code: string, verifier: string,
reauthenticate = false,
) {
const token = await getNintendoAccountSessionToken(code, verifier, ZNCA_CLIENT_ID);
debug('session token', token);
const {nso, data} = await getToken(app.store.storage, token.session_token, process.env.ZNC_PROXY_URL, false);
const users = new Set(await app.store.storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await app.store.storage.setItem('NintendoAccountIds', [...users]);
new Notification({
title: app.i18n.t('na_auth:notification_coral.title') ?? 'Nintendo Switch Online',
body: app.i18n.t('na_auth:notification_coral.body_' + (reauthenticate ? 're' : '') + 'authenticated', {
name: data.nsoAccount.user.name,
na_name: data.user.nickname,
na_username: data.user.screenName,
}) ?? (reauthenticate ?
'Reauthenticated to ' + data.nsoAccount.user.name + ' (Nintendo Account ' + data.user.nickname + ' / ' +
data.user.screenName + ')' :
'Authenticated as ' + data.nsoAccount.user.name + ' (Nintendo Account ' + data.user.nickname + ' / ' +
data.user.screenName + ')'),
icon: await tryGetNativeImageFromUrl(data.nsoAccount.user.imageUri),
}).show();
return {nso, data};
}
export async function askAddNsoAccount(app: App, iab = true) {
try {
return await addNsoAccount(storage, iab);
return await addNsoAccount(app, iab);
} catch (err: any) {
if (err instanceof AuthoriseError && err.code === 'access_denied') return;
dialog.showErrorBox('Error adding account', err.stack || err.message);
dialog.showErrorBox(app.i18n.t('na_auth:error.title') ?? 'Error adding account',
err.stack || err.message);
}
}
async function checkZncaApiUseAllowed(storage: persist.LocalStorage, window?: BrowserWindow, force = false) {
async function checkZncaApiUseAllowed(app: App, window?: BrowserWindow, force = false) {
if (!force) {
if (await storage.getItem('ZncaApiConsent')) {
if (await app.store.storage.getItem('ZncaApiConsent')) {
return;
}
if (process.env.ZNC_PROXY_URL) {
debug('Skipping znca API consent; znc proxy URL set');
await storage.setItem('ZncaApiConsent', true);
await app.store.storage.setItem('ZncaApiConsent', true);
return;
}
const ids: string[] | undefined = await storage.getItem('NintendoAccountIds');
const ids: string[] | undefined = await app.store.storage.getItem('NintendoAccountIds');
for (const id of ids ?? []) {
const nsotoken: string | undefined = await storage.getItem('NintendoAccountToken.' + id);
const nsotoken: string | undefined = await app.store.storage.getItem('NintendoAccountToken.' + id);
if (!nsotoken) continue;
debug('Skipping znca API consent; Nintendo Switch Online account already linked');
await storage.setItem('ZncaApiConsent', true);
await app.store.storage.setItem('ZncaApiConsent', true);
return;
}
}
if (await askZncaApiUseAllowed(window)) {
await storage.setItem('ZncaApiConsent', true);
if (await askZncaApiUseAllowed(app, window)) {
await app.store.storage.setItem('ZncaApiConsent', true);
} else {
throw new Error('Cannot continue without third-party APIs allowed');
}
}
const ZNCA_API_USE_TEXT = `To access the Nintendo Switch Online app API, nxapi must send some data to third-party APIs. This is required to generate some data to make Nintendo think you\'re using the real Nintendo Switch Online app.
By default, this uses the imink API, but another service can be used by setting an environment variable. The default API may change without notice if you do not force use of a specific service.
The data sent includes:
- When authenticating to the Nintendo Switch Online app: a Nintendo Account ID token, containing your Nintendo Account ID and country, which is valid for 15 minutes
- When authenticating to game-specific services: a Coral (Nintendo Switch Online app) ID token, containing your Coral user ID, Nintendo Switch Online membership status, and Nintendo Account child restriction status, which is valid for 2 hours`;
async function askZncaApiUseAllowed(window?: BrowserWindow): Promise<boolean> {
async function askZncaApiUseAllowed(app?: App, window?: BrowserWindow): Promise<boolean> {
const options: MessageBoxOptions = {
message: 'Third-party API usage',
detail: ZNCA_API_USE_TEXT,
buttons: ['OK', 'Cancel', 'More information'],
message: app?.i18n.t('na_auth:znca_api_use.title') ?? 'Third-party API usage',
detail: app?.i18n.t('na_auth:znca_api_use.text') ?? ZNCA_API_USE_TEXT,
buttons: [
app?.i18n.t('na_auth:znca_api_use.ok') ?? 'OK',
app?.i18n.t('na_auth:znca_api_use.cancel') ?? 'Cancel',
app?.i18n.t('na_auth:znca_api_use.more_information') ?? 'More information',
],
cancelId: 1,
};
@@ -471,7 +498,7 @@ async function askZncaApiUseAllowed(window?: BrowserWindow): Promise<boolean> {
if (result.response === 2) {
shell.openExternal(ZNCA_API_USE_URL);
return askZncaApiUseAllowed(window);
return askZncaApiUseAllowed(app, window);
}
return result.response === 0;
@@ -493,9 +520,9 @@ const MOON_SCOPE = [
'moonMonthlySummary',
];
export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_browser = true) {
export async function addPctlAccount(app: App, use_in_app_browser = true) {
const {code, verifier, window} = use_in_app_browser ?
await getSessionTokenCodeByInAppBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false) :
await getSessionTokenCodeByInAppBrowser(app, ZNMA_CLIENT_ID, MOON_SCOPE, false) :
await getSessionTokenCodeByDefaultBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false);
window?.setFocusable(false);
@@ -504,48 +531,78 @@ export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_b
try {
const [jwt, sig] = Jwt.decode(code);
const moontoken = await storage.getItem('NintendoAccountToken-pctl.' + jwt.payload.sub) as string | undefined;
const moontoken = await app.store.storage.getItem('NintendoAccountToken-pctl.' + jwt.payload.sub) as string | undefined;
if (moontoken) {
const data = await storage.getItem('MoonToken.' + moontoken) as SavedMoonToken | undefined;
debug('Already authenticated', jwt.payload);
debug('Already authenticated', data);
try {
const {moon, data} = await getPctlToken(app.store.storage, moontoken, false);
new Notification({
title: 'Nintendo Switch Parental Controls',
body: 'Already signed in as ' + data?.user.nickname,
}).show();
new Notification({
title: app.i18n.t('na_auth:notification_moon.title') ?? 'Nintendo Switch Parental Controls',
body: app.i18n.t('na_auth:notification_moon.body_existing', {
na_name: data.user.nickname,
na_username: data.user.screenName,
}) ?? 'Already signed in as ' + data.user.nickname + ' (' + data.user.screenName + ')',
}).show();
return getPctlToken(storage, moontoken, false);
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);
}
}
throw err;
}
}
const token = await getNintendoAccountSessionToken(code, verifier, ZNMA_CLIENT_ID);
debug('session token', token);
const {moon, data} = await getPctlToken(storage, token.session_token, false);
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await storage.setItem('NintendoAccountIds', [...users]);
new Notification({
title: 'Nintendo Switch Parental Controls',
body: 'Authenticated as ' + data.user.nickname,
}).show();
return {moon, data};
return authenticateMoonSessionToken(app, code, verifier);
} finally {
window?.close();
}
}
export async function askAddPctlAccount(storage: persist.LocalStorage, iab = true) {
async function authenticateMoonSessionToken(
app: App,
code: string, verifier: string,
reauthenticate = false,
) {
const token = await getNintendoAccountSessionToken(code, verifier, ZNMA_CLIENT_ID);
debug('session token', token);
const {moon, data} = await getPctlToken(app.store.storage, token.session_token, false);
const users = new Set(await app.store.storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await app.store.storage.setItem('NintendoAccountIds', [...users]);
new Notification({
title: app.i18n.t('na_auth:notification_moon.title') ?? 'Nintendo Switch Parental Controls',
body: app.i18n.t('na_auth:notification_moon.body_' + (reauthenticate ? 're' : '') + 'authenticated', {
na_name: data.user.nickname,
na_username: data.user.screenName,
}) ?? (reauthenticate ?
'Reauthenticated to ' + data.user.nickname + ' (' + data.user.screenName + ')' :
'Authenticated as ' + data.user.nickname + ' (' + data.user.screenName + ')'),
}).show();
return {moon, data};
}
export async function askAddPctlAccount(app: App, iab = true) {
try {
return await addPctlAccount(storage, iab);
return await addPctlAccount(app, iab);
} catch (err: any) {
if (err instanceof AuthoriseError && err.code === 'access_denied') return;
dialog.showErrorBox('Error adding account', err.stack || err.message);
dialog.showErrorBox(app.i18n.t('na_auth:error.title') ?? 'Error adding account',
err.stack || err.message);
}
}

View File

@@ -1,19 +1,19 @@
import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, nativeImage, nativeTheme, Notification, ShareMenu, shell, WebContents } from './electron.js';
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 createDebug from 'debug';
import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, nativeImage, nativeTheme, Notification, ShareMenu, shell, WebContents } from './electron.js';
import fetch from 'node-fetch';
import CoralApi from '../../api/coral.js';
import { CurrentUser, WebService, WebServiceToken } from '../../api/coral-types.js';
import { App, Store } from './index.js';
import type { DownloadImagesRequest, NativeShareRequest, NativeShareUrlRequest, QrCodeReaderCameraOptions, QrCodeReaderCheckinOptions, QrCodeReaderCheckinResult, QrCodeReaderPhotoLibraryOptions, SendMessageOptions } from '../preload-webservice/znca-js-api.js';
import { SavedToken } from '../../common/auth/coral.js';
import { createWebServiceWindow } from './windows.js';
import { askUserForUri } 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 { SavedToken } from '../../common/auth/coral.js';
const debug = createDebug('app:main:webservices');
@@ -68,6 +68,8 @@ export default async function openWebService(
if (!isWebServiceUrlAllowed(webservice, url)) {
debug('Web service attempted to navigate to a URL not allowed by it\'s `whiteList`', webservice, url);
debug('open', url);
shell.openExternal(url);
event.preventDefault();
}
});

View File

@@ -10,7 +10,7 @@ const windows = new WeakMap<WebContents, WindowConfiguration>();
export function createWindow<T extends WindowType>(
type: T, props: WindowConfiguration<T>['props'],
options?: BrowserWindowConstructorOptions
options?: BrowserWindowConstructorOptions,
) {
// Create the browser window
const window = new BrowserWindow({
@@ -51,6 +51,62 @@ export function getWindowConfiguration(webcontents: WebContents): WindowConfigur
return data;
}
const modal_window_width = new WeakMap<BrowserWindow, number>();
const modal_window_shown = new WeakSet<BrowserWindow>();
export function createModalWindow<T extends WindowType>(
type: T, props: WindowConfiguration<T>['props'],
parent?: BrowserWindow | WebContents,
options?: BrowserWindowConstructorOptions,
) {
if (parent && !(parent instanceof BrowserWindow)) {
parent = BrowserWindow.fromWebContents(parent) ?? undefined;
}
const window = createWindow(type, props, {
parent,
modal: !!parent,
show: false,
maximizable: false,
minimizable: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
...options,
});
if (process.platform === 'win32') {
// Use a fixed window width on Windows due to a bug getting/setting window size
window.setResizable(false);
modal_window_width.set(window, options?.width ?? 560);
}
return window;
}
export function setWindowHeight(window: BrowserWindow, height: number) {
const [curWidth, curHeight] = window.getSize();
const [curContentWidth, curContentHeight] = window.getContentSize();
const [minWidth, minHeight] = window.getMinimumSize();
const [maxWidth, maxHeight] = window.getMaximumSize();
if (height !== curContentHeight && curHeight === minHeight && curHeight === maxHeight) {
window.setMinimumSize(minWidth, height + (curHeight - curContentHeight));
window.setMaximumSize(maxWidth, height + (curHeight - curContentHeight));
}
window.setContentSize(modal_window_width.get(window) ?? curContentWidth, height);
if (!modal_window_shown.has(window)) {
window.show();
modal_window_shown.add(window);
}
}
const BACKGROUND_COLOUR_MAIN_LIGHT = process.platform === 'win32' ? '#ffffff' : '#ececec';
const BACKGROUND_COLOUR_MAIN_DARK = process.platform === 'win32' ? '#000000' : '#252424';