Add preferences window

This commit is contained in:
Samuel Elliott 2022-09-07 15:54:38 +01:00
parent 87071dd5c3
commit 8afcd80f5a
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
23 changed files with 622 additions and 142 deletions

View File

@ -4,11 +4,13 @@ import App from './main/index.js';
import Friend from './friend/index.js';
import DiscordSetup from './discord/index.js';
import AddFriend from './add-friend/index.js';
import Preferences from './preferences/index.js';
AppRegistry.registerComponent('App', () => App);
AppRegistry.registerComponent('Friend', () => Friend);
AppRegistry.registerComponent('DiscordPresence', () => DiscordSetup);
AppRegistry.registerComponent('AddFriend', () => AddFriend);
AppRegistry.registerComponent('Preferences', () => Preferences);
const style = window.document.createElement('style');

View File

@ -4,7 +4,7 @@ import { User } from 'discord-rpc';
import ipc, { events } from '../ipc.js';
import { RequestState, useAsync, useEventListener } from '../util.js';
import { DiscordPresenceSource, DiscordPresenceSourceUrl, DiscordPresenceSourceCoral } from '../../common/types.js';
import { DiscordPresence } from '../../../discord/util.js';
import { DiscordPresence } from '../../../discord/types.js';
import { DISCORD_COLOUR, TEXT_COLOUR_DARK } from '../constants.js';
export default function DiscordPresenceSource(props: {

View File

@ -0,0 +1,353 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { StyleSheet, Text, TextInput, TouchableHighlight, TouchableOpacity, useColorScheme, View } from 'react-native';
import { CheckBox, Picker } from 'react-native-web';
import { DiscordPresencePlayTime } from '../../../discord/types.js';
import { Button } from '../components/index.js';
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 } from '../util.js';
export interface PreferencesProps {}
export default function Preferences(props: PreferencesProps) {
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 [login_item, ,, forceRefreshLoginItem] = useAsync(useCallback(() => ipc.getLoginItemSettings(), [ipc]));
const setOpenAtLogin = useCallback(async (open_at_login: boolean | 'mixed') => {
await ipc.setLoginItemSettings({...login_item, openAtLogin: !!open_at_login});
forceRefreshLoginItem();
}, [ipc, login_item]);
const setOpenAsHidden = useCallback(async (open_as_hidden: boolean | 'mixed') => {
await ipc.setLoginItemSettings({...login_item, openAsHidden: !!open_as_hidden});
forceRefreshLoginItem();
}, [ipc, login_item]);
const [discord_users, discord_users_error, discord_users_state, forceRefreshDiscordUsers] =
useAsync(useCallback(() => ipc.getDiscordUsers(), [ipc]));
const [discord_options, , discord_options_state, forceRefreshDiscordOptions] =
useAsync(useCallback(() => ipc.getSavedDiscordPresenceOptions(), [ipc]));
const [discord_friend_code, setDiscordFriendCodeValue] = useState(discord_options?.friend_code ?? '');
const discord_friend_code_valid = !discord_friend_code || discord_friend_code.match(/^\d{4}-\d{4}-\d{4}$/);
useEffect(() => {
setDiscordFriendCodeValue(discord_options?.friend_code ?? '');
}, [discord_options]);
const setDiscordUser = useCallback(async (user: string | undefined) => {
if (user === '*') user = undefined;
await ipc.setDiscordPresenceOptions({...discord_options, user});
forceRefreshDiscordOptions();
}, [ipc, discord_options]);
const setDiscordFriendCode = useCallback(async (friend_code: string | undefined) => {
setDiscordFriendCodeValue(friend_code ?? '');
if (friend_code && !friend_code.match(/^\d{4}-\d{4}-\d{4}$/)) return;
if (!friend_code) friend_code = undefined;
await ipc.setDiscordPresenceOptions({...discord_options, friend_code});
forceRefreshDiscordOptions();
}, [ipc, discord_options]);
const setDiscordShowConsoleOnline = useCallback(async (show_console_online: boolean | 'mixed') => {
await ipc.setDiscordPresenceOptions({...discord_options, show_console_online: !!show_console_online});
forceRefreshDiscordOptions();
}, [ipc, discord_options]);
const setDiscordShowPlayTime = useCallback(async (show_play_time: DiscordPresencePlayTime) => {
await ipc.setDiscordPresenceOptions({...discord_options, show_play_time});
forceRefreshDiscordOptions();
}, [ipc, discord_options]);
const [discord_presence_source, discord_presence_source_state] = useDiscordPresenceSource();
const discord_presence_source_user = discord_presence_source && 'na_id' in discord_presence_source ?
discord_presence_source.friend_nsa_id ?
users?.find(u => u.nso?.nsoAccount.user.nsaId === discord_presence_source.friend_nsa_id)?.nso :
users?.find(u => u.nso?.user.id === discord_presence_source.na_id)?.nso :
null;
const discord_friend_code_self = discord_presence_source_user?.nsoAccount.user.links.friendCode.id;
const [is_discord_friend_code_self, setIsDiscordFriendCodeSelf] = useState(false);
useEffect(() => {
setIsDiscordFriendCodeSelf(!!discord_presence_source && (!discord_friend_code || discord_friend_code === discord_friend_code_self));
}, [discord_presence_source, discord_friend_code_self]);
useEventListener(events, 'window:refresh', () => (
forceRefreshAccounts(), forceRefreshLoginItem(),
forceRefreshDiscordUsers(), forceRefreshDiscordOptions()
), []);
if (!users ||
!login_item ||
!discord_options ||
discord_presence_source_state !== RequestState.LOADED
) {
return null;
}
const discord_user_picker = [<Picker.Item key="*" label="First discovered" value="*" />];
if (discord_options?.user && !discord_users?.find(u => u.id === discord_options.user)) {
discord_user_picker.push(<Picker.Item key={discord_options?.user} label={discord_options.user}
value={discord_options.user} />);
}
for (const user of discord_users ?? []) {
discord_user_picker.push(<Picker.Item key={user.id} label={user.username + '#' + user.discriminator}
value={user.id} />);
}
return <Root title="Preferences" scrollable autoresize>
<View style={styles.main}>
{/* <Text style={theme.text}>Preferences</Text> */}
{ipc.platform === 'darwin' || ipc.platform === 'win32' ? <View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>Startup</Text>
</View>
<View style={styles.sectionRight}>
{/* <Text style={theme.text}>Launch at startup menu here</Text>
<Text style={theme.text}>{JSON.stringify(login_item, null, 4)}</Text> */}
<View style={styles.checkboxContainer}>
<CheckBox
value={login_item.openAtLogin}
onValueChange={setOpenAtLogin}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity style={styles.checkboxLabel} onPress={() => setOpenAtLogin(!login_item.openAtLogin)}>
<Text style={theme.text}>Open at login</Text>
</TouchableOpacity>
</View>
{ipc.platform === 'darwin' ? <View style={styles.checkboxContainer}>
<CheckBox
value={login_item.openAsHidden}
onValueChange={setOpenAsHidden}
disabled={!login_item.openAtLogin}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity style={styles.checkboxLabel} onPress={() => setOpenAsHidden(!login_item.openAsHidden)}>
<Text style={theme.text}>Open in background</Text>
</TouchableOpacity>
</View> : null}
</View>
</View> : null}
<View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>Sleep</Text>
</View>
<View style={styles.sectionRight}>
<Text style={theme.text}>Prevent sleep menu here</Text>
</View>
</View>
<View style={styles.section}>
<View style={styles.sectionLeft}>
<Text style={[styles.label, theme.text]}>Discord Rich Presence</Text>
</View>
<View style={styles.sectionRight}>
<Text style={theme.text}>Discord Rich Presence is {discord_presence_source ? 'en' : 'dis'}abled.</Text>
<View style={styles.button}>
<Button title="Discord Rich Presence setup"
onPress={() => ipc.showDiscordModal()}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)} />
</View>
<Text style={[styles.header, theme.text]}>Discord user</Text>
<Picker<string> selectedValue={discord_options?.user ?? '*'} onValueChange={setDiscordUser}
style={[styles.picker, theme.picker]}
enabled={discord_options_state !== RequestState.LOADING &&
discord_users_state !== RequestState.LOADING}
>{...discord_user_picker}</Picker>
<Text style={[styles.header, theme.text]}>Friend code</Text>
<Text style={[styles.help, theme.text]}>Adding your friend code will also show your Nintendo Switch user icon in Discord.</Text>
{is_discord_friend_code_self ? <>
<View style={styles.checkboxContainer}>
<CheckBox
value={!!discord_options.friend_code}
onValueChange={v => setDiscordFriendCode(v ? discord_friend_code_self : undefined)}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity style={styles.checkboxLabel} onPress={() => setDiscordFriendCode(discord_options.friend_code ? undefined : discord_friend_code_self)}>
<Text style={theme.text}>Share my friend code</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.textLinkTouchable} onPress={() => setIsDiscordFriendCodeSelf(false)}>
<Text style={[styles.textLink, theme.text, {color: '#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}]}>
Set custom friend code
</Text>
</TouchableOpacity>
</> : <TextInput value={discord_friend_code} onChangeText={setDiscordFriendCode}
placeholder="0000-0000-0000"
style={[styles.textInput, theme.textInput]} />}
{/* <View style={styles.header} /> */}
<View style={styles.checkboxContainer}>
<CheckBox
value={discord_options?.show_console_online ?? false}
onValueChange={setDiscordShowConsoleOnline}
color={'#' + (accent_colour ?? DEFAULT_ACCENT_COLOUR)}
style={styles.checkbox}
/>
<TouchableOpacity style={styles.checkboxLabel} onPress={() => setDiscordShowConsoleOnline(!discord_options?.show_console_online)}>
<Text style={theme.text}>Show "Not playing" when a console linked to your account is online, but you are not selected in a game</Text>
</TouchableOpacity>
</View>
<Text style={[styles.header, theme.text]}>Play time</Text>
<Picker<string>
selectedValue={'' + (discord_options?.show_play_time ??
DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE)}
onValueChange={v => setDiscordShowPlayTime(parseInt(v))}
style={[styles.picker, theme.picker]}
enabled={discord_options_state !== RequestState.LOADING}
>
<Picker.Item key={DiscordPresencePlayTime.HIDDEN} value={DiscordPresencePlayTime.HIDDEN}
label="Never show play time" />
<Picker.Item key={DiscordPresencePlayTime.NINTENDO} value={DiscordPresencePlayTime.NINTENDO}
label="Show play time as it appears on a Nintendo Switch console" />
<Picker.Item key={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME} value={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME}
label="Show approximate play time (nearest 5 hours)" />
<Picker.Item key={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME_SINCE} value={DiscordPresencePlayTime.APPROXIMATE_PLAY_TIME_SINCE}
label="Show approximate play time (nearest 5 hours) with first played date" />
<Picker.Item key={DiscordPresencePlayTime.DETAILED_PLAY_TIME} value={DiscordPresencePlayTime.DETAILED_PLAY_TIME}
label="Show exact play time" />
<Picker.Item key={DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE} value={DiscordPresencePlayTime.DETAILED_PLAY_TIME_SINCE}
label="Show exact play time with first played date" />
</Picker>
</View>
</View>
</View>
</Root>;
}
const styles = StyleSheet.create({
loading: {
flex: 1,
paddingVertical: 50,
paddingHorizontal: 20,
justifyContent: 'center',
},
main: {
flex: 1,
paddingVertical: 20,
paddingHorizontal: 35,
},
section: {
marginBottom: 15,
flexDirection: 'row',
},
sectionLeft: {
// flex: 0,
width: '30%',
marginRight: 30,
},
label: {
textAlign: 'right',
},
sectionRight: {
flex: 1,
},
header: {
marginTop: 12,
},
help: {
marginTop: 8,
fontSize: 13,
opacity: 0.7,
},
textLinkTouchable: {
marginBottom: 8,
},
textLink: {
fontSize: 13,
opacity: 0.85,
},
picker: {
marginTop: 8,
paddingVertical: 4,
paddingHorizontal: 8,
borderWidth: 0,
borderRadius: 3,
fontSize: 13,
},
textInput: {
marginTop: 8,
paddingVertical: 5,
paddingHorizontal: 10,
borderRadius: 3,
fontSize: 13,
},
checkboxContainer: {
flex: 1,
flexBasis: 'auto',
// marginRight: 20,
flexDirection: 'row',
alignItems: 'center',
// maxWidth: 1,
},
checkbox: {
marginRight: 10,
marginVertical: 8,
},
checkboxLabel: {
flex: 1,
marginVertical: 8,
},
button: {
marginTop: 10,
marginRight: 10,
flexDirection: 'row',
},
});
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

@ -3,7 +3,7 @@ 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 { DiscordPresence } from '../../discord/types.js';
import ipc, { events } from './ipc.js';
import { NintendoAccountUser } from '../../api/na.js';
import { SavedToken } from '../../common/auth/coral.js';

View File

@ -1,10 +1,11 @@
import { DiscordPresencePlayTime } from '../../discord/util.js';
import { DiscordPresencePlayTime } from '../../discord/types.js';
export enum WindowType {
MAIN_WINDOW = 'App',
FRIEND = 'Friend',
DISCORD_PRESENCE = 'DiscordPresence',
ADD_FRIEND = 'AddFriend',
PREFERENCES = 'Preferences',
}
interface WindowProps {
@ -12,6 +13,7 @@ interface WindowProps {
[WindowType.FRIEND]: import('../browser/friend/index.js').FriendProps;
[WindowType.DISCORD_PRESENCE]: import('../browser/discord/index.js').DiscordSetupProps;
[WindowType.ADD_FRIEND]: import('../browser/add-friend/index.js').AddFriendProps;
[WindowType.PREFERENCES]: import('../browser/preferences/index.js').PreferencesProps;
}
export interface WindowConfiguration<T extends WindowType = WindowType> {

View File

@ -1,7 +1,36 @@
import { GITHUB_MIRROR_URL, GITLAB_URL, ISSUES_URL } from '../../common/constants.js';
import { BrowserWindow, Menu, MenuItem, shell } from './electron.js';
import { App } from './index.js';
let appinstance: App | null;
export function setAppInstance(app: App) {
appinstance = app;
}
const menu_app = new MenuItem({
role: 'appMenu',
submenu: [
{ role: 'about' },
{ type: 'separator' },
{
label: 'Preferences',
accelerator: 'CommandOrControl+,',
click: () => {
appinstance?.showPreferencesWindow();
},
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
});
const menu_app = new MenuItem({role: 'appMenu'});
const menu_file = new MenuItem({role: 'fileMenu'});
const menu_edit = new MenuItem({role: 'editMenu'});

View File

@ -23,10 +23,13 @@ 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 LoginItemSettings = import('electron').LoginItemSettings;
export type LoginItemSettingsOptions = import('electron').LoginItemSettingsOptions;
export type Menu = import('electron').Menu;
export type MenuItem = import('electron').MenuItem;
export type MessageBoxOptions = import('electron').MessageBoxOptions;
export type Notification = import('electron').Notification;
export type Settings = import('electron').Settings;
export type ShareMenu = import('electron').ShareMenu;
export type SharingItem = import('electron').SharingItem;
export type Tray = import('electron').Tray;

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain } from './electron.js';
import { app, BrowserWindow, dialog, ipcMain, LoginItemSettingsOptions } from './electron.js';
import process from 'node:process';
import * as path from 'node:path';
import { EventEmitter } from 'node:events';
@ -18,9 +18,12 @@ import { setupIpc } from './ipc.js';
import { dev, dir } from '../../util/product.js';
import { addUserAgent } from '../../util/useragent.js';
import { askUserForUri } from './util.js';
import { setAppInstance } from './app-menu.js';
const debug = createDebug('app:main');
export const login_item_options: LoginItemSettingsOptions = {};
export class App {
readonly store: Store;
readonly monitors: PresenceMonitorManager;
@ -58,6 +61,32 @@ export class App {
return this.main_window = window;
}
preferences_window: BrowserWindow | null = null;
showPreferencesWindow() {
if (this.preferences_window) {
this.preferences_window.show();
this.preferences_window.focus();
return this.preferences_window;
}
const window = createWindow(WindowType.PREFERENCES, {}, {
show: false,
maximizable: false,
minimizable: false,
width: 580,
height: 400,
minWidth: 580,
maxWidth: 580,
minHeight: 400,
maxHeight: 400,
});
window.on('closed', () => this.preferences_window = null);
return this.preferences_window = window;
}
}
export async function init() {
@ -82,11 +111,12 @@ export async function init() {
const storage = await initStorage(process.env.NXAPI_DATA_PATH ?? paths.data);
const appinstance = new App(storage);
setupIpc(appinstance, ipcMain);
// @ts-expect-error
globalThis.app = appinstance;
setAppInstance(appinstance);
setupIpc(appinstance, ipcMain);
appinstance.store.restoreMonitorState(appinstance.monitors);
const menu = new MenuApp(appinstance);
@ -136,7 +166,9 @@ export async function init() {
debug('App started');
appinstance.showMainWindow();
if (!app.getLoginItemSettings(login_item_options).wasOpenedAsHidden) {
appinstance.showMainWindow();
}
}
function tryHandleUrl(app: App, url: string) {
@ -243,6 +275,20 @@ export class Store extends EventEmitter {
debug('Saving monitor state', state);
await this.storage.setItem('AppMonitors', state);
if (state.discord_presence) {
await this.storage.setItem('AppDiscordPresenceOptions', {
...state.discord_presence,
source: undefined,
});
}
}
async getSavedDiscordPresenceOptions() {
const options: Omit<DiscordPresenceConfiguration, 'source'> | undefined =
await this.storage.getItem('AppDiscordPresenceOptions');
return options ?? null;
}
async restoreMonitorState(monitors: PresenceMonitorManager) {

View File

@ -1,4 +1,4 @@
import { BrowserWindow, clipboard, dialog, IpcMain, Menu, MenuItem, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
import { app, BrowserWindow, clipboard, dialog, IpcMain, LoginItemSettings, Menu, MenuItem, Settings, ShareMenu, SharingItem, shell, systemPreferences } from './electron.js';
import * as util from 'node:util';
import createDebug from 'debug';
import { User } from 'discord-rpc';
@ -7,10 +7,10 @@ import { createWindow, getWindowConfiguration } from './windows.js';
import { DiscordPresenceConfiguration, DiscordPresenceSource, WindowType } from '../common/types.js';
import { CurrentUser, Friend, Game, PresenceState, WebService } from '../../api/coral-types.js';
import { askAddNsoAccount, askAddPctlAccount } from './na-auth.js';
import { App } from './index.js';
import { App, login_item_options } from './index.js';
import { NintendoAccountUser } from '../../api/na.js';
import { hrduration } from '../../util/misc.js';
import { DiscordPresence } from '../../discord/util.js';
import { DiscordPresence } from '../../discord/types.js';
import { getDiscordRpcClients } from '../../discord/rpc.js';
import { defaultTitle } from '../../discord/titles.js';
import type { FriendProps } from '../browser/friend/index.js';
@ -20,6 +20,8 @@ import { AddFriendProps } from '../browser/add-friend/index.js';
const debug = createDebug('app:main:ipc');
const shown_modal_windows = new WeakSet<BrowserWindow>();
export function setupIpc(appinstance: App, ipcMain: IpcMain) {
const store = appinstance.store;
const storage = appinstance.store.storage;
@ -38,6 +40,9 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
sendToAllWindows('nxapi:systemPreferences:accent-colour', accent_colour);
});
ipcMain.handle('nxapi:systemPreferences:getloginitem', () => app.getLoginItemSettings(login_item_options));
ipcMain.handle('nxapi:systemPreferences:setloginitem', (e, settings: Settings) => app.setLoginItemSettings({...login_item_options, ...settings}));
ipcMain.handle('nxapi:update:get', () => appinstance.updater.cache ?? appinstance.updater.check());
ipcMain.handle('nxapi:update:check', () => appinstance.updater.check());
@ -128,11 +133,18 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
window.setMaximumSize(maxWidth, height + (curHeight - curContentHeight));
}
window.setContentSize(curContentWidth, height);
window.show();
if (!shown_modal_windows.has(window)) {
window.show();
shown_modal_windows.add(window);
}
});
ipcMain.handle('nxapi:discord:config', () => appinstance.monitors.getDiscordPresenceConfiguration());
ipcMain.handle('nxapi:discord:setconfig', (e, config: DiscordPresenceConfiguration | null) => appinstance.monitors.setDiscordPresenceConfiguration(config));
ipcMain.handle('nxapi:discord:options', () => appinstance.monitors.getActiveDiscordPresenceOptions() ?? appinstance.store.getSavedDiscordPresenceOptions());
ipcMain.handle('nxapi:discord:savedoptions', () => appinstance.store.getSavedDiscordPresenceOptions());
ipcMain.handle('nxapi:discord:setoptions', (e, options: Omit<DiscordPresenceConfiguration, 'source'>) => appinstance.monitors.setDiscordPresenceOptions(options));
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());

View File

@ -103,6 +103,7 @@ export default class MenuApp {
menu.append(new MenuItem({type: 'separator'}));
menu.append(new MenuItem({label: 'Show main window', click: () => this.app.showMainWindow()}));
menu.append(new MenuItem({label: 'Preferences', click: () => this.app.showPreferencesWindow()}));
if (dev) menu.append(new MenuItem({label: 'Dump notifications state', click: () => {
debug('Accounts', this.app.monitors.notifications.accounts);
debug('Friends', this.app.monitors.notifications.onlinefriends);

View File

@ -8,7 +8,7 @@ import { LoopResult } from '../../util/loop.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { App } from './index.js';
import { DiscordPresenceConfiguration, DiscordPresenceSource } from '../common/types.js';
import { DiscordPresence, DiscordPresencePlayTime } from '../../discord/util.js';
import { DiscordPresence, DiscordPresencePlayTime } from '../../discord/types.js';
import { DiscordRpcClient } from '../../discord/rpc.js';
const debug = createDebug('app:main:monitor');
@ -110,14 +110,11 @@ export class PresenceMonitorManager {
return this.getActiveDiscordPresenceMonitor()?.discord.last_activity ?? null;
}
getDiscordPresenceConfiguration(): DiscordPresenceConfiguration | null {
getActiveDiscordPresenceOptions(): Omit<DiscordPresenceConfiguration, 'source'> | null {
const monitor = this.getActiveDiscordPresenceMonitor();
const source = this.getDiscordPresenceSource();
if (!monitor || !source) return null;
if (!monitor) return null;
return {
source,
user: this.getDiscordClientFilterConfiguration(monitor.discord_client_filter),
friend_code: monitor.show_friend_code && monitor.force_friend_code ?
monitor.force_friend_code.id : undefined,
@ -127,6 +124,30 @@ export class PresenceMonitorManager {
};
}
async setDiscordPresenceOptions(options: Omit<DiscordPresenceConfiguration, 'source'>) {
const source = this.getDiscordPresenceSource();
if (!source) {
// Discord presence is not active
// Save the presence options anyway so they can be restored when Discord presence is enabled
return this.app.store.storage.setItem('AppDiscordPresenceOptions', options);
}
await this.setDiscordPresenceSource(source, monitor => {
this.setDiscordPresenceConfigurationForMonitor(monitor, options);
monitor.skipIntervalInCurrentLoop();
});
await this.app.store.saveMonitorState(this);
}
getDiscordPresenceConfiguration(): DiscordPresenceConfiguration | null {
const source = this.getDiscordPresenceSource();
const options = this.getActiveDiscordPresenceOptions();
return source && options ? {source, ...options} : null;
}
async setDiscordPresenceConfiguration(config: DiscordPresenceConfiguration | null) {
if (!config) return this.setDiscordPresenceSource(null);
@ -140,7 +161,7 @@ export class PresenceMonitorManager {
setDiscordPresenceConfigurationForMonitor(
monitor: EmbeddedPresenceMonitor | EmbeddedProxyPresenceMonitor,
config: DiscordPresenceConfiguration
config: Omit<DiscordPresenceConfiguration, 'source'>
) {
monitor.discord_client_filter = config.user ? this.createDiscordClientFilter(config.user) : undefined;
monitor.show_friend_code = !!config.friend_code;

View File

@ -2,17 +2,17 @@ import { contextBridge, ipcRenderer } from 'electron';
import { EventEmitter } from 'events';
import createDebug from 'debug';
import type { User } from 'discord-rpc';
import type { SharingItem } from '../main/electron.js';
import type { LoginItemSettings, Settings, SharingItem } from '../main/electron.js';
import type { DiscordPresenceConfiguration, DiscordPresenceSource, WindowConfiguration } from '../common/types.js';
import type { SavedToken } from '../../common/auth/coral.js';
import type { SavedMoonToken } from '../../common/auth/moon.js';
import type { UpdateCacheData } from '../../common/update.js';
import type { Announcements, CoralSuccessResponse, CurrentUser, Friend, FriendCodeUrl, FriendCodeUser, GetActiveEventResult, WebService, WebServices } from '../../api/coral-types.js';
import type { DiscordPresence } from '../../discord/util.js';
import type { DiscordPresence } from '../../discord/types.js';
import type { NintendoAccountUser } from '../../api/na.js';
import type { DiscordSetupProps } from '../browser/discord/index.js';
import type { FriendProps } from '../browser/friend/index.js';
import { AddFriendProps } from '../browser/add-friend/index.js';
import type { AddFriendProps } from '../browser/add-friend/index.js';
// In sandboxed renderers the process object contains a very limited set of APIs
// https://www.electronjs.org/docs/latest/api/process#sandbox
@ -30,6 +30,9 @@ events.setMaxListeners(0);
const ipc = {
getWindowData: () => invSync<WindowConfiguration>('browser:getwindowdata'),
getLoginItemSettings: () => inv<LoginItemSettings>('systemPreferences:getloginitem'),
setLoginItemSettings: (settings: Settings) => inv('systemPreferences:setloginitem', settings),
getUpdateData: () => inv<UpdateCacheData | null>('update:get'),
checkUpdates: () => inv<UpdateCacheData | null>('update:check'),
@ -50,6 +53,9 @@ const ipc = {
getDiscordPresenceConfig: () => inv<DiscordPresenceConfiguration | null>('discord:config'),
setDiscordPresenceConfig: (config: DiscordPresenceConfiguration | null) => inv<void>('discord:setconfig', config),
getDiscordPresenceOptions: () => inv<Omit<DiscordPresenceConfiguration, 'source'> | null>('discord:options'),
getSavedDiscordPresenceOptions: () => inv<Omit<DiscordPresenceConfiguration, 'source'> | null>('discord:savedoptions'),
setDiscordPresenceOptions: (options: Omit<DiscordPresenceConfiguration, 'source'>) => inv<void>('discord:setoptions', options),
getDiscordPresenceSource: () => inv<DiscordPresenceSource | null>('discord:source'),
setDiscordPresenceSource: (source: DiscordPresenceSource | null) => inv<void>('discord:setsource', source),
getDiscordPresence: () => inv<DiscordPresence | null>('discord:presence'),

View File

@ -3,7 +3,7 @@ import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/coral.js';
import { DiscordPresencePlayTime } from '../../discord/util.js';
import { DiscordPresencePlayTime } from '../../discord/types.js';
import { handleEnableSplatNet2Monitoring, TerminalNotificationManager } from './notify.js';
import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../../common/presence.js';

View File

@ -4,7 +4,8 @@ import fetch from 'node-fetch';
import { getPresenceFromUrl } from '../../api/znc-proxy.js';
import { ActiveEvent, CurrentUser, Friend, Game, Presence, PresenceState } from '../../api/coral-types.js';
import type { Arguments as ParentArguments } from '../util.js';
import { DiscordPresenceContext, DiscordPresencePlayTime, getDiscordPresence, getInactiveDiscordPresence } from '../../discord/util.js';
import { getDiscordPresence, getInactiveDiscordPresence } from '../../discord/util.js';
import { DiscordPresenceContext, DiscordPresencePlayTime } from '../../discord/types.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken } from '../../common/auth/coral.js';

View File

@ -4,7 +4,7 @@ import type { Arguments as ParentArguments } from '../util.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { titles as unsorted_titles } from '../../discord/titles.js';
import { DiscordApplicationRpc, getDiscordApplicationRpc } from './discord-activity.js';
import { Title } from '../../discord/util.js';
import { Title } from '../../discord/types.js';
const debug = createDebug('cli:util:export-discord-titles');

View File

@ -1,6 +1,7 @@
import createDebug from 'debug';
import { DiscordRpcClient, findDiscordRpcClient } from '../discord/rpc.js';
import { DiscordPresencePlayTime, DiscordPresenceContext, getDiscordPresence, getInactiveDiscordPresence, DiscordPresence } from '../discord/util.js';
import { getDiscordPresence, getInactiveDiscordPresence } from '../discord/util.js';
import { DiscordPresencePlayTime, DiscordPresenceContext, DiscordPresence } from '../discord/types.js';
import { EmbeddedSplatNet2Monitor, ZncNotifications } from './notify.js';
import { getPresenceFromUrl } from '../api/znc-proxy.js';
import { ActiveEvent, CurrentUser, Friend, Game, Presence, PresenceState, CoralErrorResponse } from '../api/coral-types.js';

View File

@ -1,4 +1,4 @@
import { Title } from './util.js';
import { Title } from './types.js';
import * as publishers from './titles/index.js';
export const defaultTitle: Title = {

View File

@ -1,4 +1,4 @@
import { Title } from '../util.js';
import { Title } from '../types.js';
export const titles: Title[] = [
{

View File

@ -1,4 +1,4 @@
import { Title } from '../util.js';
import { Title } from '../types.js';
export const titles: Title[] = [
{

View File

@ -1,4 +1,4 @@
import { Title } from '../util.js';
import { Title } from '../types.js';
export const titles: Title[] = [
{

View File

@ -1,4 +1,4 @@
import { Title } from '../util.js';
import { Title } from '../types.js';
export const titles: Title[] = [
{

112
src/discord/types.ts Normal file
View File

@ -0,0 +1,112 @@
import DiscordRPC from 'discord-rpc';
import { ActiveEvent, CurrentUser, Friend, Game } from '../api/coral-types.js';
import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../common/presence.js';
export interface DiscordPresenceContext {
friendcode?: CurrentUser['links']['friendCode'];
activeevent?: ActiveEvent;
show_play_time?: DiscordPresencePlayTime;
znc_discord_presence?: ZncDiscordPresence | ZncProxyDiscordPresence;
nsaid?: string;
user?: CurrentUser | Friend;
}
export interface DiscordPresence {
id: string;
title: string | null;
activity: DiscordRPC.Presence;
showTimestamp?: boolean;
}
type SystemModuleTitleId = `01000000000000${string}`;
type SystemDataTitleId = `01000000000008${string}`;
type SystemAppletTitleId = `0100000000001${string}`;
type ApplicationTitleId = `0100${string}${'0' | '2' | '4' | '6' | '8' | 'a' | 'c' | 'e'}000`;
export interface Title {
/**
* Lowercase hexadecimal title ID.
*
* Valid application title IDs are 16 characters long, and should start with `0100` and end with `0000`, `2000`, `4000`, `6000`, `8000`, `a000`, `c000`, `e000` (this is because applications have 16^4 title IDs for the application itself, plus addon content and update data).
*/
id: ApplicationTitleId | '0000000000000000';
/**
* Discord client ID
*/
client: string;
/**
* Title name to show in Discord. This is *not* the name that will appear under the user's name after "Playing ".
*
* If this is set to true the title's name from znc will be used.
* If this is set to false (default) no title name will be set. This should be used when a specific Discord client for the title is used.
* If this is set to a string it will be used as the title name.
*
* @default false
*/
titleName?: string | boolean;
/**
* By default the title's icon from znc will be used. (No icons need to be uploaded to Discord.)
*/
largeImageKey?: string;
largeImageText?: string;
/**
* By default the user's icon and friend code will be used if the user is sharing their friend code; otherwise it will not be set.
*/
smallImageKey?: string;
smallImageText?: string;
/**
* Whether to show the timestamp the user started playing the title in Discord. Discord shows this as the number of minutes and seconds since the timestamp.
*
* If enabled this is set to the time the user's presence was last updated as reported by Nintendo. Any changes to the updated timestamp will be ignored as long as the title doesn't change. The timestamp may change if the presence tracking is reset for any reason.
*
* This is now enabled by default as it's required for the activity to show in the Active Now panel.
*
* @default true
*/
showTimestamp?: boolean;
/**
* Show the activity description set by the title.
*
* @default true
*/
showDescription?: boolean;
/**
* Show "Playing online" if playing online and the game doesn't set activity details.
*
* @default false
*/
showPlayingOnline?: string | boolean;
/**
* Whether to show details of the current event (Online Lounge/voice chat) in Discord.
*
* @default false
*/
showActiveEvent?: boolean;
/**
* Whether to show "Played for ... since ..." in Discord.
*
* @default true
*/
showPlayTime?: boolean;
/**
* A function to call to customise the Discord activity.
*/
callback?: (activity: DiscordRPC.Presence, game: Game, context?: DiscordPresenceContext) => void;
}
export enum DiscordPresencePlayTime {
/** Don't show play time */
HIDDEN,
/** "First played x minutes/hours/days ago" or "Played for [x5] hours or more" */
NINTENDO,
/** "Played for [x5] hours or more" */
APPROXIMATE_PLAY_TIME,
/** "Played for [x5] hours or more since dd/mm/yyyy" */
APPROXIMATE_PLAY_TIME_SINCE,
/** "Played for x hours and x minutes" */
DETAILED_PLAY_TIME,
/** "Played for x hours and x minutes since dd/mm/yyyy" */
DETAILED_PLAY_TIME_SINCE,
}

View File

@ -1,9 +1,9 @@
import DiscordRPC from 'discord-rpc';
import { ActiveEvent, CurrentUser, Friend, Game, PresenceState } from '../api/coral-types.js';
import { Game, PresenceState } from '../api/coral-types.js';
import { defaultTitle, titles } from './titles.js';
import { product, version } from '../util/product.js';
import { getTitleIdFromEcUrl, hrduration } from '../util/misc.js';
import { ZncDiscordPresence, ZncProxyDiscordPresence } from '../common/presence.js';
import { DiscordPresence, DiscordPresenceContext, DiscordPresencePlayTime } from './types.js';
export function getDiscordPresence(
state: PresenceState, game: Game, context?: DiscordPresenceContext
@ -142,22 +142,6 @@ export function getInactiveDiscordPresence(
};
}
export interface DiscordPresenceContext {
friendcode?: CurrentUser['links']['friendCode'];
activeevent?: ActiveEvent;
show_play_time?: DiscordPresencePlayTime;
znc_discord_presence?: ZncDiscordPresence | ZncProxyDiscordPresence;
nsaid?: string;
user?: CurrentUser | Friend;
}
export interface DiscordPresence {
id: string;
title: string | null;
activity: DiscordRPC.Presence;
showTimestamp?: boolean;
}
export function getTitleConfiguration(game: Game, id: string) {
return titles.find(title => {
if (title.id !== id) return false;
@ -165,96 +149,3 @@ export function getTitleConfiguration(game: Game, id: string) {
return true;
});
}
type SystemModuleTitleId = `01000000000000${string}`;
type SystemDataTitleId = `01000000000008${string}`;
type SystemAppletTitleId = `0100000000001${string}`;
type ApplicationTitleId = `0100${string}${'0' | '2' | '4' | '6' | '8' | 'a' | 'c' | 'e'}000`;
export interface Title {
/**
* Lowercase hexadecimal title ID.
*
* Valid application title IDs are 16 characters long, and should start with `0100` and end with `0000`, `2000`, `4000`, `6000`, `8000`, `a000`, `c000`, `e000` (this is because applications have 16^4 title IDs for the application itself, plus addon content and update data).
*/
id: ApplicationTitleId | '0000000000000000';
/**
* Discord client ID
*/
client: string;
/**
* Title name to show in Discord. This is *not* the name that will appear under the user's name after "Playing ".
*
* If this is set to true the title's name from znc will be used.
* If this is set to false (default) no title name will be set. This should be used when a specific Discord client for the title is used.
* If this is set to a string it will be used as the title name.
*
* @default false
*/
titleName?: string | boolean;
/**
* By default the title's icon from znc will be used. (No icons need to be uploaded to Discord.)
*/
largeImageKey?: string;
largeImageText?: string;
/**
* By default the user's icon and friend code will be used if the user is sharing their friend code; otherwise it will not be set.
*/
smallImageKey?: string;
smallImageText?: string;
/**
* Whether to show the timestamp the user started playing the title in Discord. Discord shows this as the number of minutes and seconds since the timestamp.
*
* If enabled this is set to the time the user's presence was last updated as reported by Nintendo. Any changes to the updated timestamp will be ignored as long as the title doesn't change. The timestamp may change if the presence tracking is reset for any reason.
*
* This is now enabled by default as it's required for the activity to show in the Active Now panel.
*
* @default true
*/
showTimestamp?: boolean;
/**
* Show the activity description set by the title.
*
* @default true
*/
showDescription?: boolean;
/**
* Show "Playing online" if playing online and the game doesn't set activity details.
*
* @default false
*/
showPlayingOnline?: string | boolean;
/**
* Whether to show details of the current event (Online Lounge/voice chat) in Discord.
*
* @default false
*/
showActiveEvent?: boolean;
/**
* Whether to show "Played for ... since ..." in Discord.
*
* @default true
*/
showPlayTime?: boolean;
/**
* A function to call to customise the Discord activity.
*/
callback?: (activity: DiscordRPC.Presence, game: Game, context?: DiscordPresenceContext) => void;
}
export enum DiscordPresencePlayTime {
/** Don't show play time */
HIDDEN,
/** "First played x minutes/hours/days ago" or "Played for [x5] hours or more" */
NINTENDO,
/** "Played for [x5] hours or more" */
APPROXIMATE_PLAY_TIME,
/** "Played for [x5] hours or more since dd/mm/yyyy" */
APPROXIMATE_PLAY_TIME_SINCE,
/** "Played for x hours and x minutes" */
DETAILED_PLAY_TIME,
/** "Played for x hours and x minutes since dd/mm/yyyy" */
DETAILED_PLAY_TIME_SINCE,
}