mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-03-21 18:04:10 -05:00
Electron app
This commit is contained in:
parent
27e8fa5401
commit
60c0e592db
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 | {};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
18
src/app/browser/constants.ts
Normal file
18
src/app/browser/constants.ts
Normal 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';
|
||||
226
src/app/browser/friend/index.tsx
Normal file
226
src/app/browser/friend/index.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
145
src/app/browser/main/discord.tsx
Normal file
145
src/app/browser/main/discord.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
87
src/app/browser/main/event.tsx
Normal file
87
src/app/browser/main/event.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
145
src/app/browser/main/friends.tsx
Normal file
145
src/app/browser/main/friends.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
127
src/app/browser/main/index.tsx
Normal file
127
src/app/browser/main/index.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
57
src/app/browser/main/section.tsx
Normal file
57
src/app/browser/main/section.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
199
src/app/browser/main/sidebar.tsx
Normal file
199
src/app/browser/main/sidebar.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
48
src/app/browser/main/update.tsx
Normal file
48
src/app/browser/main/update.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
79
src/app/browser/main/webservices.tsx
Normal file
79
src/app/browser/main/webservices.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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
161
src/app/browser/util.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
110
src/app/main/ipc.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
344
src/app/main/monitor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
146
src/common/users.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user