Electron app

This commit is contained in:
Samuel Elliott 2022-06-09 00:39:00 +01:00
parent 60c0e592db
commit 065e9f6175
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
32 changed files with 1242 additions and 333 deletions

View File

@ -39,6 +39,7 @@ const cli = {
}),
json(),
nodeResolve({
exportConditions: ['node'],
browser: false,
preferBuiltins: true,
}),
@ -59,6 +60,7 @@ const app = {
file: 'dist/bundle/app-main-bundle.cjs',
format: 'cjs',
inlineDynamicImports: true,
sourcemap: true,
},
plugins: [
typescript({
@ -74,6 +76,7 @@ const app = {
}),
json(),
nodeResolve({
exportConditions: ['node'],
browser: false,
preferBuiltins: true,
}),
@ -92,6 +95,7 @@ const app_preload = {
output: {
file: 'dist/app/bundle/preload.cjs',
format: 'cjs',
sourcemap: true,
},
plugins: [
typescript({
@ -152,6 +156,7 @@ const app_browser = {
output: {
file: 'dist/app/bundle/browser.js',
format: 'iife',
sourcemap: true,
},
plugins: [
html({

View File

@ -1,110 +0,0 @@
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 { 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[] = [];
for (const id of ids ?? []) {
const nsotoken = await ipc.getNintendoAccountNsoToken(id);
const moontoken = await ipc.getNintendoAccountMoonToken(id);
const nso = nsotoken ? await ipc.getSavedNsoToken(nsotoken) ?? null : null;
const moon = moontoken ? await ipc.getSavedMoonToken(moontoken) ?? null : null;
if (!nso && !moon) continue;
accounts.push({user: nso?.user ?? moon!.user, nso, nsotoken, moon, moontoken});
}
return accounts;
}
function App(props: AppProps) {
const colour_scheme = useColorScheme();
const theme = colour_scheme === 'light' ? light : dark;
const [users] = useAsync(useCallback(() => getAccounts(), [ipc]));
console.log(users);
const [selectedUserId, setSelectedUserId] = useState<string | undefined>(undefined);
const selectedUser = useMemo(() => users?.find(u => u.user.id === selectedUserId), [users, selectedUserId]);
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: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
main: {
backgroundColor: BACKGROUND_COLOUR_MAIN_DARK,
},
text: {
color: TEXT_COLOUR_DARK,
},
});
export default App;

View File

@ -2,8 +2,11 @@ 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 BACKGROUND_COLOUR_MAIN_LIGHT = '#ececec';
export const BACKGROUND_COLOUR_MAIN_DARK = '#252424';
export const BACKGROUND_COLOUR_SECONDARY_LIGHT = '#ffffff';
export const BACKGROUND_COLOUR_SECONDARY_DARK = '#353535';
export const UPDATE_COLOUR = '#006064e0';
@ -16,3 +19,5 @@ export const BORDER_COLOUR_DARK = '#00000080';
export const TEXT_COLOUR_LIGHT = '#212121';
export const TEXT_COLOUR_DARK = '#f5f5f5';
export const TEXT_COLOUR_ACTIVE = '#3ba55d';
export const DEFAULT_ACCENT_COLOUR = NSO_COLOUR.substr(1).toUpperCase() + 'FF';

View File

@ -0,0 +1,234 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, StyleSheet, Text, TextInput, useColorScheme, View } from 'react-native';
import { Picker } from 'react-native-web';
import { DEFAULT_ACCENT_COLOUR, HIGHLIGHT_COLOUR_DARK, HIGHLIGHT_COLOUR_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import ipc, { events } from '../ipc.js';
import { getAccounts, RequestState, Root, useAsync, useDiscordPresenceSource, useEventListener, User } from '../util.js';
export interface DiscordSetupProps {
//
}
enum DiscordSourceType {
ZNC,
URL,
NONE,
}
export default function DiscordSetup(props: DiscordSetupProps) {
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 [users, ,, forceRefreshAccounts] = useAsync(useCallback(() => getAccounts(), [ipc]));
useEventListener(events, 'update-nintendo-accounts', forceRefreshAccounts, []);
const [discord_presence_source, discord_presence_source_state] = useDiscordPresenceSource();
const [selectedMode, setSelectedMode] = useState(DiscordSourceType.NONE);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [selectedFriendNsaId, setSelectedFriendNsaId] = useState<string | null>(null);
const [presenceUrl, setPresenceUrl] = useState('');
useEffect(() => {
if (!discord_presence_source) {
setSelectedMode(DiscordSourceType.NONE);
} else if ('na_id' in discord_presence_source) {
setSelectedMode(DiscordSourceType.ZNC);
setSelectedUserId(discord_presence_source.na_id);
setSelectedFriendNsaId(discord_presence_source.friend_nsa_id ?? null);
} else if ('url' in discord_presence_source) {
setSelectedMode(DiscordSourceType.URL);
setPresenceUrl(discord_presence_source.url);
}
}, [discord_presence_source]);
const user = useMemo(() => selectedUserId ? users?.find(u => u.user.id === selectedUserId) : undefined,
[selectedUserId, users]);
const [friends, , friends_state, forceRefreshFriends] = useAsync(useCallback(() => user?.nsotoken ?
ipc.getNsoFriends(user.nsotoken) : Promise.resolve(null), [ipc, user?.nsotoken]));
const friends_with_presence = useMemo(() => friends?.filter(f => f.presence.updatedAt ||
f.nsaId === selectedFriendNsaId), [friends, selectedFriendNsaId]);
const friend = useMemo(() => selectedFriendNsaId ? friends?.find(f => f.nsaId === selectedFriendNsaId) : undefined,
[selectedFriendNsaId, friends]);
useEventListener(events, 'window:refresh', () => (forceRefreshAccounts(), forceRefreshFriends()), []);
useEffect(() => {
if (users?.length && !user) setSelectedUserId(users[0].user.id);
}, [users, user]);
useEffect(() => {
if (friends?.length && !friend) setSelectedFriendNsaId(friends[0].nsaId);
}, [friends, user]);
const save = useCallback(async () => {
if (selectedMode === DiscordSourceType.ZNC) {
if (!selectedUserId?.match(/^[0-9a-f]{16}$/)) throw new Error('Invalid Nintendo Account ID');
if (!selectedFriendNsaId?.match(/^[0-9a-f]{16}$/)) throw new Error('Invalid friend Network Service Account ID');
await ipc.setDiscordPresenceSource({na_id: selectedUserId, friend_nsa_id: selectedFriendNsaId});
} else if (selectedMode === DiscordSourceType.URL) {
await ipc.setDiscordPresenceSource({url: presenceUrl});
} else {
await ipc.setDiscordPresenceSource(null);
}
window.close();
}, [selectedMode, selectedUserId, selectedFriendNsaId, presenceUrl]);
useEffect(() => {
const handler = (event: KeyboardEvent) => event.key === 'Escape' && window.close();
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
if (!users || discord_presence_source_state !== RequestState.LOADED) {
return null;
}
return <Root title="Discord Rich Presence setup" scrollable autoresize>
<View style={styles.main}>
<Text style={theme.text}>Discord Rich Presence setup</Text>
<Text style={[styles.header, theme.text]}>1. Select mode</Text>
<Picker<DiscordSourceType> selectedValue={selectedMode} onValueChange={v => setSelectedMode(parseInt(v))}
style={[styles.picker, theme.picker]}
>
<Picker.Item label="Select a user that is friends with the user you want to share"
value={DiscordSourceType.ZNC} />
<Picker.Item label="Enter a URL that returns the presence data you want to share"
value={DiscordSourceType.URL} />
<Picker.Item label="Disable" value={DiscordSourceType.NONE} />
</Picker>
{selectedMode === DiscordSourceType.ZNC ? <>
<Text style={[styles.header, theme.text]}>2. Select user</Text>
<Text style={[styles.help, theme.text]}>This user must be friends with the user you want to share.</Text>
<Picker<string> selectedValue={selectedUserId ?? ''} onValueChange={setSelectedUserId}
style={[styles.picker, theme.picker]}
>
{users.filter(u => u.nso).map(u => <Picker.Item
label={u.user.nickname +
(u.user.nickname !== u.nso!.nsoAccount.user.name ? '/' + u.nso!.nsoAccount.user.name : '')}
value={u.user.id}
/>)}
</Picker>
{user && friends_with_presence ? <>
<Text style={[styles.header, theme.text]}>3. Select friend</Text>
<Text style={[styles.help, theme.text]}>This is the user you want to share.</Text>
<Picker<string> selectedValue={selectedFriendNsaId ?? ''} onValueChange={setSelectedFriendNsaId}
style={[styles.picker, theme.picker]}
>
{friends_with_presence.map(f => <Picker.Item label={f.name} value={f.nsaId} />)}
</Picker>
</> : null}
</> : null}
{selectedMode === DiscordSourceType.URL ? <>
<Text style={[styles.header, theme.text]}>2. Enter presence URL</Text>
<Text style={[styles.help, theme.text]}>This must be a HTTPS URL that returns a JSON object with either a user, friend or presence key. This is intended to be used with nxapi's znc API proxy.</Text>
<TextInput value={presenceUrl} onChangeText={setPresenceUrl}
placeholder="https://nxapi.example.com/api/znc/friend/..."
style={[styles.textInput, theme.textInput]} />
</> : null}
<View style={styles.buttons}>
<View style={styles.button}>
<Button title="Cancel"
onPress={() => window.close()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
<View style={styles.button}>
<Button title="Save"
onPress={save}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
</View>
</View>
</Root>;
}
const styles = StyleSheet.create({
loading: {
flex: 1,
paddingVertical: 50,
paddingHorizontal: 20,
justifyContent: 'center',
},
main: {
flex: 1,
paddingVertical: 20,
paddingHorizontal: 20,
},
header: {
marginTop: 12,
},
help: {
marginTop: 8,
fontSize: 13,
opacity: 0.7,
},
picker: {
marginTop: 8,
paddingVertical: 4,
paddingHorizontal: 8,
borderWidth: 0,
borderRadius: 3,
fontSize: 13,
},
textInput: {
marginTop: 8,
paddingVertical: 5,
paddingHorizontal: 10,
borderRadius: 3,
fontSize: 13,
},
buttons: {
marginTop: 20,
flexDirection: 'row',
justifyContent: 'flex-end',
},
button: {
marginLeft: 10,
},
});
const light = StyleSheet.create({
text: {
color: TEXT_COLOUR_LIGHT,
},
picker: {
backgroundColor: HIGHLIGHT_COLOUR_LIGHT,
color: TEXT_COLOUR_LIGHT,
},
textInput: {
backgroundColor: HIGHLIGHT_COLOUR_LIGHT,
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
text: {
color: TEXT_COLOUR_DARK,
},
picker: {
backgroundColor: HIGHLIGHT_COLOUR_DARK,
color: TEXT_COLOUR_DARK,
},
textInput: {
backgroundColor: HIGHLIGHT_COLOUR_DARK,
color: TEXT_COLOUR_DARK,
},
});

View File

@ -1,8 +1,11 @@
/// <reference path="../react-native-web.d.ts" />
import React, { useCallback, useEffect } from 'react';
import { Button, Image, StyleSheet, Text, useColorScheme, View } from 'react-native';
import { ActivityIndicator, Button, Image, StyleSheet, Text, TouchableOpacity, useColorScheme, View } from 'react-native';
import { CheckBox } from 'react-native-web';
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 { DEFAULT_ACCENT_COLOUR, 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';
@ -29,14 +32,29 @@ export default function Friend(props: FriendProps) {
useEffect(() => {
if (friends_state !== RequestState.LOADED) return;
const timeout = setTimeout(forceRefreshFriends, 60 * 1000);
const timeout = setTimeout(forceRefreshFriends, 40 * 1000);
return () => clearTimeout(timeout);
}, [ipc, token, friends_state]);
useEventListener(events, 'window:refresh', forceRefreshFriends, []);
useEffect(() => {
const handler = (event: KeyboardEvent) => event.key === 'Escape' && window.close();
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
if (friends && !friend) throw new Error('Unknown friend');
if (!user || !friend || discord_presence_source_state !== RequestState.LOADED) return null;
if (!user || !friend || discord_presence_source_state !== RequestState.LOADED) {
return <Root title={friend?.name} titleUser={user ?? undefined}
autoresize={!!user && discord_presence_source_state === RequestState.LOADED}
>
<View style={styles.loading}>
<ActivityIndicator size="large" color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
</Root>;
}
const discord_presence_active = discord_presence_source && 'na_id' in discord_presence_source &&
discord_presence_source.na_id === user.user.id &&
@ -44,7 +62,7 @@ export default function Friend(props: FriendProps) {
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>
return <Root title={friend.name} titleUser={user} autoresize>
<View style={styles.main}>
<View style={styles.friend}>
<Image source={{uri: friend.imageUri, width: 130, height: 130}} style={styles.friendImage} />
@ -60,24 +78,34 @@ export default function Friend(props: FriendProps) {
{(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>
<Text style={[styles.friendNsaId, theme.text]}>NSA ID: <Text style={styles.friendNsaIdValue}>{friend.nsaId}</Text></Text>
<Text style={[styles.friendCoralId, theme.text]}>{friend.isServiceUser ? <>
Coral user ID: <Text style={styles.friendCoralIdValue}>{friend.id}</Text>
</> : 'Never used Nintendo Switch Online app'}</Text>
<Text style={[styles.friendCreatedAt, theme.text]}>Friends since {new Date(friend.friendCreatedAt * 1000).toLocaleString('en-GB')}</Text>
{friend.presence.updatedAt ? <Text style={[styles.presenceUpdatedAt, theme.text]}>Presence updated at {new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB')}</Text> : null}
<Text style={[styles.canSeeUserPresence, 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}
{discord_presence_active || friend.presence.updatedAt ? <View style={styles.discord}>
<CheckBox
value={discord_presence_active ?? false}
onValueChange={v => ipc.setDiscordPresenceSource(v ?
{na_id: user.user.id, friend_nsa_id: friend.nsaId} : null)}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.discordCheckbox}
/>
<TouchableOpacity onPress={() => ipc.setDiscordPresenceSource(!discord_presence_active ?
{na_id: user.user.id, friend_nsa_id: friend.nsaId} : null)}>
<Text style={theme.text}>Share presence to Discord</Text>
</TouchableOpacity>
</View> : null}
<Button title="Close"
onPress={() => window.close()}
color={'#' + accent_colour} />
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
</View>
</View>
@ -124,8 +152,14 @@ function FriendPresenceGame(props: {
}
const styles = StyleSheet.create({
main: {
loading: {
flex: 1,
paddingVertical: 50,
paddingHorizontal: 20,
justifyContent: 'center',
},
main: {
paddingVertical: 20,
paddingHorizontal: 20,
flexDirection: 'row',
@ -157,11 +191,51 @@ const styles = StyleSheet.create({
flex: 1,
},
friendNsaId: {
fontSize: 13,
opacity: 0.7,
},
friendNsaIdValue: {
fontFamily: 'monospace',
userSelect: 'all',
},
friendCoralId: {
fontSize: 13,
opacity: 0.7,
},
friendCoralIdValue: {
fontFamily: 'monospace',
userSelect: 'all',
},
friendCreatedAt: {
marginTop: 8,
fontSize: 13,
opacity: 0.7,
},
presenceUpdatedAt: {
fontSize: 13,
opacity: 0.7,
},
canSeeUserPresence: {
marginTop: 8,
fontSize: 13,
opacity: 0.7,
},
buttons: {
marginTop: 20,
flexDirection: 'row',
justifyContent: 'flex-end',
},
discord: {
flex: 1,
marginRight: 20,
flexDirection: 'row',
alignItems: 'center',
},
discordCheckbox: {
marginRight: 10,
},
presenceText: {
marginTop: 8,

View File

@ -1,16 +1,27 @@
import { AppRegistry } from 'react-native';
import { config } from './ipc.js';
import App from './app.jsx';
import App from './main/index.js';
import Friend from './friend/index.js';
import DiscordSetup from './discord/index.js';
AppRegistry.registerComponent('App', () => App);
AppRegistry.registerComponent('Friend', () => Friend);
AppRegistry.registerComponent('DiscordPresence', () => DiscordSetup);
const style = window.document.createElement('style');
style.textContent = `
html {
:root {
user-select: none;
overflow-x: hidden;
}
*:focus-visible {
outline-style: solid;
outline-width: medium;
}
input,
input:focus-visible {
outline: none 0;
}
`;

View File

@ -0,0 +1,112 @@
import React, { useCallback, useMemo } from 'react';
import { Button, Image, StyleSheet, Text, View } from 'react-native';
import ipc, { events } from '../ipc.js';
import { getAccounts, RequestState, useAccentColour, useAsync, useColourScheme, useDiscordPresenceSource, useEventListener, User } from '../util.js';
import { Friend, PresencePermissions } from '../../../api/znc-types.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import Section from './section.js';
export default function SetupDiscordPresence(props: {
user: User;
friends: Friend[] | null;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
const [source, discord_presence_source_state] = useDiscordPresenceSource();
const [users, ,, forceRefreshAccounts] = useAsync(useCallback(() => getAccounts(), [ipc]));
useEventListener(events, 'update-nintendo-accounts', forceRefreshAccounts, []);
const added_friends = useMemo(() => users?.filter(u => u.nso && props.friends?.find(f => {
if (f.nsaId !== u.nso?.nsoAccount.user.nsaId) return false;
return props.user.nso!.nsoAccount.user.permissions.presence === PresencePermissions.FRIENDS ||
(props.user.nso!.nsoAccount.user.permissions.presence === PresencePermissions.FAVORITE_FRIENDS &&
f.isFavoriteFriend);
})), [users, props.friends]);
const auth_user = source && 'na_id' in source ? users?.find(u => u.user.id === source.na_id)?.nso : null;
const friend = source && 'na_id' in source && source.na_id === props.user.user.id && source.friend_nsa_id ?
props.friends?.find(f => f.nsaId === source.friend_nsa_id) : null;
if (!props.friends || discord_presence_source_state !== RequestState.LOADED || !users) return null;
const content = !source && added_friends?.length ? <>
<Text style={[styles.text, theme.text]}>
Use one of these accounts to set up Discord Rich Presence for this user:{' '}
{added_friends.map((u, i) => <React.Fragment key={u.user.id}>
{i === 0 ? '' : ', '}
<Image source={{uri: u.nso!.nsoAccount.user.imageUri, width: 16, height: 16}} style={styles.discordNsoUserImage} />{' '}
{u.nso!.nsoAccount.user.name}
{u.nso!.nsoAccount.user.name !== u.user.nickname ? ' (' + u.user.nickname + ')' : ''}
</React.Fragment>)}.
</Text>
</> :!source && users ? <>
<Text style={[styles.text, theme.text]}>Add a Nintendo Switch Online account with this user as a friend to set up Discord Rich Presence.</Text>
</> : source && 'na_id' in source && source.na_id === props.user.user.id && !source.friend_nsa_id ? <>
<Text style={[styles.text, theme.text]}>
This user's presence is being shared to Discord.
</Text>
</> : source && 'na_id' in source && source.na_id === props.user.user.id && friend ? <>
<Text style={[styles.text, theme.text]}>
<Image source={{uri: friend.imageUri, width: 16, height: 16}} style={styles.discordNsoUserImage} />{' '}
{friend.name}'s presence is being shared to Discord using this account.
</Text>
</> : source && 'na_id' in source && source.na_id === props.user.user.id && source.friend_nsa_id ? <>
<Text style={[styles.text, theme.text]}>
An unknown user's presence is being shared to Discord using this account.
</Text>
</> : source && 'na_id' in source && auth_user && source.friend_nsa_id && source.friend_nsa_id === props.user.nso?.nsoAccount.user.nsaId ? <>
<Text style={[styles.text, theme.text]}>
This user's presence is being shared to Discord using{' '}
<Image source={{uri: auth_user.nsoAccount.user.imageUri, width: 16, height: 16}} style={styles.discordNsoUserImage} />{' '}
{auth_user.nsoAccount.user.name}
{auth_user.nsoAccount.user.name !== auth_user.user.nickname ? ' (' + auth_user.user.nickname + ')' : ''}.
</Text>
</> : null;
return content ? <Section title="Discord Rich Presence">
<View style={styles.content}>
{content}
{source ? <View style={styles.button}>
<Button title="Disable" onPress={() => ipc.setDiscordPresenceSource(null)} color={'#' + accent_colour} />
</View> : null}
</View>
</Section> : null;
}
const styles = StyleSheet.create({
content: {
marginTop: -4,
paddingBottom: 16,
paddingHorizontal: 20,
},
text: {
fontSize: 13,
opacity: 0.7,
},
discordNsoUserImage: {
borderRadius: 8,
textAlignVertical: -3,
},
button: {
marginTop: 10,
flexDirection: 'row',
},
});
const light = StyleSheet.create({
text: {
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
text: {
color: TEXT_COLOUR_DARK,
},
});

View File

@ -1,8 +1,8 @@
import React, { useCallback, useEffect } from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { User } from 'discord-rpc';
import ipc from '../ipc.js';
import { RequestState, useAsync } from '../util.js';
import ipc, { events } from '../ipc.js';
import { RequestState, useAsync, useEventListener } 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';
@ -14,10 +14,12 @@ export default function DiscordPresenceSource(props: {
}) {
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>;
return <TouchableOpacity onPress={() => ipc.showDiscordModal()}>
<View style={[styles.discord, !props.source ? styles.discordInactive : null]}>
{renderDiscordPresenceSource(props.source)}
{props.presence && props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
</View>
</TouchableOpacity>;
}
function renderDiscordPresenceSource(source: DiscordPresenceSource | null) {
@ -35,20 +37,18 @@ function DiscordPresenceSourceZnc(props: {
}) {
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]);
useEventListener(events, 'window:refresh', forceRefreshFriends, []);
return <View style={styles.discordSource}>
{friend ? <Text style={styles.discordSourceText}>
Discord Rich Presence active:{' '}
@ -64,7 +64,10 @@ function DiscordPresenceSourceUrl(props: {
source: DiscordPresenceSourceUrl;
}) {
return <View style={styles.discordSource}>
<Text style={styles.discordSourceText}>Discord Rich Presence active: {props.source.url}</Text>
<Text style={styles.discordSourceText} numberOfLines={3} ellipsizeMode="tail">
Discord Rich Presence active:{' '}
<Text style={styles.discordSourceUrlValue}>{props.source.url}</Text>
</Text>
</View>;
}
@ -87,12 +90,12 @@ function DiscordPresence(props: {
return <>
<View style={styles.discordPresence}>
<Image source={{uri: large_image_url, width: 18, height: 18}} style={styles.discordPresenceImage} />
<Text style={styles.discordPresenceText}>Playing</Text>
<Text style={styles.discordPresenceText} numberOfLines={1} ellipsizeMode="tail">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>
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">{props.user.username}#{props.user.discriminator}</Text>
</View>
</>;
}
@ -116,9 +119,14 @@ const styles = StyleSheet.create({
borderRadius: 8,
textAlignVertical: -3,
},
discordSourceUrlValue: {
fontFamily: 'monospace',
fontSize: 12,
userSelect: 'all',
},
discordPresence: {
marginTop: 16,
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
},
@ -131,7 +139,7 @@ const styles = StyleSheet.create({
},
discordUser: {
marginTop: 12,
marginTop: 10,
flexDirection: 'row',
alignItems: 'center',
},

View File

@ -1,8 +1,7 @@
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 { useAccentColour, useColourScheme, User } from '../util.js';
import { ActiveEvent } from '../../../api/znc-types.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import Section from './section.js';

View File

@ -1,8 +1,7 @@
import React from 'react';
import { Image, ImageStyle, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import React, { useCallback } from 'react';
import { Image, ImageStyle, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import ipc from '../ipc.js';
import { useColourScheme } from '../util.js';
import { User } from '../app.js';
import { useColourScheme, User } from '../util.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';
@ -14,12 +13,20 @@ export default function Friends(props: {
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const fc = <Text style={styles.friendCodeValue}>SW-{props.user.nso!.nsoAccount.user.links.friendCode.id}</Text>;
const onFriendCodeContextMenu = useCallback(() => {
ipc.showFriendCodeMenu(props.user.nso!.nsoAccount.user.links.friendCode);
}, [ipc, props.user.nso?.nsoAccount.user.links.friendCode]);
const fc = <Text
style={styles.friendCodeValue}
// @ts-expect-error react-native-web
onContextMenu={onFriendCodeContextMenu}
>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} />)}
{props.friends.map(f => <Friend key={f.nsaId} friend={f} user={props.user} />)}
</View>
</ScrollView> : <View style={styles.noFriends}>
<Text style={[styles.noFriendsText, theme.text]}>Add friends using a Nintendo Switch console.</Text>
@ -34,10 +41,14 @@ export default function Friends(props: {
function Friend(props: {
friend: Friend;
user?: string;
user?: User;
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const onContextMenu = useCallback(() => {
ipc.showFriendMenu(props.user!.user, props.user!.nso!.nsoAccount.user, props.friend);
}, [ipc, props.user?.user, props.user?.nso?.nsoAccount.user]);
const game = 'name' in props.friend.presence.game ? props.friend.presence.game : null;
const content = <View style={styles.friend}>
@ -48,10 +59,17 @@ function Friend(props: {
{props.friend.presence.updatedAt ? <FriendPresence presence={props.friend.presence} /> : null}
</View>;
const touchable = props.user ? <TouchableOpacity onPress={() => ipc.showFriendModal(props.user!.user.id, props.friend.nsaId)}>
{content}
</TouchableOpacity> : content;
const contextmenu = Platform.OS === 'web' && props.user ? <View
// @ts-expect-error react-native-web
onContextMenu={onContextMenu}
>{touchable}</View> : touchable;
return <View style={styles.friendContainer}>
{props.user ? <TouchableOpacity onPress={() => ipc.showFriendModal(props.user!, props.friend.nsaId)}>
{content}
</TouchableOpacity> : content}
{contextmenu}
</View>;
}
@ -60,10 +78,8 @@ function FriendPresence(props: {
}) {
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, theme.text, styles.presenceTextOnline]}>Playing</Text>;
}
return <Text style={[styles.presenceText, styles.presenceTextOffline, theme.text]}>Offline</Text>;

View File

@ -1,126 +1,103 @@
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';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, ScrollView, StyleSheet, useColorScheme, View } from 'react-native';
import ipc, { events } from '../ipc.js';
import { AccentColourContext, getAccounts, Root, useAsync, useEventListener } from '../util.js';
import Sidebar from './sidebar.js';
import Update from './update.js';
import Main from './main.js';
import { BACKGROUND_COLOUR_MAIN_DARK, BACKGROUND_COLOUR_MAIN_LIGHT, BACKGROUND_COLOUR_SECONDARY_DARK, BACKGROUND_COLOUR_SECONDARY_LIGHT, 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>;
export interface AppProps {
vibrancy?: boolean;
insetTitleBarControls?: boolean;
}
function MoonOnlyUser() {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
export default function App(props: AppProps) {
const colour_scheme = useColorScheme();
const theme = colour_scheme === 'light' ? light : dark;
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>
const [users, ,, forceRefreshAccounts] = useAsync(useCallback(() => getAccounts(), [ipc]));
<View style={styles.moonOnlyUserButton}>
<Button title="Login" onPress={() => ipc.addNsoAccount()} color={'#' + accent_colour} />
useEventListener(events, 'update-nintendo-accounts', forceRefreshAccounts, []);
useEventListener(events, 'window:refresh', forceRefreshAccounts, []);
const [selectedUserId, setSelectedUserId] = useState<string | undefined>(undefined);
const selectedUser = useMemo(() => users?.find(u => u.user.id === selectedUserId), [users, selectedUserId]);
const [auto_refresh, setAutoRefresh] = useState<number | undefined>(undefined);
useEffect(() => {
if (!selectedUser) setSelectedUserId(users?.[0]?.user.id);
}, [users, selectedUser]);
return <Root
titleUser={selectedUser}
vibrancy={props.vibrancy}
style={[styles.app, !props.vibrancy ? theme.appNoVibrancy : null]}
>
<Sidebar users={users} selectedUser={selectedUserId} onSelectUser={setSelectedUserId}
insetTitleBarControls={props.insetTitleBarControls}
>
<View style={[styles.content, styles.button]}>
<AccentColourContext.Consumer children={accent_colour => (
<Button title={auto_refresh ? 'Disable auto refresh' : 'Enable auto refresh'}
onPress={() => setAutoRefresh(auto_refresh ? undefined : 30 * 1000)}
color={'#' + accent_colour} />
)} />
</View>
</Sidebar>
<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>
</Section>;
</Root>;
}
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,
app: {
flexDirection: 'row',
},
main: {
flex: 1,
},
scroller: {
flex: 1,
},
scrollerContent: {
flex: 1,
},
content: {
paddingVertical: 16,
paddingHorizontal: 20,
},
button: {
flexDirection: 'row',
justifyContent: 'center',
},
});
const light = StyleSheet.create({
appNoVibrancy: {
backgroundColor: BACKGROUND_COLOUR_SECONDARY_LIGHT,
},
main: {
backgroundColor: BACKGROUND_COLOUR_MAIN_LIGHT,
},
text: {
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
appNoVibrancy: {
backgroundColor: BACKGROUND_COLOUR_SECONDARY_DARK,
},
main: {
backgroundColor: BACKGROUND_COLOUR_MAIN_DARK,
},
text: {
color: TEXT_COLOUR_DARK,
},

View File

@ -0,0 +1,117 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Button, StyleSheet, Text, View } from 'react-native';
import ipc, { events } from '../ipc.js';
import { RequestState, useAccentColour, useAsync, useColourScheme, useEventListener, User } from '../util.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';
import SetupDiscordPresence from './discord-setup.js';
export default function Main(props: {
user: User;
autoRefresh?: number;
}) {
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]);
useEffect(() => {
if (loading || !props.autoRefresh) return;
const timeout = setTimeout(refresh, props.autoRefresh);
return () => clearTimeout(timeout);
}, [ipc, props.user.nsotoken, loading, props.autoRefresh]);
useEventListener(events, 'window:refresh', 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 ? <SetupDiscordPresence user={props.user} friends={friends} /> : 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>;
}
function MoonOnlyUser() {
const theme = useColourScheme() === 'light' ? light : dark;
const accent_colour = useAccentColour();
return <Section title="Nintendo Switch Online">
<View style={styles.moonOnlyUser}>
<Text style={[styles.moonOnlyUserText, theme.text]}>This user is signed in to the Nintendo Switch Parental Controls app, but not the Nintendo Switch Online app.</Text>
<Text style={[styles.moonOnlyUserText, theme.text]}>Login to the Nintendo Switch Online app to view details here, or use the nxapi command to access Parental Controls data.</Text>
<View style={styles.moonOnlyUserButton}>
<Button title="Login" onPress={() => ipc.addNsoAccount()} color={'#' + accent_colour} />
</View>
</View>
</Section>;
}
const styles = StyleSheet.create({
loading: {
flex: 1,
paddingVertical: 16,
paddingHorizontal: 20,
justifyContent: 'center',
},
container: {
paddingVertical: 16,
paddingHorizontal: 20,
},
moonOnlyUser: {
paddingVertical: 32,
paddingHorizontal: 20,
marginBottom: 20,
},
moonOnlyUserText: {
marginBottom: 10,
textAlign: 'center',
},
moonOnlyUserButton: {
marginTop: 10,
flexDirection: 'row',
justifyContent: 'center',
},
});
const light = StyleSheet.create({
text: {
color: TEXT_COLOUR_LIGHT,
},
});
const dark = StyleSheet.create({
text: {
color: TEXT_COLOUR_DARK,
},
});

View File

@ -1,8 +1,7 @@
import React from 'react';
import { Button, Image, ImageURISource, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import React, { useCallback } from 'react';
import { Image, ImageURISource, Platform, 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 { RequestState, useActiveDiscordPresence, useActiveDiscordUser, useColourScheme, useDiscordPresenceSource, User } from '../util.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';
@ -10,16 +9,17 @@ export default function Sidebar(props: {
users?: User[] | null;
selectedUser?: string;
onSelectUser?: (na_id: string) => void;
insetTitleBarControls?: boolean;
children?: React.ReactNode;
}) {
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]}>
<View style={[styles.top, theme.top, props.insetTitleBarControls ? styles.insetTitleBarControls : null]}>
<Text style={styles.topText}>nxapi</Text>
<Text style={styles.topText}>Nintendo Switch Online</Text>
</View>
@ -39,19 +39,17 @@ export default function Sidebar(props: {
/>)}
</View> : null}
{props.users ? <View style={styles.addUser}>
{props.users ? <TouchableOpacity onPress={() => ipc.showAddUserMenu()} 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}
</TouchableOpacity> : null}
{discord_presence_source_state === RequestState.LOADED && !discord_presence_source ? <TouchableOpacity
onPress={() => ipc.showDiscordModal()} style={styles.discordSetup}
>
<Text style={theme.text}>Set up Discord Rich Presence</Text>
</TouchableOpacity> : null}
{props.children}
</View>
</ScrollView>
</View>;
@ -64,6 +62,10 @@ function User(props: {
}) {
const theme = useColourScheme() === 'light' ? light : dark;
const onContextMenu = useCallback(() => {
ipc.showUserMenu(props.user.user, props.user.nso?.nsoAccount.user);
}, [ipc, props.user.user, props.user.nso?.nsoAccount.user]);
const miiImageSource: ImageURISource = props.user.user.mii ? {
uri: 'https://' + props.user.user.mii.imageOrigin + '/2.0.0/mii_images/' +
props.user.user.mii.id + '/' +
@ -77,7 +79,7 @@ function User(props: {
height: 32,
};
return <TouchableOpacity onPress={props.onPress}>
const touchable = <TouchableOpacity onPress={props.onPress}>
<View style={[styles.user, props.selected ? theme.userSelected : null]}>
<View style={styles.userMii}>
<Image source={miiImageSource} style={styles.userMiiImage} />
@ -96,6 +98,11 @@ function User(props: {
</View>
</View>
</TouchableOpacity>;
return Platform.OS === 'web' ? <View
// @ts-expect-error react-native-web
onContextMenu={onContextMenu}
>{touchable}</View> : touchable;
}
const styles = StyleSheet.create({
@ -110,6 +117,9 @@ const styles = StyleSheet.create({
paddingVertical: 28,
paddingHorizontal: 20,
},
insetTitleBarControls: {
paddingTop: 58,
},
topText: {
fontSize: 16,
fontWeight: '600',
@ -128,13 +138,20 @@ const styles = StyleSheet.create({
},
addUser: {
paddingVertical: 16,
marginTop: 10,
paddingVertical: 6,
paddingHorizontal: 20,
},
addUserButton: {
marginTop: 5,
},
discordSetup: {
marginTop: 10,
paddingVertical: 6,
paddingHorizontal: 20,
},
user: {
flexDirection: 'row',
paddingVertical: 8,

View File

@ -1,8 +1,7 @@
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 { useColourScheme, User } from '../util.js';
import { WebService } from '../../../api/znc-types.js';
import { TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import Section from './section.js';

89
src/app/browser/react-native-web.d.ts vendored Normal file
View File

@ -0,0 +1,89 @@
declare module 'react-native-web' {
export {};
import React from 'react';
import { ViewProps, ViewStyle } from 'react-native';
export interface CheckBoxProps extends ViewProps {
/**
* Set the background color of the checkbox.
* @default "#AAB8C2"
*/
color?: string;
/**
* Prevent all interactions with the checkbox.
*/
disabled?: boolean;
/**
* Called when the state of the native checkbox changes.
*/
onChange?: (e: ChangeEvent) => void;
/**
* Called when the state of the native checkbox changes.
*/
onValueChange?: (value: boolean | 'mixed') => void;
/**
* Set the value of the checkbox.
* @default false
*/
value?: boolean | 'mixed';
}
export class CheckBox extends React.Component<CheckBoxProps> {}
export interface PickerItemProps<T = number | string> {
/**
* Color of the item label. (Limited by browser support.)
*/
color?: string;
/**
* Text to display for this item.
*/
label?: string;
/**
* Used to locate this view in end-to-end tests.
*/
testID?: string;
/**
* The value to be passed to the pickers onValueChange callback when this item is selected.
*/
value?: T;
}
declare class PickerItem<T = number | string> extends React.Component<PickerItemProps<T>> {}
export interface PickerStyle extends ViewStyle {
color?: string;
}
export interface PickerProps<T = number | string> extends ViewProps {
/**
* The items to display in the picker must be of type Picker.Item.
*/
children?: React.ReactElement<PickerItemProps<T>, PickerItem> |
React.ReactElement<PickerItemProps<T>, PickerItem>[];
/**
* Determines if the picker will be disabled, i.e., the user will not be able to make a selection.
* @default true
*/
enabled?: boolean;
/**
* Callback for when an item is selected. This is called with the value and index prop of the item that was selected.
*/
onValueChange?: (value: string, index: number) => void;
/**
* Select the item with the matching value.
*/
selectedValue?: T;
/**
* Supported style properties.
*/
style?: StyleProp<PickerStyle>;
}
export class Picker<T = number | string> extends React.Component<PickerProps<T>> {
static Item = PickerItem<T>;
}
export namespace Picker {
type Item<T = number | string> = PickerItem<T>;
}
}

View File

@ -1,20 +1,25 @@
import { EventEmitter } from 'node:events';
import React, { useEffect } from 'react';
import { ColorSchemeName, Platform, StyleProp, StyleSheet, useColorScheme, View, ViewStyle } from 'react-native';
import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { ColorSchemeName, LayoutChangeEvent, 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 { NintendoAccountUser } from '../../api/na.js';
import { SavedToken } from '../../common/auth/nso.js';
import { SavedMoonToken } from '../../common/auth/moon.js';
import { BACKGROUND_COLOUR_MAIN_DARK, BACKGROUND_COLOUR_MAIN_LIGHT, DEFAULT_ACCENT_COLOUR } from './constants.js';
export function Root(props: React.PropsWithChildren<{
title?: string;
titleUser?: User | SavedToken;
style?: StyleProp<ViewStyle>;
scrollable?: boolean;
autoresize?: boolean;
vibrancy?: boolean;
}>) {
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, []);
@ -30,10 +35,37 @@ export function Root(props: React.PropsWithChildren<{
document.title = user_prefix + (props.title ?? 'Nintendo Switch Online');
}, [props.title, props.titleUser]);
const [preventingFocus, setPreventFocus] = useState(true);
const unlockFocus = useCallback(() => setPreventFocus(false), []);
useLayoutEffect(() => setPreventFocus(props.autoresize ?? true), [props.autoresize]);
const onLayout = useCallback(async (event: LayoutChangeEvent) => {
await ipc.setWindowHeight(event.nativeEvent.layout.height);
setPreventFocus(false);
}, []);
useEffect(() => {
document.documentElement.style.overflowY = props.scrollable ? 'auto' : 'hidden';
}, [props.scrollable]);
return <ColourSchemeContext.Provider value={colour_scheme}>
<AccentColourContext.Provider value={accent_colour}>
<View style={[props.scrollable ? styles.appScrollable : styles.app, props.style]}>
{props.children}
<AccentColourContext.Provider value={accent_colour ?? DEFAULT_ACCENT_COLOUR}>
<View style={[
props.scrollable ? styles.appScrollable : styles.app,
!props.vibrancy ? theme.appNoVibrancy : null,
props.style,
]}>
{props.autoresize && preventingFocus ? <View
key={'focuslock'}
focusable
// @ts-expect-error react-native-web
onFocus={unlockFocus}
/> : null}
{props.autoresize ? <View
key={'autoresize'}
onLayout={props.autoresize ? onLayout : undefined}
>{props.children}</View> : props.children}
</View>
</AccentColourContext.Provider>
</ColourSchemeContext.Provider>;
@ -48,6 +80,18 @@ const styles = StyleSheet.create({
},
});
const light = StyleSheet.create({
appNoVibrancy: {
backgroundColor: BACKGROUND_COLOUR_MAIN_LIGHT,
},
});
const dark = StyleSheet.create({
appNoVibrancy: {
backgroundColor: BACKGROUND_COLOUR_MAIN_DARK,
},
});
export enum RequestState {
NOT_LOADING,
LOADING,
@ -120,7 +164,7 @@ export function useEventListener<
}, deps);
}
export const AccentColourContext = React.createContext<string | null>('E60012FF');
export const AccentColourContext = React.createContext<string>(DEFAULT_ACCENT_COLOUR);
export function useAccentColour() {
return React.useContext(AccentColourContext);
@ -132,6 +176,34 @@ export function useColourScheme() {
return React.useContext(ColourSchemeContext);
}
export interface User {
user: NintendoAccountUser;
nso: SavedToken | null;
nsotoken: string | undefined;
moon: SavedMoonToken | null;
moontoken: string | undefined;
}
export async function getAccounts() {
const ids = await ipc.listNintendoAccounts();
const accounts: User[] = [];
for (const id of ids ?? []) {
const nsotoken = await ipc.getNintendoAccountNsoToken(id);
const moontoken = await ipc.getNintendoAccountMoonToken(id);
const nso = nsotoken ? await ipc.getSavedNsoToken(nsotoken) ?? null : null;
const moon = moontoken ? await ipc.getSavedMoonToken(moontoken) ?? null : null;
if (!nso && !moon) continue;
accounts.push({user: nso?.user ?? moon!.user, nso, nsotoken, moon, moontoken});
}
return accounts;
}
export function useDiscordPresenceSource() {
const [source, , state, forceRefresh] = useAsync(React.useCallback(() => ipc.getDiscordPresenceSource(), [ipc]));
useEventListener(events, 'update-discord-presence-source', forceRefresh, []);

View File

@ -1,11 +1,13 @@
export enum WindowType {
MAIN_WINDOW = 'App',
FRIEND = 'Friend',
DISCORD_PRESENCE = 'DiscordPresence',
}
interface WindowProps {
[WindowType.MAIN_WINDOW]: import('../browser/app.js').AppProps;
[WindowType.MAIN_WINDOW]: import('../browser/main/index.js').AppProps;
[WindowType.FRIEND]: import('../browser/friend/index.js').FriendProps;
[WindowType.DISCORD_PRESENCE]: import('../browser/discord/index.js').DiscordSetupProps;
}
export interface WindowConfiguration<T extends WindowType = WindowType> {

92
src/app/main/app-menu.tsx Normal file
View File

@ -0,0 +1,92 @@
import { GITHUB_MIRROR_URL, GITLAB_URL, ISSUES_URL } from '../../common/constants.js';
import { BrowserWindow, Menu, MenuItem, shell } from './electron.js';
const menu_app = new MenuItem({role: 'appMenu'});
const menu_file = new MenuItem({role: 'fileMenu'});
const menu_edit = new MenuItem({role: 'editMenu'});
const menu_view = new MenuItem({
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
});
const menu_window = new MenuItem({role: 'windowMenu'});
const menu_help = new MenuItem({
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
await shell.openExternal(GITLAB_URL);
},
},
{
label: 'Learn More (GitHub)',
click: async () => {
await shell.openExternal(GITHUB_MIRROR_URL);
},
},
{
label: 'Search Issues',
click: async () => {
await shell.openExternal(ISSUES_URL);
},
},
],
});
export const app_menu = Menu.buildFromTemplate([
...(process.platform === 'darwin' ? [menu_app] : []),
menu_file,
menu_edit,
menu_view,
menu_window,
menu_help,
]);
export function createWindowMenu(window: BrowserWindow) {
const menu_view = new MenuItem({
label: 'View',
submenu: [
{
label: 'Refresh',
click: (menuItem, browserWindow, event) => {
if (browserWindow === window) {
browserWindow.webContents.send('nxapi:window:refresh');
} else {
browserWindow?.webContents.reload();
}
},
accelerator: 'CommandOrControl+R',
},
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
});
return Menu.buildFromTemplate([
...(process.platform === 'darwin' ? [menu_app] : []),
menu_file,
menu_edit,
menu_view,
menu_window,
menu_help,
]);
}

View File

@ -5,6 +5,7 @@ const electron = require('electron');
export const app = electron.app;
export const BrowserWindow = electron.BrowserWindow;
export const clipboard = electron.clipboard;
export const dialog = electron.dialog;
export const ipcMain = electron.ipcMain;
export const Menu = electron.Menu;

View File

@ -15,6 +15,8 @@ import { initStorage, paths } from '../../util/storage.js';
import { checkUpdates, UpdateCacheData } from '../../common/update.js';
import Users, { CoralUser } from '../../common/users.js';
import { setupIpc } from './ipc.js';
import { version } from '../../util/product.js';
import { GITLAB_URL } from '../../common/constants.js';
const debug = createDebug('app:main');
@ -38,10 +40,14 @@ export class App {
return this.main_window;
}
const window = createWindow(WindowType.MAIN_WINDOW, {}, {
const window = createWindow(WindowType.MAIN_WINDOW, {
vibrancy: process.platform === 'darwin',
// insetTitleBarControls: process.platform === 'darwin',
}, {
minWidth: 500,
minHeight: 300,
vibrancy: 'under-window',
vibrancy: process.platform === 'darwin' ? 'under-window' : undefined,
// titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
webPreferences: {
scrollBounce: false,
},

View File

@ -1,14 +1,16 @@
import { BrowserWindow, dialog, IpcMain, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
import { BrowserWindow, clipboard, dialog, IpcMain, Menu, MenuItem, 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 { CurrentUser, Friend, Game, PresenceState, 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';
import { NintendoAccountUser } from '../../api/na.js';
import { hrduration } from '../../util/misc.js';
const debug = createDebug('app:main:ipc');
@ -18,15 +20,15 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
ipcMain.on('nxapi:browser:getwindowdata', e => e.returnValue = getWindowConfiguration(e.sender));
let accent_colour = systemPreferences.getAccentColor();
let accent_colour = systemPreferences.getAccentColor?.() || undefined;
ipcMain.on('nxapi:systemPreferences:accent-colour', e => e.returnValue = accent_colour);
systemPreferences.subscribeLocalNotification('NSSystemColorsDidChangeNotification', (event, userInfo, object) => {
accent_colour = systemPreferences.getAccentColor();
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();
accent_colour = new_colour ?? systemPreferences.getAccentColor?.();
sendToAllWindows('nxapi:systemPreferences:accent-colour', accent_colour);
});
@ -73,9 +75,38 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
}, {
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
modal: true,
show: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
}).id);
ipcMain.handle('nxapi:window:discord', e => createWindow(WindowType.DISCORD_PRESENCE, {}, {
parent: BrowserWindow.fromWebContents(e.sender) ?? undefined,
modal: true,
show: false,
width: 560,
height: 300,
minWidth: 450,
maxWidth: 700,
minHeight: 300,
maxHeight: 300,
}).id);
ipcMain.handle('nxapi:window:setheight', (e, height: number) => {
const window = BrowserWindow.fromWebContents(e.sender)!;
const [curWidth, curHeight] = window.getSize();
const [curContentWidth, curContentHeight] = window.getContentSize();
const [minWidth, minHeight] = window.getMinimumSize();
const [maxWidth, maxHeight] = window.getMaximumSize();
if (height !== curContentHeight && curHeight === minHeight && curHeight === maxHeight) {
window.setMinimumSize(minWidth, height + (curHeight - curContentHeight));
window.setMaximumSize(maxWidth, height + (curHeight - curContentHeight));
}
window.setContentSize(curContentWidth, height);
window.show();
});
ipcMain.handle('nxapi:discord:source', () => appinstance.monitors.getDiscordPresenceSource());
ipcMain.handle('nxapi:discord:setsource', (e, source: DiscordPresenceSource | null) => appinstance.monitors.setDiscordPresenceSource(source));
@ -89,6 +120,31 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
ipcMain.handle('nxapi:misc:share', (e, item: SharingItem) =>
new ShareMenu(item).popup({window: BrowserWindow.fromWebContents(e.sender)!}));
ipcMain.handle('nxapi:menu:user', (e, user: NintendoAccountUser, nso?: CurrentUser, moon?: boolean) => (Menu.buildFromTemplate([
new MenuItem({label: 'Nintendo Account ID: ' + user.id, enabled: false}),
...(nso ? [
new MenuItem({label: 'Coral ID: ' + nso.id, enabled: false}),
new MenuItem({label: 'NSA ID: ' + nso.nsaId, enabled: false}),
] : []),
new MenuItem({type: 'separator'}),
new MenuItem({label: 'Use the nxapi command to remove this user', enabled: false}),
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:add-user', e => (Menu.buildFromTemplate([
new MenuItem({label: 'Add Nintendo Switch Online account', click: () => addNsoAccount(storage)}),
new MenuItem({label: 'Add Nintendo Switch Parental Controls account', click: () => addPctlAccount(storage)}),
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:friend-code', (e, fc: CurrentUser['links']['friendCode']) => (Menu.buildFromTemplate([
new MenuItem({label: 'SW-' + fc.id, enabled: false}),
new MenuItem({label: 'Share', role: 'shareMenu', sharingItem: {texts: ['SW-' + fc.id]}}),
new MenuItem({label: 'Copy', click: () => clipboard.writeText('SW-' + fc.id)}),
new MenuItem({type: 'separator'}),
new MenuItem({label: fc.regenerable ? 'Regenerate using a Nintendo Switch console' :
'Can be regenerated at ' + new Date(fc.regenerableAt * 1000).toLocaleString('en-GB'), enabled: false}),
]).popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
ipcMain.handle('nxapi:menu:friend', (e, user: NintendoAccountUser, nso: CurrentUser, friend: Friend) =>
(buildFriendMenu(appinstance, user, nso, friend)
.popup({window: BrowserWindow.fromWebContents(e.sender)!}), undefined));
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));
@ -108,3 +164,44 @@ function sendToAllWindows(channel: string, ...args: any[]) {
window.webContents.send(channel, ...args);
}
}
function buildFriendMenu(app: App, user: NintendoAccountUser, nso: CurrentUser, friend: Friend) {
const discord_presence_source = app.monitors.getDiscordPresenceSource();
const discord_presence_active = !!discord_presence_source && 'na_id' in discord_presence_source &&
discord_presence_source.na_id === user.id && discord_presence_source.friend_nsa_id === friend.nsaId;
return Menu.buildFromTemplate([
...(!friend.presence.updatedAt ? [
] : friend.presence.state === PresenceState.ONLINE || friend.presence.state === PresenceState.PLAYING ? [
new MenuItem({label: 'Online', enabled: false}),
...('name' in friend.presence.game ? [
new MenuItem({label: friend.presence.game.name, click: () =>
shell.openExternal((friend.presence.game as Game).shopUri)}),
...(friend.presence.game.sysDescription ? [
new MenuItem({label: friend.presence.game.sysDescription, enabled: false}),
] : []),
new MenuItem({label: 'First played: ' + new Date(friend.presence.game.firstPlayedAt * 1000).toLocaleString('en-GB'), enabled: false}),
new MenuItem({label: 'Play time: ' + hrduration(friend.presence.game.totalPlayTime), enabled: false}),
] : []),
new MenuItem({label: 'Updated: ' + new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB'), enabled: false}),
new MenuItem({type: 'separator'}),
] : friend.presence.state === PresenceState.INACTIVE ? [
new MenuItem({label: 'Offline (console online)', enabled: false}),
...(friend.presence.logoutAt ? [
new MenuItem({label: 'Logout time: ' + new Date(friend.presence.logoutAt * 1000).toLocaleString('en-GB'), enabled: false}),
] : []),
new MenuItem({label: 'Updated: ' + new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB'), enabled: false}),
new MenuItem({type: 'separator'}),
] : [
new MenuItem({label: 'Offline', enabled: false}),
...(friend.presence.logoutAt ? [
new MenuItem({label: 'Logout time: ' + new Date(friend.presence.logoutAt * 1000).toLocaleString('en-GB'), enabled: false}),
] : []),
new MenuItem({label: 'Updated: ' + new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB'), enabled: false}),
new MenuItem({type: 'separator'}),
]),
new MenuItem({label: 'Enable Discord Presence', type: 'checkbox', checked: discord_presence_active, click: () =>
app.monitors.setDiscordPresenceSource(discord_presence_active ? null :
{na_id: user.id, friend_nsa_id: friend.nsaId})}),
]);
}

View File

@ -49,6 +49,7 @@ export default class MenuApp {
label: data.nsoAccount.user.name,
submenu: [
{label: 'Nintendo Account ID: ' + data.user.id, enabled: false},
{label: 'Coral ID: ' + data.nsoAccount.user.id, enabled: false},
{label: 'NSA ID: ' + data.nsoAccount.user.nsaId, enabled: false},
{type: 'separator'},
{label: 'Enable Discord Presence', type: 'checkbox', checked: discord_presence_active,

View File

@ -42,6 +42,12 @@ export function getAuthUrl(client_id: string, scope: string | string[]) {
};
}
const css = `
html {
overflow-x: hidden;
}
`;
let i = 0;
export function createAuthWindow() {
@ -60,6 +66,10 @@ export function createAuthWindow() {
},
});
window.webContents.on('did-finish-load', () => {
window.webContents.insertCSS(css);
});
return window;
}

View File

@ -3,7 +3,7 @@ import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import { Buffer } from 'node:buffer';
import createDebug from 'debug';
import { app, BrowserWindow, dialog, IpcMainInvokeEvent, Menu, MenuItem, session, ShareMenu, shell, WebContents } from './electron.js';
import { app, BrowserWindow, dialog, IpcMainInvokeEvent, Menu, MenuItem, ShareMenu, shell, WebContents } from './electron.js';
import fetch from 'node-fetch';
import ZncApi from '../../api/znc.js';
import { dev } from '../../util/product.js';
@ -11,32 +11,10 @@ import { WebService } from '../../api/znc-types.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';
import { createWebServiceWindow } from './windows.js';
const debug = createDebug('app:main:webservices');
export function createWebServiceWindow(nsa_id: string, webservice: WebService, title_prefix?: string) {
const browser_session = session.fromPartition('persist:webservices-' + nsa_id, {
cache: false,
});
const window = new BrowserWindow({
width: 375,
height: 667,
resizable: false,
title: (title_prefix ?? '') + webservice.name,
webPreferences: {
session: browser_session,
preload: path.join(bundlepath, 'preload-webservice.cjs'),
contextIsolation: false,
scrollBounce: true,
},
});
return window;
}
const windows = new Map<string, BrowserWindow>();
const windowapi = new WeakMap<WebContents, [Store, string, ZncApi, SavedToken, WebService]>();

View File

@ -1,10 +1,21 @@
import { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from './electron.js';
import { app, BrowserWindow, BrowserWindowConstructorOptions, Menu, session, WebContents } from './electron.js';
import * as path from 'node:path';
import { dev } from '../../util/product.js';
import { WindowConfiguration, WindowType } from '../common/types.js';
import { bundlepath } from './util.js';
import { app_menu, createWindowMenu } from './app-menu.js';
import { WebService } from '../../api/znc-types.js';
const windows = new Map<WebContents, WindowConfiguration>();
const menus = new Map<BrowserWindow, Menu>();
app.on('browser-window-focus', (event, window) => {
Menu.setApplicationMenu(menus.get(window) ?? app_menu);
});
app.on('browser-window-blur', (event, window) => {
if (!BrowserWindow.getFocusedWindow()) {
Menu.setApplicationMenu(app_menu);
}
});
export function createWindow<T extends WindowType>(
type: T, props: WindowConfiguration<T>['props'],
@ -15,6 +26,7 @@ export function createWindow<T extends WindowType>(
width: 800,
height: 600,
vibrancy: 'content',
autoHideMenuBar: true,
...options,
webPreferences: {
preload: path.join(bundlepath, 'preload.cjs'),
@ -29,6 +41,12 @@ export function createWindow<T extends WindowType>(
};
windows.set(window.webContents, data);
menus.set(window, createWindowMenu(window));
window.on('closed', () => {
windows.delete(window.webContents);
menus.delete(window);
});
window.loadFile(path.join(bundlepath, 'index.html'));
if (dev) window.webContents.openDevTools();
@ -45,3 +63,30 @@ export function getWindowConfiguration(webcontents: WebContents): WindowConfigur
return data;
}
export function createWebServiceWindow(nsa_id: string, webservice: WebService, title_prefix?: string) {
const browser_session = session.fromPartition('persist:webservices-' + nsa_id, {
cache: false,
});
const window = new BrowserWindow({
width: 375,
height: 667,
resizable: false,
title: (title_prefix ?? '') + webservice.name,
webPreferences: {
session: browser_session,
preload: path.join(bundlepath, 'preload-webservice.cjs'),
contextIsolation: false,
scrollBounce: true,
},
});
menus.set(window, createWindowMenu(window));
window.on('closed', () => {
menus.delete(window);
});
return window;
}

View File

@ -4,4 +4,19 @@ import createDebug from 'debug';
const debug = createDebug('app:preload-webservice');
import './znca-js-api.js';
import './quirks/splatnet2.js';
import './quirks/nooklink.js';
const style = window.document.createElement('style');
style.textContent = `
*:focus-visible {
outline-style: solid;
outline-width: medium;
}
`;
document.addEventListener('DOMContentLoaded', () => {
(document.scrollingElement as HTMLElement).style.overflowX = 'hidden';
window.document.head.appendChild(style);
});

View File

@ -1,9 +1,12 @@
import { ipcRenderer } from 'electron';
import { EventEmitter } from 'events';
import createDebug from 'debug';
import { WebServiceData } from '../main/webservices.js';
const debug = createDebug('app:preload-webservice:ipc');
export const events = new EventEmitter();
const ipc = {
getWebServiceSync: () => ipcRenderer.sendSync('nxapi:webserviceapi:getWebServiceSync') as WebServiceData,
invokeNativeShare: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:invokeNativeShare', data) as Promise<void>,
@ -19,3 +22,5 @@ export const {webservice, url: webserviceurl} = ipc.getWebServiceSync();
debug('Web service', webservice);
debug('Web service URL', webserviceurl);
ipcRenderer.on('nxapi:window:refresh', () => events.emit('window:refresh') || location.reload());

View File

@ -0,0 +1,18 @@
import createDebug from 'debug';
import { events, webservice } from '../ipc.js';
const debug = createDebug('app:preload-webservice:quirks:splatnet2');
const SPLATNET2_WEBSERVICE_ID = 5741031244955648;
if (webservice.id === SPLATNET2_WEBSERVICE_ID) {
events.on('window:refresh', () => {
const refresh_button = document.querySelector<HTMLElement>('.refresh-button');
if (refresh_button) {
refresh_button.click();
} else {
location.reload();
}
});
}

View File

@ -1,14 +1,16 @@
import { contextBridge, ipcRenderer } from 'electron';
import * as process from 'node:process';
import { EventEmitter } from 'node:events';
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 { Announcements, CurrentUser, 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';
import { NintendoAccountUser } from '../../api/na.js';
const debug = createDebug('app:preload');
@ -47,10 +49,17 @@ const ipc = {
getSavedMoonToken: (token: string) => inv<SavedMoonToken | undefined>('moon:getcachedtoken', token),
showFriendModal: (na_id: string, nsa_id: string) => inv<number>('window:showfriend', na_id, nsa_id),
showDiscordModal: () => inv<number>('window:discord'),
setWindowHeight: (height: number) => inv('window:setheight', height),
openExternalUrl: (url: string) => inv('misc:open-url', url),
share: (item: SharingItem) => inv('misc:share', item),
showUserMenu: (user: NintendoAccountUser, nso?: CurrentUser, moon?: boolean) => inv('menu:user', user, nso, moon),
showAddUserMenu: () => inv('menu:add-user'),
showFriendCodeMenu: (fc: CurrentUser['links']['friendCode']) => inv('menu:friend-code', fc),
showFriendMenu: (user: NintendoAccountUser, nso: CurrentUser, friend: Friend) => inv('menu:friend', user, nso, friend),
registerEventListener: (event: string, listener: (args: any[]) => void) => events.on(event, listener),
removeEventListener: (event: string, listener: (args: any[]) => void) => events.removeListener(event, listener),
@ -59,12 +68,13 @@ const ipc = {
export type NxapiElectronIpc = typeof ipc;
ipcRenderer.on('nxapi:window:refresh', () => events.emit('window:refresh') || location.reload());
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');
let accent_colour: string | undefined = invSync('systemPreferences:accent-colour');
ipcRenderer.on('nxapi:systemPreferences:accent-colour', (event, c) => {
accent_colour = c;
events.emit('systemPreferences:accent-colour', c);

3
src/common/constants.ts Normal file
View File

@ -0,0 +1,3 @@
export const GITLAB_URL = 'https://gitlab.fancy.org.uk/samuel/nxapi';
export const GITHUB_MIRROR_URL = 'https://github.com/samuelthomas2774/nxapi';
export const ISSUES_URL = 'https://github.com/samuelthomas2774/nxapi/issues';

View File

@ -10,6 +10,7 @@
"importHelpers": true,
"rootDir": "src",
"outDir": "dist",
"sourceMap": true,
"lib": [
"es2020"
],