From 60c0e592db464eafc2f6fef11e9f02eb165fe909 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Sun, 5 Jun 2022 15:02:24 +0100 Subject: [PATCH] Electron app --- .vscode/launch.json | 3 + rollup.config.js | 12 + src/api/na.ts | 34 +-- src/api/znc-types.ts | 26 ++ src/app/browser/app.tsx | 82 +++++-- src/app/browser/constants.ts | 18 ++ src/app/browser/friend/index.tsx | 226 ++++++++++++++++++ src/app/browser/index.ts | 12 + src/app/browser/ipc.ts | 18 +- src/app/browser/main/discord.tsx | 145 +++++++++++ src/app/browser/main/event.tsx | 87 +++++++ src/app/browser/main/friends.tsx | 145 +++++++++++ src/app/browser/main/index.tsx | 127 ++++++++++ src/app/browser/main/section.tsx | 57 +++++ src/app/browser/main/sidebar.tsx | 199 ++++++++++++++++ src/app/browser/main/update.tsx | 48 ++++ src/app/browser/main/webservices.tsx | 79 ++++++ src/app/browser/util.ts | 60 ----- src/app/browser/util.tsx | 161 +++++++++++++ src/app/common/types.ts | 15 +- src/app/main/electron.ts | 4 + src/app/main/index.ts | 335 +++++++++----------------- src/app/main/ipc.ts | 110 +++++++++ src/app/main/menu.ts | 87 ++++--- src/app/main/monitor.ts | 344 +++++++++++++++++++++++++++ src/app/main/util.ts | 4 + src/app/main/webservices.ts | 123 +++++++++- src/app/main/windows.ts | 11 +- src/app/preload/index.ts | 72 +++++- src/cli.ts | 2 +- src/common/notify.ts | 20 +- src/common/presence.ts | 252 +++++++++++++------- src/{cli => common}/update.ts | 10 +- src/common/users.tsx | 146 ++++++++++++ src/discord/util.ts | 4 +- 35 files changed, 2604 insertions(+), 474 deletions(-) create mode 100644 src/app/browser/constants.ts create mode 100644 src/app/browser/friend/index.tsx create mode 100644 src/app/browser/main/discord.tsx create mode 100644 src/app/browser/main/event.tsx create mode 100644 src/app/browser/main/friends.tsx create mode 100644 src/app/browser/main/index.tsx create mode 100644 src/app/browser/main/section.tsx create mode 100644 src/app/browser/main/sidebar.tsx create mode 100644 src/app/browser/main/update.tsx create mode 100644 src/app/browser/main/webservices.tsx delete mode 100644 src/app/browser/util.ts create mode 100644 src/app/browser/util.tsx create mode 100644 src/app/main/ipc.ts create mode 100644 src/app/main/monitor.ts rename src/{cli => common}/update.ts (94%) create mode 100644 src/common/users.tsx diff --git a/.vscode/launch.json b/.vscode/launch.json index b12032a..376b213 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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" }, diff --git a/rollup.config.js b/rollup.config.js index 54e7f37..fd81a1e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -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 [ diff --git a/src/api/na.ts b/src/api/na.ts index 20eaede..f29dc53 100644 --- a/src/api/na.ts +++ b/src/api/na.ts @@ -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; diff --git a/src/api/znc-types.ts b/src/api/znc-types.ts index eab4081..9fbe439 100644 --- a/src/api/znc-types.ts +++ b/src/api/znc-types.ts @@ -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 = ZncSuccessResponse | 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 | {}; diff --git a/src/app/browser/app.tsx b/src/app/browser/app.tsx index dd63d33..958237c 100644 --- a/src/app/browser/app.tsx +++ b/src/app/browser/app.tsx @@ -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 - Hello from React! + const [selectedUserId, setSelectedUserId] = useState(undefined); + const selectedUser = useMemo(() => users?.find(u => u.user.id === selectedUserId), [users, selectedUserId]); - {users?.map(u => - {u.user.id} - {u.user.nickname} - )} - ; + useEffect(() => { + if (!selectedUser) setSelectedUserId(users?.[0]?.user.id); + }, [users, selectedUser]); + + return + + + + + + + {selectedUser ?
: null} + + + ; } 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, }, }); diff --git a/src/app/browser/constants.ts b/src/app/browser/constants.ts new file mode 100644 index 0000000..ef972a9 --- /dev/null +++ b/src/app/browser/constants.ts @@ -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'; diff --git a/src/app/browser/friend/index.tsx b/src/app/browser/friend/index.tsx new file mode 100644 index 0000000..159068a --- /dev/null +++ b/src/app/browser/friend/index.tsx @@ -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 + + + + {friend.name} + + {friend.presence.updatedAt ? + : + You don't have access to this user's presence, or they have never been online.} + + + + + {(friend.presence.state === PresenceState.ONLINE || friend.presence.state === PresenceState.PLAYING) && + 'name' in friend.presence.game ? : null} + + NSA ID: {friend.nsaId} + {friend.isServiceUser ? 'Coral user ID: ' + friend.id : 'Never used Nintendo Switch Online app'} + Friends since {new Date(friend.friendCreatedAt * 1000).toLocaleString('en-GB')} + {friend.presence.updatedAt ? Presence updated at {new Date(friend.presence.updatedAt * 1000).toLocaleString('en-GB')} : null} + This user {can_see_user_presence ? 'can' : 'can not'} see your presence. + + + + {discord_presence_active ?