Electron app

This commit is contained in:
Samuel Elliott 2022-06-05 15:02:24 +01:00
parent 27e8fa5401
commit 60c0e592db
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
35 changed files with 2604 additions and 474 deletions

3
.vscode/launch.json vendored
View File

@ -10,6 +10,9 @@
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"osx": {
"runtimeExecutable": "${workspaceFolder}/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron"
},
"windows": {
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
},

View File

@ -8,6 +8,13 @@ import nodePolyfill from 'rollup-plugin-polyfill-node';
import html from '@rollup/plugin-html';
import json from '@rollup/plugin-json';
/**
* @type {import('rollup').RollupOptions['watch']}
*/
const watch = {
include: 'src/**',
};
/**
* @type {import('rollup').RollupOptions}
*/
@ -40,6 +47,7 @@ const cli = {
'node-notifier',
'frida',
],
watch,
};
/**
@ -73,6 +81,7 @@ const app = {
external: [
'electron',
],
watch,
};
/**
@ -102,6 +111,7 @@ const app_preload = {
external: [
'electron',
],
watch,
};
/**
@ -131,6 +141,7 @@ const app_preload_webservice = {
external: [
'electron',
],
watch,
};
/**
@ -166,6 +177,7 @@ const app_browser = {
preferBuiltins: false,
}),
],
watch,
};
export default [

View File

@ -218,22 +218,7 @@ export interface NintendoAccountUser {
region: null;
nickname: string;
clientFriendsOptedIn: boolean;
mii: {
favoriteColor: string;
id: string;
updatedAt: number;
coreData: {
'4': string;
};
clientId: '1cfe3a55ed8924d9';
imageUriTemplate: string;
storeData: {
'3': string;
};
imageOrigin: string;
etag: string;
type: 'profile';
};
mii: Mii | null;
isChild: boolean;
eachEmailOptedIn: {
survey: {
@ -269,6 +254,23 @@ export interface NintendoAccountUser {
clientFriendsOptedInUpdatedAt: number;
}
export interface Mii {
favoriteColor: string;
id: string;
updatedAt: number;
coreData: {
'4': string;
};
clientId: '1cfe3a55ed8924d9';
imageUriTemplate: string;
storeData: {
'3': string;
};
imageOrigin: string;
etag: string;
type: 'profile';
}
export interface NintendoAccountError {
errorCode: string;
detail: string;

View File

@ -14,9 +14,31 @@ export enum ZncStatus {
OK = 0,
BAD_REQUEST = 9400,
METHOD_NOT_ALLOWED = 9401,
RESOURCE_NOT_FOUND = 9402,
INVALID_TOKEN = 9403,
TOKEN_EXPIRED = 9404,
FORBIDDEN = 9405,
UNAUTHORISED = 9406,
NSA_NOT_LINKED = 9407,
APPLICATION_ID_NOT_SUPPORTED = 9409,
EVENT_NOT_ACTIVATED_ERROR = 9412,
NOT_JOINED_VOICE_CHAT_ERROR = 9416,
DUPLICATE_APPLICATION_ID_ERROR = 9417,
OPERATION_NOT_ALLOWED_ERROR = 9422,
RATING_AGE_ERROR = 9423,
USER_NOT_ACTIVATED_ERROR = 9424,
INVITATION_LIMIT_EXCEEDED_ERROR = 9425,
MULTIPLE_LOGIN_ERROR = 9426,
UPGRADE_REQUIRED = 9427,
ACCOUNT_DISABLED_ERROR = 9428,
MEMBERSHIP_REQUIRED_ERROR = 9450,
SERVICE_CLOSED_ERROR = 9499,
INTERNAL_SERVER_ERROR = 9500,
SERVICE_UNAVAILABLE = 9501,
MAINTENANCE_ERROR = 9511,
UNEXPECTED_ERROR = 9599,
// UNKNOWN = -1,
}
export type ZncResponse<T = unknown> = ZncSuccessResponse<T> | ZncErrorResponse;
@ -68,6 +90,10 @@ export interface Friend {
export interface Presence {
state: PresenceState;
/**
* Timestamp (in seconds) the user's presence was last updated.
* This seems to change every hour if a linked console is online, even if the user's presence doesn't change.
*/
updatedAt: number;
logoutAt: number;
game: Game | {};

View File

@ -1,23 +1,31 @@
import React, { useCallback } from 'react';
import { StyleSheet, Text, useColorScheme, View } from 'react-native';
import { NintendoAccountUser } from '../../api/na.js';
import { SavedToken } from '../../common/auth/nso.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, useColorScheme, View } from 'react-native';
import type { NintendoAccountUser } from '../../api/na.js';
import type { SavedToken } from '../../common/auth/nso.js';
import type { SavedMoonToken } from '../../common/auth/moon.js';
import ipc from './ipc.js';
import { useAsync } from './util.js';
import { Root, useAsync } from './util.js';
import Sidebar from './main/sidebar.js';
import Update from './main/update.js';
import Main from './main/index.js';
import { BACKGROUND_COLOUR_MAIN_DARK, BACKGROUND_COLOUR_MAIN_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from './constants.js';
export interface AppProps {
//
}
export interface User {
user: NintendoAccountUser;
nso: SavedToken | null;
nsotoken: string | undefined;
moon: SavedMoonToken | null;
moontoken: string | undefined;
}
async function getAccounts() {
const ids = await ipc.listNintendoAccounts();
const accounts: {
user: NintendoAccountUser;
nso: SavedToken | null;
moon: SavedMoonToken | null;
}[] = [];
const accounts: User[] = [];
for (const id of ids ?? []) {
const nsotoken = await ipc.getNintendoAccountNsoToken(id);
@ -28,42 +36,74 @@ async function getAccounts() {
if (!nso && !moon) continue;
accounts.push({user: nso?.user ?? moon!.user, nso, moon});
accounts.push({user: nso?.user ?? moon!.user, nso, nsotoken, moon, moontoken});
}
return accounts;
}
function App(props: AppProps) {
const theme = useColorScheme() === 'light' ? light : dark;
const colour_scheme = useColorScheme();
const theme = colour_scheme === 'light' ? light : dark;
const [users] = useAsync(useCallback(() => getAccounts(), [ipc]));
console.log(users);
return <View style={styles.app}>
<Text>Hello from React!</Text>
const [selectedUserId, setSelectedUserId] = useState<string | undefined>(undefined);
const selectedUser = useMemo(() => users?.find(u => u.user.id === selectedUserId), [users, selectedUserId]);
{users?.map(u => <Text key={u.user.id} style={theme.text}>
{u.user.id} - {u.user.nickname}
</Text>)}
</View>;
useEffect(() => {
if (!selectedUser) setSelectedUserId(users?.[0]?.user.id);
}, [users, selectedUser]);
return <Root titleUser={selectedUser} style={styles.app}>
<Sidebar users={users} selectedUser={selectedUserId} onSelectUser={setSelectedUserId} />
<View style={[styles.main, theme.main]}>
<ScrollView style={styles.scroller} contentContainerStyle={styles.scrollerContent}>
<Update />
{selectedUser ? <Main key={selectedUser.user.id} user={selectedUser} /> : null}
</ScrollView>
</View>
</Root>;
}
const styles = StyleSheet.create({
app: {
flexDirection: 'row',
},
main: {
flex: 1,
},
scroller: {
flex: 1,
},
scrollerContent: {
flex: 1,
},
content: {
paddingVertical: 16,
paddingHorizontal: 20,
},
});
const light = StyleSheet.create({
main: {
backgroundColor: BACKGROUND_COLOUR_MAIN_LIGHT,
},
text: {
color: '#212121',
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
main: {
backgroundColor: BACKGROUND_COLOUR_MAIN_DARK,
},
text: {
color: '#f5f5f5',
color: TEXT_COLOUR_DARK,
},
});

View File

@ -0,0 +1,18 @@
export const NSO_COLOUR = '#e60012';
export const NSO_COLOUR_DARK: `${typeof NSO_COLOUR}e0` = `${NSO_COLOUR}e0`;
export const DISCORD_COLOUR = '#5865f2';
export const BACKGROUND_COLOUR_MAIN_LIGHT = '#e7e7e7';
export const BACKGROUND_COLOUR_MAIN_DARK = '#202020';
export const UPDATE_COLOUR = '#006064e0';
export const HIGHLIGHT_COLOUR_LIGHT = '#00000020';
export const HIGHLIGHT_COLOUR_DARK = '#ffffff20';
export const BORDER_COLOUR_LIGHT = '#00000020';
export const BORDER_COLOUR_DARK = '#00000080';
export const TEXT_COLOUR_LIGHT = '#212121';
export const TEXT_COLOUR_DARK = '#f5f5f5';
export const TEXT_COLOUR_ACTIVE = '#3ba55d';

View File

@ -0,0 +1,226 @@
import React, { useCallback, useEffect } from 'react';
import { Button, Image, StyleSheet, Text, useColorScheme, View } from 'react-native';
import { Game, Presence, PresencePermissions, PresenceState } from '../../../api/znc-types.js';
import { getTitleIdFromEcUrl, hrduration } from '../../../util/misc.js';
import { TEXT_COLOUR_ACTIVE, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc, { events } from '../ipc.js';
import { RequestState, Root, useAsync, useColourScheme, useDiscordPresenceSource, useEventListener } from '../util.js';
export interface FriendProps {
user: string;
friend: string;
}
export default function Friend(props: FriendProps) {
const colour_scheme = useColorScheme();
const theme = colour_scheme === 'light' ? light : dark;
const [accent_colour, setAccentColour] = React.useState(() => ipc.getAccentColour());
useEventListener(events, 'systemPreferences:accent-colour', setAccentColour, []);
const [discord_presence_source, discord_presence_source_state] = useDiscordPresenceSource();
const [token] = useAsync(useCallback(() => ipc.getNintendoAccountNsoToken(props.user), [ipc, props.user]));
const [user] = useAsync(useCallback(() => token ?
ipc.getSavedNsoToken(token) : Promise.resolve(null), [ipc, token]));
const [friends, , friends_state, forceRefreshFriends] = useAsync(useCallback(() => token ?
ipc.getNsoFriends(token) : Promise.resolve(null), [ipc, token]));
const friend = friends?.find(f => f.nsaId === props.friend);
useEffect(() => {
if (friends_state !== RequestState.LOADED) return;
const timeout = setTimeout(forceRefreshFriends, 60 * 1000);
return () => clearTimeout(timeout);
}, [ipc, token, friends_state]);
if (friends && !friend) throw new Error('Unknown friend');
if (!user || !friend || discord_presence_source_state !== RequestState.LOADED) return null;
const discord_presence_active = discord_presence_source && 'na_id' in discord_presence_source &&
discord_presence_source.na_id === user.user.id &&
discord_presence_source.friend_nsa_id === friend.nsaId;
const can_see_user_presence = user.nsoAccount.user.permissions.presence === PresencePermissions.FRIENDS ||
(user.nsoAccount.user.permissions.presence === PresencePermissions.FAVORITE_FRIENDS && friend.isFavoriteFriend);
return <Root title={friend.name} titleUser={user} scrollable>
<View style={styles.main}>
<View style={styles.friend}>
<Image source={{uri: friend.imageUri, width: 130, height: 130}} style={styles.friendImage} />
<Text style={[styles.friendName, theme.text]}>{friend.name}</Text>
{friend.presence.updatedAt ?
<FriendPresence presence={friend.presence} /> :
<Text style={[styles.noPresence, theme.text]}>You don't have access to this user's presence, or they have never been online.</Text>}
</View>
<View style={styles.right}>
<View style={styles.detail}>
{(friend.presence.state === PresenceState.ONLINE || friend.presence.state === PresenceState.PLAYING) &&
'name' in friend.presence.game ? <FriendPresenceGame game={friend.presence.game} /> : null}
<Text style={theme.text}>NSA ID: {friend.nsaId}</Text>
<Text style={theme.text}>{friend.isServiceUser ? 'Coral user ID: ' + friend.id : 'Never used Nintendo Switch Online app'}</Text>
<Text style={theme.text}>Friends since {new Date(friend.friendCreatedAt * 1000).toLocaleString('en-GB')}</Text>
{friend.presence.updatedAt ? <Text style={theme.text}>Presence updated at {new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB')}</Text> : null}
<Text style={theme.text}>This user {can_see_user_presence ? 'can' : 'can not'} see your presence.</Text>
</View>
<View style={styles.buttons}>
{discord_presence_active ? <Button title="Stop sharing presence to Discord"
onPress={() => ipc.setDiscordPresenceSource(null)}
color={'#' + accent_colour} /> :
friend.presence.updatedAt ? <Button title="Share presence to Discord"
onPress={() => ipc.setDiscordPresenceSource({na_id: user.user.id, friend_nsa_id: friend.nsaId})}
color={'#' + accent_colour} /> : null}
<Button title="Close"
onPress={() => window.close()}
color={'#' + accent_colour} />
</View>
</View>
</View>
</Root>;
}
function FriendPresence(props: {
presence: Presence;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const logout = props.presence.logoutAt ? new Date(props.presence.logoutAt * 1000) : null;
const game = 'name' in props.presence.game ? props.presence.game : null;
if (props.presence.state === PresenceState.ONLINE || props.presence.state === PresenceState.PLAYING) {
return <Text style={[styles.presenceText, theme.text, styles.presenceTextOnline]}>Playing {game?.name}</Text>;
}
return <View>
<Text style={[styles.presenceText, styles.presenceTextOffline, theme.text]}>Offline</Text>
{logout ? <Text style={[styles.presenceText, styles.presenceTextOffline, theme.text]}>Last seen {logout.toLocaleString('en-GB')}</Text> : null}
</View>;
}
function FriendPresenceGame(props: {
game: Game;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const titleid = getTitleIdFromEcUrl(props.game.shopUri);
const first_played = props.game.firstPlayedAt ? new Date(props.game.firstPlayedAt * 1000) : null;
return <View style={styles.game}>
<Image source={{uri: props.game.imageUri, width: 80, height: 80}} style={styles.gameIcon} />
<View style={styles.gameDetail}>
<Text style={[styles.gameName, theme.text]}>{props.game.name}</Text>
{props.game.sysDescription ? <Text style={[styles.gameActivity, theme.text]}>{props.game.sysDescription}</Text> : null}
<Text style={[styles.gameTotalPlayTime, theme.text]}>Played for {hrduration(props.game.totalPlayTime)}</Text>
<Text style={[styles.gameFirstPlayed, theme.text]}>First played {first_played?.toLocaleString('en-GB') ?? 'now'}</Text>
{titleid ? <Text style={[styles.gameTitleId, theme.text]}>Title ID: <Text style={styles.gameTitleIdValue}>{titleid}</Text></Text> : null}
</View>
</View>;
}
const styles = StyleSheet.create({
main: {
flex: 1,
paddingVertical: 20,
paddingHorizontal: 20,
flexDirection: 'row',
},
friend: {
marginRight: 20,
width: 130,
},
friendImage: {
marginBottom: 14,
},
friendName: {
fontSize: 18,
fontWeight: '500',
textAlign: 'center',
},
noPresence: {
marginTop: 8,
textAlign: 'center',
fontSize: 12,
opacity: 0.7,
},
right: {
flex: 1,
},
detail: {
flex: 1,
},
buttons: {
marginTop: 20,
flexDirection: 'row',
justifyContent: 'flex-end',
},
presenceText: {
marginTop: 8,
textAlign: 'center',
fontSize: 12,
},
presenceTextOnline: {
color: TEXT_COLOUR_ACTIVE,
},
presenceTextOffline: {
opacity: 0.7,
},
game: {
marginBottom: 20,
flexDirection: 'row',
},
gameIcon: {
marginRight: 15,
},
gameDetail: {
flex: 1,
},
gameName: {
fontSize: 16,
fontWeight: '400',
userSelect: 'text',
},
gameActivity: {
marginTop: 5,
fontSize: 13,
userSelect: 'text',
},
gameTotalPlayTime: {
marginTop: 5,
fontSize: 13,
},
gameFirstPlayed: {
marginTop: 2,
fontSize: 13,
},
gameTitleId: {
marginTop: 5,
fontSize: 13,
},
gameTitleIdValue: {
fontFamily: 'monospace',
userSelect: 'all',
},
});
const light = StyleSheet.create({
text: {
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
text: {
color: TEXT_COLOUR_DARK,
},
});

View File

@ -1,8 +1,20 @@
import { AppRegistry } from 'react-native';
import { config } from './ipc.js';
import App from './app.jsx';
import Friend from './friend/index.js';
AppRegistry.registerComponent('App', () => App);
AppRegistry.registerComponent('Friend', () => Friend);
const style = window.document.createElement('style');
style.textContent = `
html {
user-select: none;
}
`;
window.document.head.appendChild(style);
const rootTag = window.document.createElement('div');

View File

@ -1,4 +1,4 @@
import type { EventEmitter } from 'node:events';
import { EventEmitter } from 'events';
import createDebug from 'debug';
import type { NxapiElectronIpc } from '../preload/index.js';
@ -10,7 +10,21 @@ declare global {
}
}
const ipc = window.nxapiElectronIpc;
export const events = new EventEmitter();
events.setMaxListeners(0);
const ipc = {
...window.nxapiElectronIpc,
events,
};
events.on('newListener', (event: string, listener: (...args: any[]) => void) => {
ipc.registerEventListener(event, listener);
});
events.on('removeListener', (event: string, listener: (...args: any[]) => void) => {
ipc.removeEventListener(event, listener);
});
export default ipc;

View File

@ -0,0 +1,145 @@
import React, { useCallback, useEffect } from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';
import { User } from 'discord-rpc';
import ipc from '../ipc.js';
import { RequestState, useAsync } from '../util.js';
import { DiscordPresenceSource, DiscordPresenceSourceUrl, DiscordPresenceSourceZnc } from '../../common/types.js';
import { DiscordPresence } from '../../../discord/util.js';
import { DISCORD_COLOUR, TEXT_COLOUR_DARK } from '../constants.js';
export default function DiscordPresenceSource(props: {
source: DiscordPresenceSource | null;
presence: DiscordPresence | null;
user: User | null;
}) {
if (!props.source) return null;
return <View style={[styles.discord, !props.source ? styles.discordInactive : null]}>
{renderDiscordPresenceSource(props.source)}
{props.presence && props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
</View>;
}
function renderDiscordPresenceSource(source: DiscordPresenceSource | null) {
if (source && 'na_id' in source) {
return <DiscordPresenceSourceZnc source={source} />;
} else if (source && 'url' in source) {
return <DiscordPresenceSourceUrl source={source} />;
} else {
return <DiscordPresenceInactive />;
}
}
function DiscordPresenceSourceZnc(props: {
source: DiscordPresenceSourceZnc;
}) {
const [token] = useAsync(useCallback(() =>
ipc.getNintendoAccountNsoToken(props.source.na_id), [ipc, props.source.na_id]));
const [user] = useAsync(useCallback(() => token ?
ipc.getSavedNsoToken(token) : Promise.resolve(null), [ipc, token]));
const [friends, , friends_state, forceRefreshFriends] = useAsync(useCallback(() => token ?
ipc.getNsoFriends(token) : Promise.resolve(null), [ipc, token]));
const friend = friends?.find(f => f.nsaId === props.source.friend_nsa_id);
useEffect(() => {
if (friends_state !== RequestState.LOADED) return;
const timeout = setTimeout(forceRefreshFriends, 60 * 1000);
return () => clearTimeout(timeout);
}, [ipc, token, friends_state]);
return <View style={styles.discordSource}>
{friend ? <Text style={styles.discordSourceText}>
Discord Rich Presence active:{' '}
<Image source={{uri: friend.imageUri, width: 16, height: 16}} style={styles.discordNsoUserImage} />{' '}
{friend.name}
</Text> : <Text style={styles.discordSourceText}>
Discord Rich Presence active
</Text>}
</View>;
}
function DiscordPresenceSourceUrl(props: {
source: DiscordPresenceSourceUrl;
}) {
return <View style={styles.discordSource}>
<Text style={styles.discordSourceText}>Discord Rich Presence active: {props.source.url}</Text>
</View>;
}
function DiscordPresenceInactive() {
return <View style={styles.discordSource}>
<Text style={styles.discordSourceText}>Discord Rich Presence not active</Text>
</View>;
}
function DiscordPresence(props: {
presence: DiscordPresence;
user: User;
}) {
const large_image_url = 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';
return <>
<View style={styles.discordPresence}>
<Image source={{uri: large_image_url, width: 18, height: 18}} style={styles.discordPresenceImage} />
<Text style={styles.discordPresenceText}>Playing</Text>
</View>
<View style={styles.discordUser}>
<Image source={{uri: user_image_url, width: 18, height: 18}} style={styles.discordUserImage} />
<Text style={styles.discordUserText}>Connected as {props.user.username}#{props.user.discriminator}</Text>
</View>
</>;
}
const styles = StyleSheet.create({
discord: {
backgroundColor: DISCORD_COLOUR,
paddingVertical: 16,
paddingHorizontal: 20,
},
discordInactive: {
opacity: 0.7,
},
discordSource: {
},
discordSourceText: {
color: TEXT_COLOUR_DARK,
},
discordNsoUserImage: {
borderRadius: 8,
textAlignVertical: -3,
},
discordPresence: {
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
},
discordPresenceImage: {
marginRight: 10,
borderRadius: 2,
},
discordPresenceText: {
color: TEXT_COLOUR_DARK,
},
discordUser: {
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
},
discordUserImage: {
marginRight: 10,
borderRadius: 9,
},
discordUserText: {
color: TEXT_COLOUR_DARK,
},
});

View File

@ -0,0 +1,87 @@
import React from 'react';
import { Button, Image, StyleSheet, Text, View } from 'react-native';
import ipc from '../ipc.js';
import { useAccentColour, useColourScheme } from '../util.js';
import { User } from '../app.js';
import { ActiveEvent } from '../../../api/znc-types.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import Section from './section.js';
export default function Event(props: {
user: User;
event: ActiveEvent;
loading?: boolean;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const event_members = props.event.members.filter(m => m.isPlaying).length;
const voip_members = props.event.members.filter(m => m.isJoinedVoip).length;
return <Section title="Voice chat" loading={props.loading}>
<View style={styles.content}>
<Image source={{uri: props.event.imageUri, width: 100, height: 100}} style={styles.image} />
<View style={styles.detail}>
<Text style={[styles.eventName, theme.text]}>{props.event.name}</Text>
<Text style={[styles.eventMembers, theme.text]}>
{event_members} in game, {voip_members} in voice chat
{props.event.members.length > 1 ? ' of ' + props.event.members.length + ' members' : ''}
</Text>
<Text style={[styles.eventInstruction, theme.text]}>
Use the Nintendo Switch Online app on iOS or Android to {voip_members ? 'join' : 'start'} voice chat.
</Text>
{props.event.shareUri ? <View style={styles.shareButton}>
<Button title="Share" onPress={() => ipc.share({urls: [props.event.shareUri]})}
color={'#' + accent_colour} />
</View> : null}
</View>
</View>
</Section>;
}
const styles = StyleSheet.create({
content: {
paddingBottom: 16,
paddingHorizontal: 20,
flexDirection: 'row',
},
image: {
marginRight: 20,
borderRadius: 2,
},
detail: {
flex: 1,
},
eventName: {
fontSize: 16,
fontWeight: '500',
marginBottom: 5,
},
eventMembers: {
marginBottom: 5,
fontSize: 13,
},
eventInstruction: {
fontSize: 13,
opacity: 0.7,
},
shareButton: {
marginTop: 10,
flexDirection: 'row',
},
});
const light = StyleSheet.create({
text: {
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
text: {
color: TEXT_COLOUR_DARK,
},
});

View File

@ -0,0 +1,145 @@
import React from 'react';
import { Image, ImageStyle, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import ipc from '../ipc.js';
import { useColourScheme } from '../util.js';
import { User } from '../app.js';
import { Friend, Presence, PresenceState } from '../../../api/znc-types.js';
import { TEXT_COLOUR_ACTIVE, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import Section from './section.js';
export default function Friends(props: {
user: User;
friends: Friend[];
loading?: boolean;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const fc = <Text style={styles.friendCodeValue}>SW-{props.user.nso!.nsoAccount.user.links.friendCode.id}</Text>;
return <Section title="Friends" loading={props.loading}>
{props.friends.length ? <ScrollView horizontal>
<View style={styles.content}>
{props.friends.map(f => <Friend key={f.nsaId} friend={f} user={props.user.user.id} />)}
</View>
</ScrollView> : <View style={styles.noFriends}>
<Text style={[styles.noFriendsText, theme.text]}>Add friends using a Nintendo Switch console.</Text>
<Text style={[styles.noFriendsText, styles.noFriendsFriendCodeText, theme.text]}>Your friend code: {fc}</Text>
</View>}
{props.friends.length ? <View style={styles.footer}>
<Text style={[styles.friendCode, theme.text]}>Your friend code: {fc}</Text>
</View> : null}
</Section>;
}
function Friend(props: {
friend: Friend;
user?: string;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const game = 'name' in props.friend.presence.game ? props.friend.presence.game : null;
const content = <View style={styles.friend}>
<Image source={{uri: props.friend.imageUri, width: 50, height: 50}} style={styles.friendImage as ImageStyle} />
{game ? <Image source={{uri: game.imageUri, width: 20, height: 20}} style={styles.presenceImage as ImageStyle} /> : null}
<Text style={theme.text}>{props.friend.name}</Text>
{props.friend.presence.updatedAt ? <FriendPresence presence={props.friend.presence} /> : null}
</View>;
return <View style={styles.friendContainer}>
{props.user ? <TouchableOpacity onPress={() => ipc.showFriendModal(props.user!, props.friend.nsaId)}>
{content}
</TouchableOpacity> : content}
</View>;
}
function FriendPresence(props: {
presence: Presence;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const game = 'name' in props.presence.game ? props.presence.game : null;
if (props.presence.state === PresenceState.ONLINE || props.presence.state === PresenceState.PLAYING) {
return <Text numberOfLines={2} ellipsizeMode="tail" style={[styles.presenceText, theme.text, styles.presenceTextOnline]}>Playing {game?.name}</Text>;
}
return <Text style={[styles.presenceText, styles.presenceTextOffline, theme.text]}>Offline</Text>;
}
const styles = StyleSheet.create({
footer: {
paddingBottom: 16,
paddingHorizontal: 20,
},
friendCode: {
fontSize: 12,
opacity: 0.7,
},
friendCodeValue: {
// @ts-expect-error
userSelect: 'all',
},
content: {
paddingBottom: 16,
paddingLeft: 20,
flexDirection: 'row',
},
noFriends: {
paddingVertical: 32,
marginBottom: 20,
},
noFriendsText: {
textAlign: 'center',
},
noFriendsFriendCodeText: {
marginTop: 10,
},
friendContainer: {
marginRight: 20,
},
friend: {
minWidth: 55,
maxWidth: 80,
alignItems: 'center',
},
friendImage: {
borderRadius: 25,
marginBottom: 12,
},
presenceImage: {
position: 'absolute',
marginTop: 32,
marginLeft: 32,
borderRadius: 2,
},
presenceText: {
marginTop: 5,
fontSize: 12,
textAlign: 'center',
},
presenceTextOnline: {
color: TEXT_COLOUR_ACTIVE,
},
presenceTextOffline: {
opacity: 0.7,
},
});
const light = StyleSheet.create({
text: {
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
text: {
color: TEXT_COLOUR_DARK,
},
});

View File

@ -0,0 +1,127 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Button, StyleSheet, Text, View } from 'react-native';
import ipc from '../ipc.js';
import { RequestState, useAccentColour, useAsync, useColourScheme } from '../util.js';
import { User } from '../app.js';
import Friends from './friends.js';
import WebServices from './webservices.js';
import Event from './event.js';
import Section from './section.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
export default function Main(props: {
user: User;
}) {
const accent_colour = useAccentColour();
const [announcements, , announcements_state] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getNsoAnnouncements(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
const [friends, , friends_state, forceRefreshFriends] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getNsoFriends(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
const [webservices, , webservices_state, forceRefreshWebServices] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getNsoWebServices(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
const [active_event, , active_event_state, forceRefreshActiveEvent] = useAsync(useCallback(() => props.user.nsotoken ?
ipc.getNsoActiveEvent(props.user.nsotoken) : Promise.resolve(null), [ipc, props.user.nsotoken]));
const loading = announcements_state === RequestState.LOADING ||
friends_state === RequestState.LOADING ||
webservices_state === RequestState.LOADING ||
active_event_state === RequestState.LOADING;
const refresh = useCallback(() => Promise.all([
forceRefreshFriends(), forceRefreshWebServices(), forceRefreshActiveEvent(),
]), [forceRefreshFriends, forceRefreshWebServices, forceRefreshActiveEvent]);
const [auto_refresh, setAutoRefresh] = useState(false);
useEffect(() => {
if (loading || !auto_refresh) return;
const timeout = setTimeout(refresh, 60 * 1000);
return () => clearTimeout(timeout);
}, [ipc, props.user.nsotoken, loading, auto_refresh]);
if (loading && (!announcements || !friends || !webservices || !active_event)) {
return <View style={styles.loading}>
<ActivityIndicator size="large" color={'#' + accent_colour} />
</View>;
}
return <View>
{!props.user.nso && props.user.moon ? <MoonOnlyUser /> : null}
{props.user.nso && friends ? <Friends user={props.user} friends={friends}
loading={friends_state === RequestState.LOADING} /> : null}
{props.user.nso && webservices ? <WebServices user={props.user} webservices={webservices}
loading={webservices_state === RequestState.LOADING} /> : null}
{props.user.nso && active_event && 'id' in active_event ? <Event user={props.user} event={active_event}
loading={active_event_state === RequestState.LOADING} /> : null}
<View style={styles.container}>
<Button title="Refresh"
onPress={refresh}
color={'#' + accent_colour} />
<Button title={auto_refresh ? 'Disable auto refresh' : 'Enable auto refresh'}
onPress={() => setAutoRefresh(!auto_refresh)}
color={'#' + accent_colour} />
</View>
</View>;
}
function MoonOnlyUser() {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
return <Section title="Nintendo Switch Online">
<View style={styles.moonOnlyUser}>
<Text style={[styles.moonOnlyUserText, theme.text]}>This user is signed in to the Nintendo Switch Parental Controls app, but not the Nintendo Switch Online app.</Text>
<Text style={[styles.moonOnlyUserText, theme.text]}>Login to the Nintendo Switch Online app to view details here, or use the nxapi command to access Parental Controls data.</Text>
<View style={styles.moonOnlyUserButton}>
<Button title="Login" onPress={() => ipc.addNsoAccount()} color={'#' + accent_colour} />
</View>
</View>
</Section>;
}
const styles = StyleSheet.create({
loading: {
flex: 1,
paddingVertical: 16,
paddingHorizontal: 20,
justifyContent: 'center',
},
container: {
paddingVertical: 16,
paddingHorizontal: 20,
},
moonOnlyUser: {
paddingVertical: 32,
paddingHorizontal: 20,
marginBottom: 20,
},
moonOnlyUserText: {
marginBottom: 10,
textAlign: 'center',
},
moonOnlyUserButton: {
marginTop: 10,
flexDirection: 'row',
justifyContent: 'center',
},
});
const light = StyleSheet.create({
text: {
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
text: {
color: TEXT_COLOUR_DARK,
},
});

View File

@ -0,0 +1,57 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { useAccentColour, useColourScheme } from '../util.js';
import { BORDER_COLOUR_DARK, BORDER_COLOUR_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
export default function Section(props: React.PropsWithChildren<{
title: string;
loading?: boolean;
}>) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
return <View style={[styles.container, theme.container]}>
<View style={styles.header}>
<Text style={[styles.headerText, theme.text]}>{props.title}</Text>
{props.loading ? <ActivityIndicator style={styles.activityIndicator} size={14}
color={'#' + accent_colour} /> : null}
</View>
{props.children}
</View>;
}
const styles = StyleSheet.create({
container: {
borderBottomWidth: 1,
},
header: {
paddingVertical: 16,
paddingHorizontal: 20,
flexDirection: 'row',
},
headerText: {
flex: 1,
},
activityIndicator: {
marginLeft: 10,
},
});
const light = StyleSheet.create({
container: {
borderBottomColor: BORDER_COLOUR_LIGHT,
},
text: {
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
container: {
borderBottomColor: BORDER_COLOUR_DARK,
},
text: {
color: TEXT_COLOUR_DARK,
},
});

View File

@ -0,0 +1,199 @@
import React from 'react';
import { Button, Image, ImageURISource, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import ipc from '../ipc.js';
import { RequestState, useAccentColour, useActiveDiscordPresence, useActiveDiscordUser, useAsync, useColourScheme, useDiscordPresenceSource } from '../util.js';
import { User } from '../app.js';
import DiscordPresenceSource from './discord.js';
import { BORDER_COLOUR_DARK, BORDER_COLOUR_LIGHT, HIGHLIGHT_COLOUR_DARK, HIGHLIGHT_COLOUR_LIGHT, NSO_COLOUR, NSO_COLOUR_DARK, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
export default function Sidebar(props: {
users?: User[] | null;
selectedUser?: string;
onSelectUser?: (na_id: string) => void;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const [discord_presence_source, discord_presence_source_state] = useDiscordPresenceSource();
const presence = useActiveDiscordPresence();
const discord_user = useActiveDiscordUser();
return <View style={[styles.sidebar, theme.sidebar]}>
<View style={[styles.top, theme.top]}>
<Text style={styles.topText}>nxapi</Text>
<Text style={styles.topText}>Nintendo Switch Online</Text>
</View>
<ScrollView style={styles.scroller}>
<View style={styles.main}>
{discord_presence_source_state === RequestState.LOADED ?
<DiscordPresenceSource source={discord_presence_source}
presence={presence} user={discord_user} /> : null}
{props.users?.length ? <View style={styles.users}>
{props.users.map(u => <User
key={u.user.id}
user={u}
selected={props.selectedUser === u.user.id}
onPress={() => props.onSelectUser?.call(null, u.user.id)}
/>)}
</View> : null}
{props.users ? <View style={styles.addUser}>
<Text style={theme.text}>Add user</Text>
<View style={styles.addUserButton}>
<Button title="Add Nintendo Switch Online account"
onPress={() => ipc.addNsoAccount().then(id => props.onSelectUser?.call(null, id))}
color={'#' + accent_colour} />
</View>
<View style={styles.addUserButton}>
<Button title="Add Nintendo Switch Parental Controls account"
onPress={() => ipc.addMoonAccount().then(id => props.onSelectUser?.call(null, id))}
color={'#' + accent_colour} />
</View>
</View> : null}
</View>
</ScrollView>
</View>;
}
function User(props: {
user: User;
selected?: boolean;
onPress?: () => void;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const miiImageSource: ImageURISource = props.user.user.mii ? {
uri: 'https://' + props.user.user.mii.imageOrigin + '/2.0.0/mii_images/' +
props.user.user.mii.id + '/' +
props.user.user.mii.etag + '.png' +
'?type=face&width=140&bgColor=DFDFDFFF',
width: 32,
height: 32,
} : {
uri: 'https://cdn.accounts.nintendo.com/account/images/common/defaults/mii.png',
width: 32,
height: 32,
};
return <TouchableOpacity onPress={props.onPress}>
<View style={[styles.user, props.selected ? theme.userSelected : null]}>
<View style={styles.userMii}>
<Image source={miiImageSource} style={styles.userMiiImage} />
</View>
<View style={styles.userMain}>
<Text style={[styles.userName, theme.text]}>{props.user.user.nickname}</Text>
{props.user.nso ? <View style={styles.userNso}>
<View style={styles.userNsoImage}>
<Image source={{uri: props.user.nso.nsoAccount.user.imageUri, width: 16, height: 16}} />
</View>
<Text style={[styles.userNsoName, theme.text]}>{props.user.nso.nsoAccount.user.name}</Text>
</View> : null}
</View>
</View>
</TouchableOpacity>;
}
const styles = StyleSheet.create({
sidebar: {
flex: 1,
maxWidth: 250,
borderRightWidth: 1,
},
top: {
backgroundColor: NSO_COLOUR,
paddingVertical: 28,
paddingHorizontal: 20,
},
topText: {
fontSize: 16,
fontWeight: '600',
color: TEXT_COLOUR_DARK,
},
scroller: {
flex: 1,
},
main: {
paddingBottom: 16,
},
users: {
marginTop: 16,
},
addUser: {
paddingVertical: 16,
paddingHorizontal: 20,
},
addUserButton: {
marginTop: 5,
},
user: {
flexDirection: 'row',
paddingVertical: 8,
paddingHorizontal: 20,
alignItems: 'center',
},
userMii: {
marginRight: 14,
},
userMiiImage: {
borderRadius: 16,
},
userMain: {
flex: 1,
},
userName: {
},
userNso: {
flexDirection: 'row',
flex: 1,
alignItems: 'center',
marginTop: 5,
},
userNsoImage: {
marginRight: 7,
},
userNsoName: {
opacity: 0.7,
fontSize: 12,
},
});
const light = StyleSheet.create({
sidebar: {
borderRightColor: BORDER_COLOUR_LIGHT,
},
top: {
},
userSelected: {
backgroundColor: HIGHLIGHT_COLOUR_LIGHT,
},
text: {
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
sidebar: {
borderRightColor: BORDER_COLOUR_DARK,
},
top: {
backgroundColor: NSO_COLOUR_DARK,
},
userSelected: {
backgroundColor: HIGHLIGHT_COLOUR_DARK,
},
text: {
color: TEXT_COLOUR_DARK,
},
});

View File

@ -0,0 +1,48 @@
import React, { useEffect, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import ipc from '../ipc.js';
import { useAccentColour, useEventListener } from '../util.js';
import type { UpdateCacheData } from '../../../common/update.js';
import { TEXT_COLOUR_DARK, UPDATE_COLOUR } from '../constants.js';
export default function Update() {
const accent_colour = useAccentColour();
const [update, setUpdateData] = useState<UpdateCacheData | null>(null);
useEffect(() => (ipc.getUpdateData().then(setUpdateData), undefined), [ipc]);
useEventListener(ipc.events, 'nxapi:update:latest', setUpdateData, [ipc.events]);
return update && 'update_available' in update && update.update_available ? <View style={styles.container}>
<Text style={styles.updateText}>Update available: {update.latest.name}</Text>
<View style={styles.updateButton}>
<Button title="Download"
onPress={() => ipc.openExternalUrl(update.latest.html_url)}
color={'#' + accent_colour} />
</View>
</View> : update && 'error_message' in update ? <View style={styles.container}>
<Text style={styles.updateText}>Error checking for updates: {update.error_message}</Text>
<View style={styles.updateButton}>
<Button title="Try again"
onPress={() => (setUpdateData(null), ipc.checkUpdates())}
color={'#' + accent_colour} />
</View>
</View> : null;
}
const styles = StyleSheet.create({
container: {
backgroundColor: UPDATE_COLOUR,
paddingVertical: 8,
paddingHorizontal: 20,
flexDirection: 'row',
alignItems: 'center',
},
updateText: {
marginVertical: 4,
flex: 1,
color: TEXT_COLOUR_DARK,
},
updateButton: {
marginLeft: 14,
},
});

View File

@ -0,0 +1,79 @@
import React from 'react';
import { Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import ipc from '../ipc.js';
import { useColourScheme } from '../util.js';
import { User } from '../app.js';
import { WebService } from '../../../api/znc-types.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import Section from './section.js';
export default function WebServices(props: {
user: User;
webservices: WebService[];
loading?: boolean;
}) {
if (!props.webservices.length) return null;
return <Section title="Game-specific services" loading={props.loading}>
<ScrollView horizontal>
<View style={styles.content}>
{props.webservices.map(g => <WebService key={g.id} webservice={g} token={props.user.nsotoken} />)}
</View>
</ScrollView>
</Section>;
}
function WebService(props: {
webservice: WebService;
token?: string;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const content = <View style={styles.webservice}>
<Image source={{uri: props.webservice.imageUri, width: 120, height: 120}} style={styles.webserviceImage} />
<Text style={[styles.webserviceName, theme.text]}>{props.webservice.name}</Text>
</View>;
return <View style={styles.webserviceContainer}>
{props.token ? <TouchableOpacity onPress={() => ipc.openWebService(props.webservice, props.token!)}>
{content}
</TouchableOpacity> : content}
</View>;
}
const styles = StyleSheet.create({
content: {
paddingBottom: 16,
paddingLeft: 20,
paddingRight: 6,
flexDirection: 'row',
},
webserviceContainer: {
marginRight: 14,
},
webservice: {
maxWidth: 120,
alignItems: 'center',
},
webserviceImage: {
borderRadius: 2,
marginBottom: 12,
},
webserviceName: {
textAlign: 'center',
},
});
const light = StyleSheet.create({
text: {
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
text: {
color: TEXT_COLOUR_DARK,
},
});

View File

@ -1,60 +0,0 @@
import * as React from 'react';
import { ErrorResponse } from '../../api/util.js';
export enum RequestState {
NOT_LOADING,
LOADING,
LOADED,
}
export function useAsync<T>(fetch: (() => Promise<T>) | null) {
const [[data, requestState, error, i], setData] =
React.useState([null as T | null, RequestState.NOT_LOADING, null as Error | null, 0]);
const [f, forceUpdate] = React.useReducer(f => !f, false);
React.useEffect(() => {
if (!fetch) {
setData(p => p[1] === RequestState.NOT_LOADING ? p : [data, RequestState.NOT_LOADING, null, p[3] + 1]);
return;
}
setData(p => [p[0], RequestState.LOADING, p[2], i + 1]);
fetch.call(null).then(data => {
setData(p => p[3] === i + 1 ? [data, RequestState.LOADED, null, i + 1] : p);
}, err => {
setData(p => p[3] === i + 1 ? [data, RequestState.LOADED, err, i + 1] : p);
});
}, [fetch, f]);
return [data, error, requestState, forceUpdate] as const;
}
export function useFetch<T>(requestInfo: RequestInfo | null, init: RequestInit | undefined, then: (res: Response) => Promise<T>): [T | null, Error | null, RequestState, React.DispatchWithoutAction]
export function useFetch(requestInfo: RequestInfo | null, init?: RequestInit): [Response | null, Error | null, RequestState, React.DispatchWithoutAction]
export function useFetch<T>(requestInfo: RequestInfo | null, init?: RequestInit, then?: (res: Response) => Promise<T>) {
const f = React.useCallback(async () => {
const response = await fetch(requestInfo!, init);
return then?.call(null, response) ?? response;
}, [requestInfo]);
return useAsync<T | Response>(requestInfo ? f : null);
}
export function useFetchJson<T>(requestInfo: RequestInfo | null, init?: RequestInit) {
return useFetch(requestInfo, init, response => {
if (response.status !== 200) {
return response.text().then(body => {
throw new ErrorResponse(
'Server returned a non-200 status code: ' + response.status + ' ' + response.statusText,
response, body);
});
}
return response.json() as Promise<T>;
});
}
export function useFetchText(requestInfo: RequestInfo | null, init?: RequestInit) {
return useFetch(requestInfo, init, response => response.text());
}

161
src/app/browser/util.tsx Normal file
View File

@ -0,0 +1,161 @@
import { EventEmitter } from 'node:events';
import React, { useEffect } from 'react';
import { ColorSchemeName, Platform, StyleProp, StyleSheet, useColorScheme, View, ViewStyle } from 'react-native';
import type { User as DiscordUser } from 'discord-rpc';
import { ErrorResponse } from '../../api/util.js';
import { DiscordPresence } from '../../discord/util.js';
import ipc, { events } from './ipc.js';
import { User } from './app.js';
import { SavedToken } from '../../common/auth/nso.js';
export function Root(props: React.PropsWithChildren<{
title?: string;
titleUser?: User | SavedToken;
style?: StyleProp<ViewStyle>;
scrollable?: boolean;
}>) {
const colour_scheme = useColorScheme();
const [accent_colour, setAccentColour] = React.useState(() => ipc.getAccentColour());
useEventListener(events, 'systemPreferences:accent-colour', setAccentColour, []);
useEffect(() => {
const user_na = props.titleUser?.user;
const user_nso = (props.titleUser && 'nso' in props.titleUser ? props.titleUser.nso : props.titleUser)?.nsoAccount.user;
const user_prefix =
user_na ? '[' + user_na.nickname +
(user_nso && user_nso.name !== user_na.nickname ? '/' + user_nso.name : '') +
'] ' : '';
document.title = user_prefix + (props.title ?? 'Nintendo Switch Online');
}, [props.title, props.titleUser]);
return <ColourSchemeContext.Provider value={colour_scheme}>
<AccentColourContext.Provider value={accent_colour}>
<View style={[props.scrollable ? styles.appScrollable : styles.app, props.style]}>
{props.children}
</View>
</AccentColourContext.Provider>
</ColourSchemeContext.Provider>;
}
const styles = StyleSheet.create({
app: {
height: Platform.OS === 'web' ? '100vh' : '100%',
},
appScrollable: {
minHeight: Platform.OS === 'web' ? '100vh' : '100%',
},
});
export enum RequestState {
NOT_LOADING,
LOADING,
LOADED,
}
export function useAsync<T>(fetch: (() => Promise<T>) | null) {
const [[data, requestState, error, i], setData] =
React.useState([null as T | null, RequestState.NOT_LOADING, null as Error | null, 0]);
const [f, forceUpdate] = React.useReducer(f => !f, false);
React.useEffect(() => {
if (!fetch) {
setData(p => p[1] === RequestState.NOT_LOADING ? p : [data, RequestState.NOT_LOADING, null, p[3] + 1]);
return;
}
setData(p => [p[0], RequestState.LOADING, p[2], i + 1]);
fetch.call(null).then(data => {
setData(p => p[3] === i + 1 ? [data, RequestState.LOADED, null, i + 1] : p);
}, err => {
setData(p => p[3] === i + 1 ? [data, RequestState.LOADED, err, i + 1] : p);
});
}, [fetch, f]);
return [data, error, requestState, forceUpdate] as const;
}
export function useFetch<T>(requestInfo: RequestInfo | null, init: RequestInit | undefined, then: (res: Response) => Promise<T>): [T | null, Error | null, RequestState, React.DispatchWithoutAction]
export function useFetch(requestInfo: RequestInfo | null, init?: RequestInit): [Response | null, Error | null, RequestState, React.DispatchWithoutAction]
export function useFetch<T>(requestInfo: RequestInfo | null, init?: RequestInit, then?: (res: Response) => Promise<T>) {
const f = React.useCallback(async () => {
const response = await fetch(requestInfo!, init);
return then?.call(null, response) ?? response;
}, [requestInfo]);
return useAsync<T | Response>(requestInfo ? f : null);
}
export function useFetchJson<T>(requestInfo: RequestInfo | null, init?: RequestInit) {
return useFetch(requestInfo, init, response => {
if (response.status !== 200) {
return response.text().then(body => {
throw new ErrorResponse(
'Server returned a non-200 status code: ' + response.status + ' ' + response.statusText,
response, body);
});
}
return response.json() as Promise<T>;
});
}
export function useFetchText(requestInfo: RequestInfo | null, init?: RequestInit) {
return useFetch(requestInfo, init, response => response.text());
}
export function useEventListener<
T extends EventEmitter,
E extends Parameters<T['on']>[0],
L extends Parameters<T['on']>[1],
>(events: T, event: E, listener: L, deps: any[]) {
React.useEffect(() => {
events.on(event, listener);
return () => {
events.removeListener(event, listener);
};
}, deps);
}
export const AccentColourContext = React.createContext<string | null>('E60012FF');
export function useAccentColour() {
return React.useContext(AccentColourContext);
}
export const ColourSchemeContext = React.createContext<ColorSchemeName>(null);
export function useColourScheme() {
return React.useContext(ColourSchemeContext);
}
export function useDiscordPresenceSource() {
const [source, , state, forceRefresh] = useAsync(React.useCallback(() => ipc.getDiscordPresenceSource(), [ipc]));
useEventListener(events, 'update-discord-presence-source', forceRefresh, []);
return [source, state] as const;
}
export function useActiveDiscordPresence() {
const [presence, setPresence] = React.useState<DiscordPresence | null>(null);
useEventListener(events, 'update-discord-presence', setPresence, []);
React.useEffect(() => {
ipc.getDiscordPresence().then(setPresence);
}, [ipc]);
return presence;
}
export function useActiveDiscordUser() {
const [user, setUser] = React.useState<DiscordUser | null>(null);
useEventListener(events, 'update-discord-user', setUser, []);
React.useEffect(() => {
ipc.getDiscordUser().then(setUser);
}, [ipc]);
return user;
}

View File

@ -1,14 +1,23 @@
import type { AppProps } from '../browser/app.js';
export enum WindowType {
MAIN_WINDOW = 'App',
FRIEND = 'Friend',
}
interface WindowProps {
[WindowType.MAIN_WINDOW]: AppProps;
[WindowType.MAIN_WINDOW]: import('../browser/app.js').AppProps;
[WindowType.FRIEND]: import('../browser/friend/index.js').FriendProps;
}
export interface WindowConfiguration<T extends WindowType = WindowType> {
type: T;
props: WindowProps[T];
}
export type DiscordPresenceSource = DiscordPresenceSourceZnc | DiscordPresenceSourceUrl;
export interface DiscordPresenceSourceZnc {
na_id: string;
friend_nsa_id?: string;
}
export interface DiscordPresenceSourceUrl {
url: string;
}

View File

@ -14,13 +14,17 @@ 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 Menu = import('electron').Menu;
export type MenuItem = import('electron').MenuItem;
export type Notification = import('electron').Notification;
export type ShareMenu = import('electron').ShareMenu;
export type SharingItem = import('electron').SharingItem;
export type Tray = import('electron').Tray;
export type WebContents = import('electron').WebContents;

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Notification } from './electron.js';
import { app, BrowserWindow, dialog, ipcMain } from './electron.js';
import process from 'node:process';
import * as path from 'node:path';
import { EventEmitter } from 'node:events';
@ -7,27 +7,50 @@ import * as persist from 'node-persist';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import MenuApp from './menu.js';
import { WebServiceIpc } from './webservices.js';
import { createWindow, getWindowConfiguration } from './windows.js';
import { WindowType } from '../common/types.js';
import { CurrentUser, Friend, Game, ZncErrorResponse } from '../../api/znc-types.js';
import { ErrorResponse } from '../../api/util.js';
import { ZncDiscordPresence } from '../../common/presence.js';
import { NotificationManager } from '../../common/notify.js';
import { getToken } from '../../common/auth/nso.js';
import { dir } from '../../util/product.js';
import { handleOpenWebServiceUri } from './webservices.js';
import { EmbeddedPresenceMonitor, EmbeddedProxyPresenceMonitor, PresenceMonitorManager } from './monitor.js';
import { createWindow } from './windows.js';
import { DiscordPresenceSource, WindowType } from '../common/types.js';
import { initStorage, paths } from '../../util/storage.js';
import { LoopResult } from '../../util/loop.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { checkUpdates, UpdateCacheData } from '../../common/update.js';
import Users, { CoralUser } from '../../common/users.js';
import { setupIpc } from './ipc.js';
const debug = createDebug('app:main');
export const bundlepath = path.resolve(dir, 'dist', 'app', 'bundle');
export class App {
readonly monitors: PresenceMonitorManager;
readonly updater = new Updater();
menu: MenuApp | null = null;
function createMainWindow() {
const window = createWindow(WindowType.MAIN_WINDOW, {});
constructor(
readonly store: Store
) {
this.monitors = new PresenceMonitorManager(this);
}
return window;
main_window: BrowserWindow | null = null;
showMainWindow() {
if (this.main_window) {
this.main_window.show();
this.main_window.focus();
return this.main_window;
}
const window = createWindow(WindowType.MAIN_WINDOW, {}, {
minWidth: 500,
minHeight: 300,
vibrancy: 'under-window',
webPreferences: {
scrollBounce: false,
},
});
window.on('closed', () => this.main_window = null);
return this.main_window = window;
}
}
app.whenReady().then(async () => {
@ -42,85 +65,104 @@ app.whenReady().then(async () => {
const storage = await initStorage(process.env.NXAPI_DATA_PATH ?? paths.data);
const store = new Store(storage);
const appinstance = new App(store);
ipcMain.on('nxapi:browser:getwindowdata', e => e.returnValue = getWindowConfiguration(e.sender));
setupIpc(appinstance, ipcMain);
ipcMain.handle('nxapi:accounts:list', () => storage.getItem('NintendoAccountIds'));
ipcMain.handle('nxapi:nso:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id));
ipcMain.handle('nxapi:nso:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token));
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));
await store.restoreMonitorState(appinstance.monitors);
const webserviceipc = new WebServiceIpc(store);
ipcMain.on('nxapi:webserviceapi:getWebServiceSync', e => e.returnValue = webserviceipc.getWebService(e));
ipcMain.handle('nxapi:webserviceapi:invokeNativeShare', (e, data: string) => webserviceipc.invokeNativeShare(e, data));
ipcMain.handle('nxapi:webserviceapi:invokeNativeShareUrl', (e, data: string) => webserviceipc.invokeNativeShareUrl(e, data));
ipcMain.handle('nxapi:webserviceapi:requestGameWebToken', e => webserviceipc.requestGameWebToken(e));
ipcMain.handle('nxapi:webserviceapi:restorePersistentData', e => webserviceipc.restorePersistentData(e));
ipcMain.handle('nxapi:webserviceapi:storePersistentData', (e, data: string) => webserviceipc.storePersistentData(e, data));
const menu = new MenuApp(appinstance);
appinstance.menu = menu;
const sendToAllWindows = (channel: string, ...args: any[]) =>
BrowserWindow.getAllWindows().forEach(w => w.webContents.send(channel, ...args));
store.on('update-nintendo-accounts', () => sendToAllWindows('nxapi:accounts:shouldrefresh'));
const monitors = new PresenceMonitorManager(store);
await store.restoreMonitorState(monitors);
const menu = new MenuApp(store, monitors);
app.on('open-url', (event, url) => {
if (url.match(/^com\.nintendo\.znca:\/\/(znca\/)game\/(\d+)\/?($|\?|\#)/i)) {
handleOpenWebServiceUri(store, url);
event.preventDefault();
}
});
app.setAsDefaultProtocolClient('com.nintendo.znca');
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
if (BrowserWindow.getAllWindows().length === 0) appinstance.showMainWindow();
});
debug('App started');
// createWindow();
appinstance.showMainWindow();
// @ts-expect-error
globalThis.app = appinstance;
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
class Updater {
private _cache: UpdateCacheData | null = null;
private _check: Promise<UpdateCacheData | null> | null = null;
get cache() {
return this._cache;
}
check() {
return this._check ?? (this._check = checkUpdates().then(data => {
this._cache = data;
return data;
}).finally(() => {
this._check = null;
}));
}
}
interface SavedMonitorState {
users: {
/** Nintendo Account ID */
id: string;
/** NSA ID */
presence_user: string | null;
user_notifications: boolean;
friend_notifications: boolean;
}[];
/** Nintendo Account ID */
discord_presence_user: string | null;
discord_presence: DiscordPresenceSource | null;
}
export class Store extends EventEmitter {
users: Users<CoralUser>;
constructor(
public storage: persist.LocalStorage
) {
super();
this.users = Users.coral(storage, process.env.ZNC_PROXY_URL);
}
async saveMonitorState(monitors: PresenceMonitorManager) {
const users = new Set();
const state: SavedMonitorState = {
users: [],
discord_presence_user: null,
discord_presence: null,
};
for (const monitor of monitors.monitors) {
if (users.has(monitor.data.user.id)) continue;
users.add(monitor.data.user.id);
if (monitor instanceof EmbeddedPresenceMonitor && !users.has(monitor.data.user.id)) {
users.add(monitor.data?.user.id);
state.users.push({
id: monitor.data.user.id,
presence_user: monitor.presence_user,
user_notifications: monitor.user_notifications,
friend_notifications: monitor.friend_notifications,
});
state.users.push({
id: monitor.data?.user.id,
user_notifications: monitor.user_notifications,
friend_notifications: monitor.friend_notifications,
});
}
if (monitor.presence_user && !state.discord_presence_user) {
state.discord_presence_user = monitor.data.user.id;
if (monitor.presence_user && !state.discord_presence) {
state.discord_presence = monitor instanceof EmbeddedProxyPresenceMonitor ? {
url: monitor.presence_url,
} : {
na_id: monitor.data.user.id,
friend_nsa_id: monitor.presence_user === monitor.data.nsoAccount.user.nsaId ? undefined :
monitor.presence_user,
};
}
}
@ -134,175 +176,30 @@ export class Store extends EventEmitter {
if (!state) return;
for (const user of state.users) {
if (state.discord_presence_user !== user.id &&
!user.user_notifications && !user.friend_notifications
const discord_presence_active = state.discord_presence && 'na_id' in state.discord_presence &&
state.discord_presence.na_id === user.id;
if (!discord_presence_active &&
!user.user_notifications &&
!user.friend_notifications
) continue;
await monitors.start(user.id, monitor => {
monitor.presence_user = state.discord_presence_user === user.id ?
user.presence_user || monitor.data.nsoAccount.user.nsaId : null;
monitor.user_notifications = user.user_notifications;
monitor.friend_notifications = user.friend_notifications;
});
}
}
}
try {
await monitors.start(user.id, monitor => {
monitor.presence_user = state.discord_presence && 'na_id' in state.discord_presence &&
state.discord_presence.na_id === user.id ?
state.discord_presence.friend_nsa_id ?? monitor.data.nsoAccount.user.nsaId : null;
monitor.user_notifications = user.user_notifications;
monitor.friend_notifications = user.friend_notifications;
export class PresenceMonitorManager {
monitors: EmbeddedPresenceMonitor[] = [];
notifications = new ElectronNotificationManager();
constructor(
public store: Store
) {}
async start(id: string, callback?: (monitor: EmbeddedPresenceMonitor, firstRun: boolean) => void) {
debug('Starting monitor', id);
const token = id.length === 16 ? await this.store.storage.getItem('NintendoAccountToken.' + id) : id;
if (!token) throw new Error('No token for this user');
const {nso, data} = await getToken(this.store.storage, token, process.env.ZNC_PROXY_URL);
const existing = this.monitors.find(m => m.data.user.id === data.user.id);
if (existing) {
callback?.call(null, existing, false);
return;
}
const i = new EmbeddedPresenceMonitor(this.store.storage, token, nso, data);
i.notifications = this.notifications;
i.presence_user = null;
i.user_notifications = false;
i.friend_notifications = false;
this.monitors.push(i);
callback?.call(null, i, true);
i.enable();
}
async stop(id: string) {
let index;
while ((index = this.monitors.findIndex(m => m.data.user.id === id)) >= 0) {
const i = this.monitors[index];
this.monitors.splice(index, 1);
i.disable();
this.notifications.removeAccount(id);
}
}
}
export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
notifications = new ElectronNotificationManager();
enable() {
if (this._running !== 0) return;
this._run();
}
disable() {
this._running = 0;
}
get enabled() {
return this._running !== 0;
}
private _running = 0;
private async _run() {
this._running++;
const i = this._running;
try {
await this.loop(true);
while (i === this._running) {
await this.loop();
if (monitor.presence_user) {
this.emit('update-discord-presence-source', monitors.getDiscordPresenceSource());
}
});
} catch (err) {
dialog.showErrorBox('Error restoring monitor for user ' + user.id,
err instanceof Error ? err.stack ?? err.message : err as any);
}
if (this._running === 0) {
// Run one more time after the loop ends
const result = await this.loopRun();
}
debug('Monitor for user %s finished', this.data.nsoAccount.user.name);
} finally {
this._running = 0;
}
}
async handleError(err: ErrorResponse<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
try {
return await super.handleError(err);
} catch (err) {
if (err instanceof ErrorResponse) {
dialog.showErrorBox('Request error',
err.response.status + ' ' + err.response.statusText + ' ' +
err.response.url + '\n' +
err.body + '\n\n' +
(err.stack ?? err.message));
} else if (err instanceof Error) {
dialog.showErrorBox(err.name, err.stack ?? err.message);
} else {
dialog.showErrorBox('Error', err as any);
}
return LoopResult.OK;
}
}
skipIntervalInCurrentLoop(start = false) {
super.skipIntervalInCurrentLoop();
if (!this._running && start) this.enable();
}
}
export class ElectronNotificationManager extends NotificationManager {
async onFriendOnline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) {
const currenttitle = friend.presence.game as Game;
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}
async onFriendOffline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) {
new Notification({
title: friend.name,
body: 'Offline',
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}
async onFriendPlayingChangeTitle(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) {
const currenttitle = friend.presence.game as Game;
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}
async onFriendTitleStateChange(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) {
const currenttitle = friend.presence.game as Game;
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}
}

110
src/app/main/ipc.ts Normal file
View File

@ -0,0 +1,110 @@
import { BrowserWindow, dialog, IpcMain, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
import * as util from 'node:util';
import createDebug from 'debug';
import openWebService, { WebServiceIpc } from './webservices.js';
import { createWindow, getWindowConfiguration } from './windows.js';
import { DiscordPresenceSource, WindowType } from '../common/types.js';
import { WebService } from '../../api/znc-types.js';
import { addNsoAccount, addPctlAccount } from './na-auth.js';
import { App } from './index.js';
import { DiscordPresence } from '../../discord/util.js';
import { User } from 'discord-rpc';
const debug = createDebug('app:main:ipc');
export function setupIpc(appinstance: App, ipcMain: IpcMain) {
const store = appinstance.store;
const storage = appinstance.store.storage;
ipcMain.on('nxapi:browser:getwindowdata', e => e.returnValue = getWindowConfiguration(e.sender));
let accent_colour = systemPreferences.getAccentColor();
ipcMain.on('nxapi:systemPreferences:accent-colour', e => e.returnValue = accent_colour);
systemPreferences.subscribeLocalNotification('NSSystemColorsDidChangeNotification', (event, userInfo, object) => {
accent_colour = systemPreferences.getAccentColor();
sendToAllWindows('nxapi:systemPreferences:accent-colour', accent_colour);
});
systemPreferences.on('accent-color-changed', (event, new_colour) => {
accent_colour = new_colour ?? systemPreferences.getAccentColor();
sendToAllWindows('nxapi:systemPreferences:accent-colour', accent_colour);
});
ipcMain.handle('nxapi:update:get', () => appinstance.updater.cache ?? appinstance.updater.check());
ipcMain.handle('nxapi: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', () => addNsoAccount(store.storage).then(u => u.data.user.id));
ipcMain.handle('nxapi:accounts:add-moon', () => addPctlAccount(store.storage).then(u => u.data.user.id));
ipcMain.handle('nxapi:nso:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id));
ipcMain.handle('nxapi:nso:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token));
ipcMain.handle('nxapi:nso:announcements', (e, token: string) => store.users.get(token).then(u => u.announcements.result));
ipcMain.handle('nxapi:nso:friends', (e, token: string) => store.users.get(token).then(u => u.getFriends()));
ipcMain.handle('nxapi:nso:webservices', (e, token: string) => store.users.get(token).then(u => u.getWebServices()));
ipcMain.handle('nxapi:nso: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 => dialog.showMessageBox(BrowserWindow.fromWebContents(e.sender)!, {
type: 'error',
title: 'Error opening web service',
message: err.message,
detail: (err instanceof Error ? err.stack ?? '' : 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}),
}))));
ipcMain.handle('nxapi:nso:activeevent', (e, token: string) => store.users.get(token).then(u => u.getActiveEvent()));
ipcMain.handle('nxapi:window:showfriend', (e, na_id: string, nsa_id: string) => createWindow(WindowType.FRIEND, {
user: na_id,
friend: nsa_id,
}, {
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
modal: true,
width: 560,
height: 300,
}).id);
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:moon:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken-pctl.' + id));
ipcMain.handle('nxapi: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) =>
new ShareMenu(item).popup({window: BrowserWindow.fromWebContents(e.sender)!}));
const webserviceipc = new WebServiceIpc(store);
ipcMain.on('nxapi:webserviceapi:getWebServiceSync', e => e.returnValue = webserviceipc.getWebService(e));
ipcMain.handle('nxapi:webserviceapi:invokeNativeShare', (e, data: string) => webserviceipc.invokeNativeShare(e, data));
ipcMain.handle('nxapi:webserviceapi:invokeNativeShareUrl', (e, data: string) => webserviceipc.invokeNativeShareUrl(e, data));
ipcMain.handle('nxapi:webserviceapi:requestGameWebToken', e => webserviceipc.requestGameWebToken(e));
ipcMain.handle('nxapi:webserviceapi:restorePersistentData', e => webserviceipc.restorePersistentData(e));
ipcMain.handle('nxapi:webserviceapi:storePersistentData', (e, data: string) => webserviceipc.storePersistentData(e, data));
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));
}
function sendToAllWindows(channel: string, ...args: any[]) {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(channel, ...args);
}
}

View File

@ -1,20 +1,20 @@
import { app, dialog, Menu, Tray, nativeImage, MenuItem } from './electron.js';
import process from 'node:process';
import createDebug from 'debug';
import { addNsoAccount, addPctlAccount } from './na-auth.js';
import { PresenceMonitorManager, Store } from './index.js';
import { App } from './index.js';
import { WebService } from '../../api/znc-types.js';
import openWebService from './webservices.js';
import { getToken, SavedToken } from '../../common/auth/nso.js';
import { SavedToken } from '../../common/auth/nso.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import { dev } from '../../util/product.js';
import { EmbeddedPresenceMonitor, EmbeddedProxyPresenceMonitor } from './monitor.js';
const debug = createDebug('app:main:menu');
export default class MenuApp {
tray: Tray;
constructor(readonly store: Store, readonly monitors: PresenceMonitorManager) {
constructor(readonly app: App) {
const icon = nativeImage.createEmpty();
this.tray = new Tray(icon);
@ -22,26 +22,28 @@ export default class MenuApp {
this.tray.setTitle('nxapi');
this.tray.setToolTip('nxapi');
store.on('update-nintendo-accounts', () => this.updateMenu());
app.store.on('update-nintendo-accounts', () => this.updateMenu());
this.updateMenu();
}
async updateMenu() {
const menu = new Menu();
const ids = await this.store.storage.getItem('NintendoAccountIds') as string[] | undefined;
const ids = await this.app.store.storage.getItem('NintendoAccountIds') as string[] | undefined;
menu.append(new MenuItem({label: 'Nintendo Switch Online', enabled: false}));
const discordPresenceMonitor = this.getActiveDiscordPresenceMonitor();
const discord_presence_monitor = this.getActiveDiscordPresenceMonitor();
for (const id of ids ?? []) {
const token = await this.store.storage.getItem('NintendoAccountToken.' + id) as string | undefined;
const token = await this.app.store.storage.getItem('NintendoAccountToken.' + id) as string | undefined;
if (!token) continue;
const data = await this.store.storage.getItem('NsoToken.' + token) as SavedToken | undefined;
const data = await this.app.store.storage.getItem('NsoToken.' + token) as SavedToken | undefined;
if (!data) continue;
const monitor = this.monitors.monitors.find(m => m.data.user.id === data.user.id);
const discordPresenceActive = discordPresenceMonitor?.data.user.id === data.user.id;
const monitor = this.app.monitors.monitors.find(m => m instanceof EmbeddedPresenceMonitor &&
m.data.user.id === data.user.id);
const discord_presence_active = discord_presence_monitor instanceof EmbeddedPresenceMonitor &&
discord_presence_monitor?.data?.user.id === data.user.id;
const item = new MenuItem({
label: data.nsoAccount.user.name,
@ -49,9 +51,9 @@ export default class MenuApp {
{label: 'Nintendo Account ID: ' + data.user.id, enabled: false},
{label: 'NSA ID: ' + data.nsoAccount.user.nsaId, enabled: false},
{type: 'separator'},
{label: 'Enable Discord Presence', type: 'checkbox', checked: discordPresenceActive,
enabled: discordPresenceActive,
click: () => this.setActiveDiscordPresenceUser(discordPresenceActive ? null : data.user.id)},
{label: 'Enable Discord Presence', type: 'checkbox', checked: discord_presence_active,
enabled: discord_presence_active,
click: () => this.setActiveDiscordPresenceUser(discord_presence_active ? null : data.user.id)},
{label: 'Enable notifications for this user\'s presence', type: 'checkbox',
checked: monitor?.user_notifications,
enabled: !!monitor?.user_notifications,
@ -74,9 +76,9 @@ export default class MenuApp {
menu.append(new MenuItem({label: 'Nintendo Switch Parental Controls', enabled: false}));
for (const id of ids ?? []) {
const token = await this.store.storage.getItem('NintendoAccountToken-pctl.' + id) as string | undefined;
const token = await this.app.store.storage.getItem('NintendoAccountToken-pctl.' + id) as string | undefined;
if (!token) continue;
const data = await this.store.storage.getItem('MoonToken.' + token) as SavedMoonToken | undefined;
const data = await this.app.store.storage.getItem('MoonToken.' + token) as SavedMoonToken | undefined;
if (!data) continue;
const item = new MenuItem({
@ -92,9 +94,10 @@ export default class MenuApp {
menu.append(new MenuItem({label: 'Add account', click: this.addPctlAccount}));
menu.append(new MenuItem({type: 'separator'}));
menu.append(new MenuItem({label: 'Show main window', click: () => this.app.showMainWindow()}));
if (dev) menu.append(new MenuItem({label: 'Dump notifications state', click: () => {
debug('Accounts', this.monitors.notifications.accounts);
debug('Friends', this.monitors.notifications.onlinefriends);
debug('Accounts', this.app.monitors.notifications.accounts);
debug('Friends', this.app.monitors.notifications.onlinefriends);
}}));
menu.append(new MenuItem({label: 'Quit', click: () => app.quit()}));
@ -102,7 +105,7 @@ export default class MenuApp {
}
addNsoAccount = () => {
addNsoAccount(this.store.storage).catch(err => {
addNsoAccount(this.app.store.storage).catch(err => {
if (err.message === 'Canceled') return;
dialog.showErrorBox('Error adding account', err.stack || err.message);
@ -110,7 +113,7 @@ export default class MenuApp {
};
addPctlAccount = () => {
addPctlAccount(this.store.storage).catch(err => {
addPctlAccount(this.app.store.storage).catch(err => {
if (err.message === 'Canceled') return;
dialog.showErrorBox('Error adding account', err.stack || err.message);
@ -160,7 +163,7 @@ export default class MenuApp {
async getWebServices(token: string) {
if (this.webservices) return this.webservices;
const {nso, data} = await getToken(this.store.storage, token, process.env.ZNC_PROXY_URL);
const {nso, data} = await this.app.store.users.get(token);
const webservices = await nso.getWebServices();
return this.webservices = webservices.result;
@ -175,9 +178,9 @@ export default class MenuApp {
label: webservice.name,
click: async () => {
try {
const {nso, data} = await getToken(this.store.storage, token, process.env.ZNC_PROXY_URL);
const {nso, data} = await this.app.store.users.get(token);
await openWebService(this.store, token, nso, data, webservice);
await openWebService(this.app.store, token, nso, data, webservice);
} catch (err) {
dialog.showErrorBox('Error loading web service', (err as any).stack ?? (err as any).message);
}
@ -189,8 +192,8 @@ export default class MenuApp {
}
getActiveDiscordPresenceMonitor() {
for (const monitor of this.monitors.monitors) {
if (!monitor.presence_user) continue;
for (const monitor of this.app.monitors.monitors) {
if (!monitor.presence_enabled) continue;
return monitor;
}
@ -202,18 +205,24 @@ export default class MenuApp {
const monitor = this.getActiveDiscordPresenceMonitor();
if (monitor) {
if (monitor.data.user.id === id) return;
if (monitor instanceof EmbeddedPresenceMonitor && monitor.data.user.id === id) return;
monitor.presence_user = null;
// monitor.skipIntervalInCurrentLoop();
monitor.updatePresenceForDiscord(null);
monitor.discord.updatePresenceForDiscord(null);
if (!monitor.user_notifications && !monitor.friend_notifications) {
this.monitors.stop(monitor.data.user.id);
if (monitor instanceof EmbeddedPresenceMonitor) {
monitor.presence_user = null;
if (!monitor.user_notifications && !monitor.friend_notifications) {
this.app.monitors.stop(monitor.data.user.id);
}
}
if (monitor instanceof EmbeddedProxyPresenceMonitor) {
this.app.monitors.stop(monitor.presence_url);
}
}
if (id) await this.monitors.start(id, monitor => {
if (id) await this.app.monitors.start(id, monitor => {
monitor.presence_user = monitor.data.nsoAccount.user.nsaId;
monitor.skipIntervalInCurrentLoop();
});
@ -222,20 +231,20 @@ export default class MenuApp {
}
async setUserNotificationsActive(id: string, active: boolean) {
const monitor = this.monitors.monitors.find(m => m.data.user.id === id);
const monitor = this.app.monitors.monitors.find(m => m instanceof EmbeddedPresenceMonitor && m.data.user.id === id);
if (monitor?.user_notifications && !active) {
monitor.user_notifications = false;
if (!monitor.presence_user && !monitor.friend_notifications) {
this.monitors.stop(monitor.data.user.id);
this.app.monitors.stop(monitor.data.user.id);
}
monitor.skipIntervalInCurrentLoop();
this.saveMonitorStateAndUpdateMenu();
}
if (!monitor?.user_notifications && active) await this.monitors.start(id, monitor => {
if (!monitor?.user_notifications && active) await this.app.monitors.start(id, monitor => {
monitor.user_notifications = true;
monitor.skipIntervalInCurrentLoop();
this.saveMonitorStateAndUpdateMenu();
@ -243,20 +252,20 @@ export default class MenuApp {
}
async setFriendNotificationsActive(id: string, active: boolean) {
const monitor = this.monitors.monitors.find(m => m.data.user.id === id);
const monitor = this.app.monitors.monitors.find(m => m instanceof EmbeddedPresenceMonitor && m.data.user.id === id);
if (monitor?.friend_notifications && !active) {
monitor.friend_notifications = false;
if (!monitor.presence_user && !monitor.user_notifications) {
this.monitors.stop(monitor.data.user.id);
this.app.monitors.stop(monitor.data.user.id);
}
monitor.skipIntervalInCurrentLoop();
this.saveMonitorStateAndUpdateMenu();
}
if (!monitor?.friend_notifications && active) await this.monitors.start(id, monitor => {
if (!monitor?.friend_notifications && active) await this.app.monitors.start(id, monitor => {
monitor.friend_notifications = true;
monitor.skipIntervalInCurrentLoop();
this.saveMonitorStateAndUpdateMenu();
@ -265,7 +274,7 @@ export default class MenuApp {
async saveMonitorState() {
try {
await this.store.saveMonitorState(this.monitors);
await this.app.store.saveMonitorState(this.app.monitors);
} catch (err) {
debug('Error saving monitor state', err);
}

344
src/app/main/monitor.ts Normal file
View File

@ -0,0 +1,344 @@
import { dialog, Notification } from './electron.js';
import createDebug from 'debug';
import { CurrentUser, Friend, Game, ZncErrorResponse } from '../../api/znc-types.js';
import { ErrorResponse } from '../../api/util.js';
import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../../common/presence.js';
import { NotificationManager } from '../../common/notify.js';
import { LoopResult } from '../../util/loop.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { App } from './index.js';
import { DiscordPresenceSource } from '../common/types.js';
import { DiscordPresence } from '../../discord/util.js';
import { DiscordRpcClient } from '../../discord/rpc.js';
const debug = createDebug('app:main:monitor');
export class PresenceMonitorManager {
monitors: (EmbeddedPresenceMonitor | EmbeddedProxyPresenceMonitor)[] = [];
notifications = new ElectronNotificationManager();
constructor(
public app: App
) {}
async start(id: string, callback?: (monitor: EmbeddedPresenceMonitor, firstRun: boolean) => void) {
debug('Starting monitor', id);
const token = id.length === 16 ? await this.app.store.storage.getItem('NintendoAccountToken.' + id) : id;
if (!token) throw new Error('No token for this user');
const user = await this.app.store.users.get(token);
const existing = this.monitors.find(m => m instanceof EmbeddedPresenceMonitor && m.data.user.id === user.data.user.id);
if (existing) {
callback?.call(null, existing as EmbeddedPresenceMonitor, false);
return;
}
const i = new EmbeddedPresenceMonitor(this.app.store.storage, token, user.nso, user.data, user);
i.notifications = this.notifications;
i.presence_user = null;
i.user_notifications = false;
i.friend_notifications = false;
i.discord.onUpdateActivity = (presence: DiscordPresence | null) => {
this.app.store.emit('update-discord-presence', presence);
};
i.discord.onUpdateClient = (client: DiscordRpcClient | null) => {
this.app.store.emit('update-discord-user', client?.user ?? null);
};
this.monitors.push(i);
callback?.call(null, i, true);
i.enable();
}
async startUrl(presence_url: string) {
debug('Starting monitor', presence_url);
const existing = this.monitors.find(m => m instanceof EmbeddedProxyPresenceMonitor && m.presence_url === presence_url);
if (existing) return;
const i = new EmbeddedProxyPresenceMonitor(presence_url);
i.notifications = this.notifications;
i.discord.onUpdateActivity = (presence: DiscordPresence | null) => {
this.app.store.emit('update-discord-presence', presence);
};
i.discord.onUpdateClient = (client: DiscordRpcClient | null) => {
this.app.store.emit('update-discord-user', client?.user ?? null);
};
this.monitors.push(i);
i.enable();
}
async stop(id: string) {
let index;
while ((index = this.monitors.findIndex(m =>
(m instanceof EmbeddedPresenceMonitor && m.data.user.id === id) ||
(m instanceof EmbeddedProxyPresenceMonitor && m.presence_url === id)
)) >= 0) {
const i = this.monitors[index];
this.monitors.splice(index, 1);
i.disable();
if (i instanceof EmbeddedPresenceMonitor) this.notifications.removeAccount(id);
}
}
getActiveDiscordPresenceMonitor() {
return this.monitors.find(m => m.presence_enabled || m instanceof EmbeddedProxyPresenceMonitor);
}
getDiscordPresence() {
return this.getActiveDiscordPresenceMonitor()?.discord.last_activity ?? null;
}
getDiscordPresenceSource(): DiscordPresenceSource | null {
const monitor = this.getActiveDiscordPresenceMonitor();
if (!monitor) return null;
return monitor instanceof EmbeddedProxyPresenceMonitor ? {
url: monitor.presence_url,
} : {
na_id: monitor.data.user.id,
friend_nsa_id: monitor.presence_user === monitor.data.nsoAccount.user.nsaId ? undefined :
monitor.presence_user ?? undefined,
};
}
async setDiscordPresenceSource(source: DiscordPresenceSource | null) {
const monitor = this.getActiveDiscordPresenceMonitor();
if (source && 'na_id' in source &&
monitor && monitor instanceof EmbeddedPresenceMonitor &&
monitor.data.user.id === source.na_id &&
monitor.presence_user !== (source.friend_nsa_id ?? monitor.data.nsoAccount.user.nsaId)
) {
await this.start(source.na_id, monitor => {
monitor.presence_user = source.friend_nsa_id ?? monitor.data.nsoAccount.user.nsaId;
monitor.skipIntervalInCurrentLoop();
});
this.app.store.saveMonitorState(this);
this.app.menu?.updateMenu();
return;
}
if (monitor) {
if (source && 'na_id' in source && monitor instanceof EmbeddedPresenceMonitor && monitor.data.user.id === source.na_id) return;
if (source && 'url' in source && monitor instanceof EmbeddedProxyPresenceMonitor && monitor.presence_url === source.url) return;
monitor.discord.updatePresenceForDiscord(null);
if (monitor instanceof EmbeddedPresenceMonitor) {
monitor.presence_user = null;
if (!monitor.user_notifications && !monitor.friend_notifications) {
this.stop(monitor.data.user.id);
}
}
if (monitor instanceof EmbeddedProxyPresenceMonitor) {
this.stop(monitor.presence_url);
}
}
if (source && 'na_id' in source) {
await this.start(source.na_id, monitor => {
monitor.presence_user = source.friend_nsa_id ?? monitor.data.nsoAccount.user.nsaId;
monitor.skipIntervalInCurrentLoop();
});
} else if (source && 'url' in source) {
await this.startUrl(source.url);
}
if (monitor || source) {
this.app.store.saveMonitorState(this);
this.app.menu?.updateMenu();
this.app.store.emit('update-discord-presence-source', source);
}
}
}
export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
notifications = new ElectronNotificationManager();
enable() {
if (this._running !== 0) return;
this._run();
}
disable() {
this._running = 0;
}
get enabled() {
return this._running !== 0;
}
private _running = 0;
private async _run() {
this._running++;
const i = this._running;
try {
await this.loop(true);
while (i === this._running) {
await this.loop();
}
if (this._running === 0) {
// Run one more time after the loop ends
const result = await this.loopRun();
}
debug('Monitor for user %s finished', this.data.nsoAccount.user.name);
} finally {
this._running = 0;
}
}
async handleError(err: ErrorResponse<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
try {
return await super.handleError(err);
} catch (err) {
if (err instanceof ErrorResponse) {
dialog.showErrorBox('Request error',
err.response.status + ' ' + err.response.statusText + ' ' +
err.response.url + '\n' +
err.body + '\n\n' +
(err.stack ?? err.message));
} else if (err instanceof Error) {
dialog.showErrorBox(err.name, err.stack ?? err.message);
} else {
dialog.showErrorBox('Error', err as any);
}
return LoopResult.OK;
}
}
skipIntervalInCurrentLoop(start = false) {
super.skipIntervalInCurrentLoop();
if (!this._running && start) this.enable();
}
}
export class EmbeddedProxyPresenceMonitor extends ZncProxyDiscordPresence {
notifications = new ElectronNotificationManager();
enable() {
if (this._running !== 0) return;
this._run();
}
disable() {
this._running = 0;
}
get enabled() {
return this._running !== 0;
}
private _running = 0;
private async _run() {
this._running++;
const i = this._running;
try {
await this.loop(true);
while (i === this._running) {
await this.loop();
}
if (this._running === 0) {
// Run one more time after the loop ends
const result = await this.loopRun();
}
debug('Monitor for presence URL %s finished', this.presence_url);
} finally {
this._running = 0;
}
}
async handleError(err: ErrorResponse<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
try {
return await super.handleError(err);
} catch (err) {
if (err instanceof ErrorResponse) {
dialog.showErrorBox('Request error',
err.response.status + ' ' + err.response.statusText + ' ' +
err.response.url + '\n' +
err.body + '\n\n' +
(err.stack ?? err.message));
} else if (err instanceof Error) {
dialog.showErrorBox(err.name, err.stack ?? err.message);
} else {
dialog.showErrorBox('Error', err as any);
}
return LoopResult.OK;
}
}
skipIntervalInCurrentLoop(start = false) {
super.skipIntervalInCurrentLoop();
if (!this._running && start) this.enable();
}
}
export class ElectronNotificationManager extends NotificationManager {
async onFriendOnline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) {
const currenttitle = friend.presence.game as Game;
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}
async onFriendOffline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) {
new Notification({
title: friend.name,
body: 'Offline',
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}
async onFriendPlayingChangeTitle(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) {
const currenttitle = friend.presence.game as Game;
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}
async onFriendTitleStateChange(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) {
const currenttitle = friend.presence.game as Game;
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: await tryGetNativeImageFromUrl(friend.imageUri),
}).show();
}
}

View File

@ -1,6 +1,10 @@
import { nativeImage } from './electron.js';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import fetch from 'node-fetch';
import { dir } from '../../util/product.js';
export const bundlepath = path.resolve(dir, 'dist', 'app', 'bundle');
export async function getNativeImageFromUrl(url: URL | string) {
const response = await fetch(url.toString());

View File

@ -3,18 +3,20 @@ import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import { Buffer } from 'node:buffer';
import createDebug from 'debug';
import { app, BrowserWindow, IpcMainInvokeEvent, session, ShareMenu, shell, WebContents } from './electron.js';
import { app, BrowserWindow, dialog, IpcMainInvokeEvent, Menu, MenuItem, session, ShareMenu, shell, WebContents } from './electron.js';
import fetch from 'node-fetch';
import ZncApi from '../../api/znc.js';
import { dev } from '../../util/product.js';
import { WebService } from '../../api/znc-types.js';
import { bundlepath, Store } from './index.js';
import { Store } from './index.js';
import type { NativeShareRequest, NativeShareUrlRequest } from '../preload-webservice/znca-js-api.js';
import { SavedToken } from '../../common/auth/nso.js';
import { bundlepath } from './util.js';
import Users from '../../common/users.js';
const debug = createDebug('app:main:webservices');
export function createWebServiceWindow(nsa_id: string, webservice: WebService) {
export function createWebServiceWindow(nsa_id: string, webservice: WebService, title_prefix?: string) {
const browser_session = session.fromPartition('persist:webservices-' + nsa_id, {
cache: false,
});
@ -23,7 +25,7 @@ export function createWebServiceWindow(nsa_id: string, webservice: WebService) {
width: 375,
height: 667,
resizable: false,
title: webservice.name,
title: (title_prefix ?? '') + webservice.name,
webPreferences: {
session: browser_session,
preload: path.join(bundlepath, 'preload-webservice.cjs'),
@ -39,7 +41,8 @@ const windows = new Map<string, BrowserWindow>();
const windowapi = new WeakMap<WebContents, [Store, string, ZncApi, SavedToken, WebService]>();
export default async function openWebService(
store: Store, token: string, nso: ZncApi, data: SavedToken, webservice: WebService
store: Store, token: string, nso: ZncApi, data: SavedToken,
webservice: WebService, qs?: string
) {
const windowid = data.nsoAccount.user.nsaId + ':' + webservice.id;
@ -59,7 +62,10 @@ export default async function openWebService(
if (!active) throw new Error('Nintendo Switch Online membership required');
}
const window = createWebServiceWindow(data.nsoAccount.user.nsaId, webservice);
const user_title_prefix = '[' + data.user.nickname +
(data.nsoAccount.user.name !== data.user.nickname ? '/' + data.nsoAccount.user.name : '') + '] ';
const window = createWebServiceWindow(data.nsoAccount.user.nsaId, webservice, user_title_prefix);
windows.set(windowid, window);
windowapi.set(window.webContents, [store, token, nso, data, webservice]);
@ -71,6 +77,20 @@ export default async function openWebService(
window.webContents.userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Mobile/15E148 Safari/604.1';
window.webContents.on('will-navigate', (event, url) => {
debug('Web service will navigate', webservice.uri, webservice.whiteList, url);
if (!isWebServiceUrlAllowed(webservice, url)) {
debug('Web service attempted to navigate to a URL not allowed by it\'s `whiteList`', webservice, url);
event.preventDefault();
}
});
window.on('page-title-updated', (event, title, explicitSet) => {
window.setTitle(user_title_prefix + (explicitSet ? title : webservice.name));
event.preventDefault();
});
window.webContents.setWindowOpenHandler(details => {
debug('open', details);
shell.openExternal(details.url);
@ -86,10 +106,17 @@ export default async function openWebService(
na_lang: data.user.language,
}).toString();
const deepLinkingEnabled = webservice.customAttributes.find(a => a.attrKey === 'deepLinkingEnabled');
if (deepLinkingEnabled?.attrValue === 'true' && qs) {
url.search += '&' + qs;
}
debug('Loading web service', {
url: url.toString(),
webservice,
webserviceToken,
qs,
});
if (dev) window.webContents.openDevTools();
@ -103,6 +130,90 @@ export default async function openWebService(
});
}
function isWebServiceUrlAllowed(webservice: WebService, url: string | URL) {
if (!webservice.whiteList) return true;
if (typeof url === 'string') url = new URL(url);
for (const host of webservice.whiteList) {
if (host.startsWith('*.')) {
return url.hostname === host.substr(2) ||
url.hostname.endsWith(host.substr(1));
}
return url.hostname === host;
}
return false;
}
export async function handleOpenWebServiceUri(store: Store, uri: string) {
const match = uri.match(/^com\.nintendo\.znca:\/\/(znca\/)game\/(\d+)\/?($|\?|\#)/i);
if (!match) return;
const webservice_id = parseInt(match[2]);
const selected_user = await askUserForWebServiceUri(store, uri);
if (!selected_user) return;
const {nso, data, webservices} = await store.users.get(selected_user[0]);
const webservice = webservices.result.find(w => w.id === webservice_id);
if (!webservice) {
dialog.showErrorBox('Invalid web service', 'The URL did not reference an existing web service.\n\n' +
uri);
return;
}
const windowid = data.nsoAccount.user.nsaId + ':' + webservice.id;
if (windows.has(windowid)) {
const window = windows.get(windowid)!;
window.focus();
return;
}
return openWebService(store, selected_user[0], nso, data, webservice, new URL(uri).search.substr(1));
}
async function askUserForWebServiceUri(store: Store, uri: string): Promise<[string, SavedToken] | null> {
const menu = new Menu();
const ids = await store.storage.getItem('NintendoAccountIds') as string[] | undefined;
menu.append(new MenuItem({label: 'Select a user to open this web service', enabled: false}));
menu.append(new MenuItem({label: uri, enabled: false}));
menu.append(new MenuItem({type: 'separator'}));
let selected_user: [string, SavedToken] | null = null;
const items = await Promise.all(ids?.map(async id => {
const token = await store.storage.getItem('NintendoAccountToken.' + id) as string | undefined;
if (!token) return;
const data = await store.storage.getItem('NsoToken.' + token) as SavedToken | undefined;
if (!data) return;
return new MenuItem({
label: data.nsoAccount.user.name,
click: (menuItem, browserWindow, event) => {
selected_user = [token, data];
menu.closePopup(browserWindow);
},
});
}) ?? []);
if (!items.length) return null;
for (const item of items) if (item) menu.append(item);
menu.append(new MenuItem({type: 'separator'}));
menu.append(new MenuItem({label: 'Cancel', click: (i, w) => menu.closePopup(w)}));
const window = new BrowserWindow({show: false});
await new Promise<void>(rs => menu.popup({callback: rs}));
window.destroy();
return selected_user;
}
export interface WebServiceData {
webservice: WebService;
url: string;

View File

@ -1,20 +1,25 @@
import { BrowserWindow, WebContents } from './electron.js';
import { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from './electron.js';
import * as path from 'node:path';
import { bundlepath } from './index.js';
import { dev } from '../../util/product.js';
import { WindowConfiguration, WindowType } from '../common/types.js';
import { bundlepath } from './util.js';
const windows = new Map<WebContents, WindowConfiguration>();
export function createWindow<T extends WindowType>(type: T, props: WindowConfiguration<T>['props']) {
export function createWindow<T extends WindowType>(
type: T, props: WindowConfiguration<T>['props'],
options?: BrowserWindowConstructorOptions
) {
// Create the browser window
const window = new BrowserWindow({
width: 800,
height: 600,
vibrancy: 'content',
...options,
webPreferences: {
preload: path.join(bundlepath, 'preload.cjs'),
scrollBounce: true,
...options?.webPreferences,
},
});

View File

@ -1,23 +1,73 @@
import { contextBridge, ipcRenderer } from 'electron';
import { EventEmitter } from 'node:events';
import { WindowConfiguration } from '../common/types.js';
import { SavedToken } from '../../common/auth/nso.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import createDebug from 'debug';
import type { DiscordPresenceSource, WindowConfiguration } from '../common/types.js';
import type { SavedToken } from '../../common/auth/nso.js';
import type { SavedMoonToken } from '../../common/auth/moon.js';
import type { UpdateCacheData } from '../../common/update.js';
import { Announcements, Friend, GetActiveEventResult, WebService, WebServices } from '../../api/znc-types.js';
import { DiscordPresence } from '../../discord/util.js';
import { User } from 'discord-rpc';
import { SharingItem } from '../main/electron.js';
const debug = createDebug('app:preload');
const inv = <T = void>(channel: string, ...args: any[]) =>
ipcRenderer.invoke('nxapi:' + channel, ...args) as Promise<T>;
const invSync = <T = void>(channel: string, ...args: any[]) =>
ipcRenderer.sendSync('nxapi:' + channel, ...args) as T;
const events = new EventEmitter();
events.setMaxListeners(0);
const ipc = {
getWindowData: () => ipcRenderer.sendSync('nxapi:browser:getwindowdata') as WindowConfiguration,
getWindowData: () => invSync<WindowConfiguration>('browser:getwindowdata'),
listNintendoAccounts: () => ipcRenderer.invoke('nxapi:accounts:list') as Promise<string[] | undefined>,
getNintendoAccountNsoToken: (id: string) => ipcRenderer.invoke('nxapi:nso:gettoken', id) as Promise<string | undefined>,
getSavedNsoToken: (token: string) => ipcRenderer.invoke('nxapi:nso:getcachedtoken', token) as Promise<SavedToken | undefined>,
getNintendoAccountMoonToken: (id: string) => ipcRenderer.invoke('nxapi:moon:gettoken', id) as Promise<string | undefined>,
getSavedMoonToken: (token: string) => ipcRenderer.invoke('nxapi:moon:getcachedtoken', token) as Promise<SavedMoonToken | undefined>,
getUpdateData: () => inv<UpdateCacheData | null>('update:get'),
checkUpdates: () => inv<UpdateCacheData | null>('update:check'),
events: new EventEmitter(),
listNintendoAccounts: () => inv<string[] | undefined>('accounts:list'),
addNsoAccount: () => inv<string>('accounts:add-coral'),
addMoonAccount: () => inv<string>('accounts:add-moon'),
getNintendoAccountNsoToken: (id: string) => inv<string | undefined>('nso:gettoken', id),
getSavedNsoToken: (token: string) => inv<SavedToken | undefined>('nso:getcachedtoken', token),
getNsoAnnouncements: (token: string) => inv<Announcements>('nso:announcements', token),
getNsoFriends: (token: string) => inv<Friend[]>('nso:friends', token),
getNsoWebServices: (token: string) => inv<WebServices | undefined>('nso:webservices', token),
openWebService: (webservice: WebService, token: string, qs?: string) => inv<number>('nso:openwebservice', webservice, token, qs),
getNsoActiveEvent: (token: string) => inv<GetActiveEventResult>('nso:activeevent', token),
getDiscordPresenceSource: () => inv<DiscordPresenceSource | null>('discord:source'),
setDiscordPresenceSource: (source: DiscordPresenceSource | null) => inv<void>('discord:setsource', source),
getDiscordPresence: () => inv<DiscordPresence | null>('discord:presence'),
getDiscordUser: () => inv<User | null>('discord:user'),
getNintendoAccountMoonToken: (id: string) => inv<string | undefined>('moon:gettoken', id),
getSavedMoonToken: (token: string) => inv<SavedMoonToken | undefined>('moon:getcachedtoken', token),
showFriendModal: (na_id: string, nsa_id: string) => inv<number>('window:showfriend', na_id, nsa_id),
openExternalUrl: (url: string) => inv('misc:open-url', url),
share: (item: SharingItem) => inv('misc:share', item),
registerEventListener: (event: string, listener: (args: any[]) => void) => events.on(event, listener),
removeEventListener: (event: string, listener: (args: any[]) => void) => events.removeListener(event, listener),
getAccentColour: () => accent_colour,
};
export type NxapiElectronIpc = typeof ipc;
ipcRenderer.on('nxapi:accounts:shouldrefresh', () => ipc.events.emit('update-nintendo-accounts'));
ipcRenderer.on('nxapi:accounts:shouldrefresh', () => events.emit('update-nintendo-accounts'));
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));
let accent_colour: string = invSync('systemPreferences:accent-colour');
ipcRenderer.on('nxapi:systemPreferences:accent-colour', (event, c) => {
accent_colour = c;
events.emit('systemPreferences:accent-colour', c);
});
contextBridge.exposeInMainWorld('nxapiElectronIpc', ipc);

View File

@ -5,7 +5,7 @@ import Yargs from 'yargs';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import * as commands from './cli/index.js';
import { checkUpdates } from './cli/update.js';
import { checkUpdates } from './common/update.js';
import { dev } from './util/product.js';
import { paths } from './util/storage.js';
import { YargsArguments } from './util/yargs.js';

View File

@ -8,6 +8,7 @@ import { SavedToken } from './auth/nso.js';
import { SplatNet2RecordsMonitor } from './splatnet2/monitor.js';
import Loop, { LoopResult } from '../util/loop.js';
import { getTitleIdFromEcUrl, hrduration } from '../util/misc.js';
import { CoralUser } from './users.js';
const debug = createDebug('nxapi:nso:notify');
const debugFriends = createDebug('nxapi:nso:notify:friends');
@ -26,6 +27,7 @@ export class ZncNotifications extends Loop {
public token: string,
public nso: ZncApi,
public data: Omit<SavedToken, 'expires_at'>,
public user?: CoralUser,
) {
super();
}
@ -51,10 +53,14 @@ export class ZncNotifications extends Loop {
}
if (req.includes('announcements')) {
result.announcements = (await this.nso.getAnnouncements()).result;
result.announcements = this.user ?
(await this.user?.getAnnouncements()) :
(await this.nso.getAnnouncements()).result;
}
if (req.includes('friends') || (friends && !(this.nso instanceof ZncProxyApi))) {
result.friends = (await this.nso.getFriendList()).result.friends;
result.friends = this.user ?
(await this.user.getFriends()) :
(await this.nso.getFriendList()).result.friends;
} else if (friends && this.nso instanceof ZncProxyApi) {
result.friends = await Promise.all(friends.map(async r => {
const nso = this.nso as unknown as ZncProxyApi;
@ -76,13 +82,17 @@ export class ZncNotifications extends Loop {
}
return (await nso.fetch<{friend: Friend}>('/friend/' + r.friend)).friend;
}))
}));
}
if (req.includes('webservices')) {
result.webservices = (await this.nso.getWebServices()).result;
result.webservices = this.user ?
(await this.user.getWebServices()) :
(await this.nso.getWebServices()).result;
}
if (req.includes('event')) {
const activeevent = (await this.nso.getActiveEvent()).result;
const activeevent = this.user ?
(await this.user.getActiveEvent()) :
(await this.nso.getActiveEvent()).result;
result.activeevent = 'id' in activeevent ? activeevent : undefined;
}
if (req.includes('user')) {

View File

@ -1,63 +1,20 @@
import createDebug from 'debug';
import { DiscordRpcClient, findDiscordRpcClient } from '../discord/rpc.js';
import { DiscordPresencePlayTime, DiscordPresenceContext, getDiscordPresence, getInactiveDiscordPresence } from '../discord/util.js';
import { ZncNotifications } from './notify.js';
import { DiscordPresencePlayTime, DiscordPresenceContext, getDiscordPresence, getInactiveDiscordPresence, DiscordPresence } from '../discord/util.js';
import { EmbeddedSplatNet2Monitor, ZncNotifications } from './notify.js';
import { getPresenceFromUrl } from '../api/znc-proxy.js';
import { ActiveEvent, CurrentUser, Friend, Presence, PresenceState, ZncErrorResponse } from '../api/znc-types.js';
import { ActiveEvent, CurrentUser, Friend, Game, Presence, PresenceState, ZncErrorResponse } from '../api/znc-types.js';
import { ErrorResponse } from '../api/util.js';
import { LoopResult } from '../util/loop.js';
import Loop, { LoopResult } from '../util/loop.js';
import { getTitleIdFromEcUrl } from '../index.js';
const debug = createDebug('nxapi:nso:presence');
const debugProxy = createDebug('nxapi:nso:presence:proxy');
const debugDiscord = createDebug('nxapi:nso:presence:discordrpc');
const debugSplatnet2 = createDebug('nxapi:nso:presence:splatnet2');
export class ZncDiscordPresence extends ZncNotifications {
presence_user: string | null = null;
discord_preconnect = false;
show_friend_code = false;
force_friend_code: CurrentUser['links']['friendCode'] | undefined = undefined;
show_console_online = false;
show_active_event = true;
show_play_time = DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE;
async init() {
const {friends, user, activeevent} = await this.fetch([
'announcements',
this.presence_user ?
this.presence_user === this.data.nsoAccount.user.nsaId ? 'user' :
{friend: this.presence_user} : null,
this.presence_user && this.show_active_event ? 'event' : null,
this.user_notifications ? 'user' : null,
this.friend_notifications ? 'friends' : null,
this.splatnet2_monitors.size ? 'user' : null,
]);
if (this.presence_user) {
if (this.presence_user !== this.data.nsoAccount.user.nsaId) {
const friend = friends!.find(f => f.nsaId === this.presence_user);
if (!friend) {
throw new Error('User "' + this.presence_user + '" is not friends with this user');
}
await this.updatePresenceForDiscord(friend.presence, friend);
} else {
await this.updatePresenceForDiscord(user!.presence, user, user!.links.friendCode, activeevent);
}
}
await this.updatePresenceForNotifications(user, friends, this.data.user.id, true);
if (user) await this.updatePresenceForSplatNet2Monitors([user]);
return LoopResult.OK;
}
get presence_enabled() {
return !!this.presence_user;
}
class ZncDiscordPresenceClient {
rpc: {client: DiscordRpcClient, id: string} | null = null;
discord_client_filter: ((client: DiscordRpcClient, id?: number) => boolean) | undefined = undefined;
title: {id: string; since: number} | null = null;
i = 0;
@ -66,6 +23,16 @@ export class ZncDiscordPresence extends ZncNotifications {
last_friendcode: CurrentUser['links']['friendCode'] | undefined = undefined;
last_event: ActiveEvent | undefined = undefined;
last_activity: DiscordPresence | null = null;
onUpdateActivity: ((activity: DiscordPresence | null) => void) | null = null;
onUpdateClient: ((client: DiscordRpcClient | null) => void) | null = null;
update_presence_errors = 0;
constructor(
readonly m: ZncDiscordPresence | ZncProxyDiscordPresence,
) {}
async updatePresenceForDiscord(
presence: Presence | null,
user?: CurrentUser | Friend,
@ -81,30 +48,39 @@ export class ZncDiscordPresence extends ZncNotifications {
const show_presence =
(online && 'name' in presence.game) ||
(this.show_console_online && presence?.state === PresenceState.INACTIVE);
(this.m.show_console_online && presence?.state === PresenceState.INACTIVE);
if (this.rpc && this.discord_client_filter && !this.discord_client_filter.call(null, this.rpc.client)) {
if (this.rpc && this.m.discord_client_filter && !this.m.discord_client_filter.call(null, this.rpc.client)) {
const client = this.rpc.client;
this.rpc = null;
this.last_activity = null;
await client.destroy();
this.onUpdateActivity?.call(null, null);
this.onUpdateClient?.call(null, null);
}
if (!presence || !show_presence) {
if (this.presence_enabled && this.discord_preconnect && !this.rpc) {
if (this.m.presence_enabled && this.m.discord_preconnect && !this.rpc) {
debugDiscord('No presence but Discord preconnect enabled - connecting');
const discordpresence = getInactiveDiscordPresence(PresenceState.OFFLINE, 0);
const client = await this.createDiscordClient(discordpresence.id, this.discord_client_filter);
const client = await this.createDiscordClient(discordpresence.id, this.m.discord_client_filter);
this.rpc = {client, id: discordpresence.id};
this.onUpdateClient?.call(null, client);
return;
}
if (this.rpc && !(this.presence_enabled && this.discord_preconnect)) {
if (this.rpc && !(this.m.presence_enabled && this.m.discord_preconnect)) {
const client = this.rpc.client;
this.rpc = null;
this.last_activity = null;
await client.destroy();
this.onUpdateActivity?.call(null, null);
this.onUpdateClient?.call(null, null);
} else if (this.rpc && this.title) {
debugDiscord('No presence but Discord preconnect enabled - clearing Discord activity');
this.last_activity = null;
await this.rpc.client.clearActivity();
this.onUpdateActivity?.call(null, null);
}
this.title = null;
@ -112,11 +88,11 @@ export class ZncDiscordPresence extends ZncNotifications {
}
const presencecontext: DiscordPresenceContext = {
friendcode: this.show_friend_code ? this.force_friend_code ?? friendcode : undefined,
activeevent: this.show_active_event ? activeevent : undefined,
show_play_time: this.show_play_time,
znc_discord_presence: this,
nsaid: this.presence_user!,
friendcode: this.m.show_friend_code ? this.m.force_friend_code ?? friendcode : undefined,
activeevent: this.m.show_active_event ? activeevent : undefined,
show_play_time: this.m.show_play_time,
znc_discord_presence: this.m,
nsaid: this.m.presence_user!,
user,
};
@ -127,12 +103,16 @@ export class ZncDiscordPresence extends ZncNotifications {
if (this.rpc && this.rpc.id !== discordpresence.id) {
const client = this.rpc.client;
this.rpc = null;
this.last_activity = null;
await client.destroy();
this.onUpdateActivity?.call(null, null);
this.onUpdateClient?.call(null, null);
}
if (!this.rpc) {
const client = await this.createDiscordClient(discordpresence.id, this.discord_client_filter);
const client = await this.createDiscordClient(discordpresence.id, this.m.discord_client_filter);
this.rpc = {client, id: discordpresence.id};
this.onUpdateClient?.call(null, client);
}
if (discordpresence.title) {
@ -155,6 +135,9 @@ export class ZncDiscordPresence extends ZncNotifications {
this.rpc.client.setActivity(discordpresence.activity);
this.last_activity = discordpresence;
this.onUpdateActivity?.call(null, discordpresence);
this.update_presence_errors = 0;
}
@ -222,6 +205,73 @@ export class ZncDiscordPresence extends ZncNotifications {
return client!;
}
async onError(err: Error) {
this.update_presence_errors++;
if (this.update_presence_errors > 2) {
// Disconnect from Discord if the last two attempts to update presence failed
// This prevents the user's activity on Discord being stuck
if (this.rpc) {
const client = this.rpc.client;
this.rpc = null;
await client.destroy();
}
this.title = null;
}
}
}
export class ZncDiscordPresence extends ZncNotifications {
presence_user: string | null = null;
discord_preconnect = false;
show_friend_code = false;
force_friend_code: CurrentUser['links']['friendCode'] | undefined = undefined;
show_console_online = false;
show_active_event = false;
show_play_time = DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE;
discord_client_filter: ((client: DiscordRpcClient, id?: number) => boolean) | undefined = undefined;
readonly discord = new ZncDiscordPresenceClient(this);
async init() {
const {friends, user, activeevent} = await this.fetch([
'announcements',
this.presence_user ?
this.presence_user === this.data.nsoAccount.user.nsaId ? 'user' :
{friend: this.presence_user} : null,
this.presence_user && this.presence_user !== this.data.nsoAccount.user.nsaId &&
this.show_active_event ? 'event' : null,
this.user_notifications ? 'user' : null,
this.friend_notifications ? 'friends' : null,
this.splatnet2_monitors.size ? 'user' : null,
]);
if (this.presence_user) {
if (this.presence_user !== this.data.nsoAccount.user.nsaId) {
const friend = friends!.find(f => f.nsaId === this.presence_user);
if (!friend) {
throw new Error('User "' + this.presence_user + '" is not friends with this user');
}
await this.discord.updatePresenceForDiscord(friend.presence, friend);
} else {
await this.discord.updatePresenceForDiscord(user!.presence, user, user!.links.friendCode, activeevent);
}
}
await this.updatePresenceForNotifications(user, friends, this.data.user.id, true);
if (user) await this.updatePresenceForSplatNet2Monitors([user]);
return LoopResult.OK;
}
get presence_enabled() {
return !!this.presence_user;
}
async update() {
const {friends, user, activeevent} = await this.fetch([
this.presence_user ?
@ -239,12 +289,12 @@ export class ZncDiscordPresence extends ZncNotifications {
if (!friend) {
// Is the authenticated user no longer friends with this user?
await this.updatePresenceForDiscord(null);
await this.discord.updatePresenceForDiscord(null);
} else {
await this.updatePresenceForDiscord(friend.presence, friend);
await this.discord.updatePresenceForDiscord(friend.presence, friend);
}
} else {
await this.updatePresenceForDiscord(user!.presence, user, user!.links.friendCode, activeevent);
await this.discord.updatePresenceForDiscord(user!.presence, user, user!.links.friendCode, activeevent);
}
}
@ -252,32 +302,36 @@ export class ZncDiscordPresence extends ZncNotifications {
if (user) await this.updatePresenceForSplatNet2Monitors([user]);
}
update_presence_errors = 0;
async handleError(err: ErrorResponse<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
this.update_presence_errors++;
if (this.update_presence_errors > 2) {
// Disconnect from Discord if the last two attempts to update presence failed
// This prevents the user's activity on Discord being stuck
if (this.rpc) {
const client = this.rpc.client;
this.rpc = null;
await client.destroy();
}
this.title = null;
}
this.discord.onError(err);
return super.handleError(err);
}
}
export class ZncProxyDiscordPresence extends ZncDiscordPresence {
export class ZncProxyDiscordPresence extends Loop {
splatnet2_monitors = new Map<string, EmbeddedSplatNet2Monitor | (() => Promise<EmbeddedSplatNet2Monitor>)>();
readonly user_notifications = false;
readonly friend_notifications = false;
update_interval = 30;
presence_user: string | null = null;
discord_preconnect = false;
show_friend_code = false;
force_friend_code: CurrentUser['links']['friendCode'] | undefined = undefined;
show_console_online = false;
readonly show_active_event = false;
show_play_time = DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE;
discord_client_filter: ((client: DiscordRpcClient, id?: number) => boolean) | undefined = undefined;
readonly discord = new ZncDiscordPresenceClient(this);
constructor(
public presence_url: string
) {
super(null!, null!, null!, null!);
super();
}
get presence_enabled() {
@ -293,7 +347,41 @@ export class ZncProxyDiscordPresence extends ZncDiscordPresence {
async update() {
const [presence, user] = await getPresenceFromUrl(this.presence_url);
await this.updatePresenceForDiscord(presence, user);
await this.discord.updatePresenceForDiscord(presence, user);
await this.updatePresenceForSplatNet2Monitor(presence, this.presence_url);
}
async updatePresenceForSplatNet2Monitors(friends: (CurrentUser | Friend)[]) {
for (const friend of friends) {
await this.updatePresenceForSplatNet2Monitor(friend.presence, friend.nsaId, friend.name);
}
}
async updatePresenceForSplatNet2Monitor(presence: Presence, nsa_id: string, name?: string) {
const playing = presence.state === PresenceState.PLAYING;
const monitor = this.splatnet2_monitors.get(nsa_id);
if (playing && monitor) {
const currenttitle = presence.game as Game;
const titleid = getTitleIdFromEcUrl(currenttitle.shopUri);
if (titleid && EmbeddedSplatNet2Monitor.title_ids.includes(titleid)) {
if ('enable' in monitor) {
monitor.enable();
if (!monitor.enabled) debugSplatnet2('Started monitor for user %s', name ?? nsa_id);
} else {
const m = await monitor.call(null);
this.splatnet2_monitors.set(nsa_id, m);
m.enable();
debugSplatnet2('Started monitor for user %s', name ?? nsa_id);
}
} else if ('disable' in monitor) {
if (monitor.enabled) debugSplatnet2('Stopping monitor for user %s', name ?? nsa_id);
monitor.disable();
}
} else if (monitor && 'disable' in monitor) {
if (monitor.enabled) debugSplatnet2('Stopping monitor for user %s', name ?? nsa_id);
monitor.disable();
}
}
}

View File

@ -6,16 +6,18 @@ import mkdirp from 'mkdirp';
import { dir, version } from '../util/product.js';
import { paths } from '../util/storage.js';
const debug = createDebug('cli:update');
const debug = createDebug('nxapi:update');
const RELEASES_URL = 'https://api.github.com/repos/samuelthomas2774/nxapi/releases';
export async function checkUpdates() {
try {
await fs.stat(path.join(dir, '.git'));
if (!process.versions.electron) {
await fs.stat(path.join(dir, '.git'));
debug('git repository exists, skipping update check');
return null;
debug('git repository exists, skipping update check');
return null;
}
} catch (err) {}
await mkdirp(paths.cache);

146
src/common/users.tsx Normal file
View File

@ -0,0 +1,146 @@
import createDebug from 'debug';
import * as persist from 'node-persist';
import ZncApi from '../api/znc.js';
import ZncProxyApi from '../api/znc-proxy.js';
import { Announcements, Friends, GetActiveEventResult, WebServices, ZncSuccessResponse } from '../api/znc-types.js';
import { getToken, SavedToken } from './auth/nso.js';
import { Jwt } from '../util/jwt.js';
import { NintendoAccountSessionTokenJwtPayload } from '../api/na.js';
const debug = createDebug('nxapi:users');
export interface UserData {
created_at: number;
expires_at: number;
}
export default class Users<T extends UserData> {
private users = new Map<string, T>();
private promise = new Map<string, Promise<T>>();
private _get: (token: string) => Promise<T>;
constructor(get: (token: string) => Promise<T>) {
this._get = get;
}
async get(token: string): Promise<T> {
if (debug.enabled) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
debug('Getting user for token', jwt.payload.sub);
}
const existing = this.users.get(token);
if (existing && existing.expires_at >= Date.now()) {
return existing;
}
const promise = this.promise.get(token) ?? this._get.call(null, token).then(data => {
this.users.set(token, data);
return data;
}).finally(() => {
this.promise.delete(token);
});
this.promise.set(token, promise);
return promise;
}
static coral(storage: persist.LocalStorage, znc_proxy_url: string): Users<CoralUser<ZncProxyApi>>
static coral(storage: persist.LocalStorage, znc_proxy_url?: string): Users<CoralUser>
static coral(storage: persist.LocalStorage, znc_proxy_url?: string) {
return new Users(async token => {
const {nso, data} = await getToken(storage, token, znc_proxy_url);
const [announcements, friends, webservices, active_event] = await Promise.all([
nso.getAnnouncements(),
nso.getFriendList(),
nso.getWebServices(),
nso.getActiveEvent(),
]);
return new CoralUser(nso, data, announcements, friends, webservices, active_event);
});
}
}
export interface CoralUserData<T extends ZncApi = ZncApi> extends UserData {
nso: T;
data: SavedToken;
announcements: ZncSuccessResponse<Announcements>;
friends: ZncSuccessResponse<Friends>;
webservices: ZncSuccessResponse<WebServices>;
active_event: ZncSuccessResponse<GetActiveEventResult>;
}
export class CoralUser<T extends ZncApi = ZncApi> implements CoralUserData<T> {
created_at = Date.now();
expires_at = Infinity;
promise = new Map<string, Promise<void>>();
updated = {
announcements: Date.now(),
friends: Date.now(),
webservices: Date.now(),
active_event: Date.now(),
};
constructor(
public nso: T,
public data: SavedToken,
public announcements: ZncSuccessResponse<Announcements>,
public friends: ZncSuccessResponse<Friends>,
public webservices: ZncSuccessResponse<WebServices>,
public active_event: ZncSuccessResponse<GetActiveEventResult>,
) {}
private async update(key: keyof CoralUser['updated'], callback: () => Promise<void>, ttl: number) {
if ((this.updated[key] + ttl) < Date.now()) {
const promise = this.promise.get(key) ?? callback.call(null).then(() => {
this.updated[key] = Date.now();
}).finally(() => {
this.promise.delete(key);
});
this.promise.set(key, promise);
await promise;
} else {
debug('Not updating %s data for coral user %s', key, this.data.nsoAccount.user.name);
}
}
async getAnnouncements() {
await this.update('announcements', async () => {
this.announcements = await this.nso.getAnnouncements();
}, 30 * 60 * 1000);
return this.announcements.result;
}
async getFriends() {
await this.update('friends', async () => {
this.friends = await this.nso.getFriendList();
}, 10 * 1000);
return this.friends.result.friends;
}
async getWebServices() {
await this.update('webservices', async () => {
this.webservices = await this.nso.getWebServices();
}, 10 * 1000);
return this.webservices.result;
}
async getActiveEvent() {
await this.update('active_event', async () => {
this.active_event = await this.nso.getActiveEvent();
}, 10 * 1000);
return this.active_event.result;
}
}

View File

@ -3,7 +3,7 @@ import { ActiveEvent, CurrentUser, Friend, Game, PresenceState } from '../api/zn
import { defaultTitle, titles } from './titles.js';
import { product } from '../util/product.js';
import { getTitleIdFromEcUrl, hrduration } from '../util/misc.js';
import { ZncDiscordPresence } from '../common/presence.js';
import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../common/presence.js';
export function getDiscordPresence(
state: PresenceState, game: Game, context?: DiscordPresenceContext
@ -143,7 +143,7 @@ export interface DiscordPresenceContext {
friendcode?: CurrentUser['links']['friendCode'];
activeevent?: ActiveEvent;
show_play_time?: DiscordPresencePlayTime;
znc_discord_presence?: ZncDiscordPresence;
znc_discord_presence?: ZncDiscordPresence | ZncProxyDiscordPresence;
nsaid?: string;
user?: CurrentUser | Friend;
}