Add Discord Rich Presence and user/friend presence notifications to the Electron app

This commit is contained in:
Samuel Elliott
2022-04-17 22:29:14 +01:00
parent e999ca85d8
commit f807719f3a
7 changed files with 410 additions and 136 deletions

View File

@@ -1,14 +1,17 @@
import { app, BrowserWindow, ipcMain } from '../electron.js';
import { app, BrowserWindow, ipcMain, nativeImage, Notification } from '../electron.js';
import * as path from 'path';
import { EventEmitter } from 'events';
import createDebug from 'debug';
import * as persist from 'node-persist';
import { initStorage, paths } from '../../util.js';
import MenuApp from './menu.js';
import { EventEmitter } from 'events';
import fetch from 'node-fetch';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import { getToken, initStorage, paths } from '../../util.js';
import MenuApp from './menu.js';
import { ZncDiscordPresence } from '../../cli/nso/presence.js';
import { WebServiceIpc } from './webservices.js';
import { CurrentUser, Friend, Game } from '../../api/znc-types.js';
import { NotificationManager } from '../../cli/nso/notify.js';
const debug = createDebug('app:main');
@@ -60,7 +63,8 @@ app.whenReady().then(async () => {
BrowserWindow.getAllWindows().forEach(w => w.webContents.send(channel, ...args));
store.on('update-nintendo-accounts', () => sendToAllWindows('nxapi:accounts:shouldrefresh'));
const menu = new MenuApp(store);
const monitors = new PresenceMonitorManager(store);
const menu = new MenuApp(store, monitors);
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
@@ -84,3 +88,151 @@ export class Store extends EventEmitter {
//
}
export class PresenceMonitorManager {
monitors: EmbeddedPresenceMonitor[] = [];
notifications = new ElectronNotificationManager();
constructor(
public store: Store
) {}
async start(id: string, callback?: (monitor: EmbeddedPresenceMonitor, firstRun: boolean) => void) {
debug('Starting monitor', id);
const token = id.length === 16 ? await this.store.storage.getItem('NintendoAccountToken.' + id) : id;
if (!token) throw new Error('No token for this user');
const {nso, data} = await getToken(this.store.storage, token, process.env.ZNC_PROXY_URL);
const existing = this.monitors.find(m => m.data.user.id === data.user.id);
if (existing) {
callback?.call(null, existing, false);
return;
}
const i = new EmbeddedPresenceMonitor({
userNotifications: false,
friendNotifications: false,
updateInterval: 60,
friendCode: undefined,
showInactivePresence: false,
showEvent: false,
friendNsaid: undefined,
}, this.store.storage, token, nso, data);
i.notifications = this.notifications;
i.presence_user = null;
this.monitors.push(i);
callback?.call(null, i, true);
i.enable();
}
async stop(id: string) {
let index;
while ((index = this.monitors.findIndex(m => m.data.user.id === id)) >= 0) {
const i = this.monitors[index];
this.monitors.splice(index, 1);
i.disable();
}
}
}
export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
notifications = new ElectronNotificationManager();
enable() {
if (this._running !== 0) return;
this._run();
}
disable() {
this._running = 0;
}
get enabled() {
return this._running !== 0;
}
private _running = 0;
private async _run() {
this._running++;
const i = this._running;
try {
await this.loop(true);
while (i === this._running) {
await this.loop();
}
if (this._running === 0) {
// Run one more time after the loop ends
const result = await this.loopRun();
}
debug('Monitor for user %s finished', this.data.nsoAccount.user.name);
} finally {
this._running = 0;
}
}
}
export class ElectronNotificationManager extends NotificationManager {
private async getNativeImageFromUrl(url: string) {
try {
const response = await fetch(url);
const image = await response.buffer();
return nativeImage.createFromBuffer(image);
} catch (err) {}
return undefined;
}
async onFriendOnline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
const currenttitle = friend.presence.game as Game;
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: await this.getNativeImageFromUrl(friend.imageUri),
}).show();
}
async onFriendOffline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
new Notification({
title: friend.name,
body: 'Offline',
icon: await this.getNativeImageFromUrl(friend.imageUri),
}).show();
}
async onFriendPlayingChangeTitle(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
const currenttitle = friend.presence.game as Game;
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: await this.getNativeImageFromUrl(friend.imageUri),
}).show();
}
async onFriendTitleStateChange(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
const currenttitle = friend.presence.game as Game;
new Notification({
title: friend.name,
body: 'Playing ' + currenttitle.name +
(currenttitle.sysDescription ? '\n' + currenttitle.sysDescription : ''),
icon: await this.getNativeImageFromUrl(friend.imageUri),
}).show();
}
}

View File

@@ -1,6 +1,6 @@
import { app, dialog, Menu, Tray, nativeImage, MenuItem } from '../electron.js';
import { addNsoAccount, addPctlAccount } from './na-auth.js';
import { Store } from './index.js';
import { PresenceMonitorManager, Store } from './index.js';
import { getToken, SavedMoonToken, SavedToken } from '../../util.js';
import { WebService } from '../../api/znc-types.js';
import openWebService from './webservices.js';
@@ -8,7 +8,7 @@ import openWebService from './webservices.js';
export default class MenuApp {
tray: Tray;
constructor(readonly store: Store) {
constructor(readonly store: Store, readonly monitors: PresenceMonitorManager) {
const icon = nativeImage.createEmpty();
this.tray = new Tray(icon);
@@ -22,21 +22,37 @@ export default class MenuApp {
async updateMenu() {
const menu = new Menu();
const ids = await this.store.storage.getItem('NintendoAccountIds') as string[] | undefined;
menu.append(new MenuItem({label: 'Nintendo Switch Online', enabled: false}));
const discordPresenceMonitor = this.getActiveDiscordPresenceMonitor();
for (const id of ids ?? []) {
const token = await this.store.storage.getItem('NintendoAccountToken.' + id) as string | undefined;
if (!token) continue;
const data = await this.store.storage.getItem('NsoToken.' + token) as SavedToken | undefined;
if (!data) continue;
const monitor = this.monitors.monitors.find(m => m.data.user.id === data.user.id);
const discordPresenceActive = discordPresenceMonitor?.data.user.id === data.user.id;
const item = new MenuItem({
label: data.nsoAccount.user.name,
submenu: [
{label: 'Nintendo Account ID: ' + data.user.id, enabled: false},
{label: 'NSA ID: ' + data.nsoAccount.user.nsaId, enabled: false},
{type: 'separator'},
{label: 'Enable Discord Presence', type: 'checkbox', checked: discordPresenceActive,
click: () => this.setActiveDiscordPresenceUser(discordPresenceActive ? null : data.user.id)},
{label: 'Enable notifications for this user\'s presence', type: 'checkbox',
checked: monitor?.user_notifications,
click: () => this.setUserNotificationsActive(data.user.id, !monitor?.user_notifications)},
{label: 'Enable notifications for this friends of this user\'s presence', type: 'checkbox',
checked: monitor?.friend_notifications,
click: () => this.setFriendNotificationsActive(data.user.id, !monitor?.friend_notifications)},
{label: 'Update now', enabled: !!monitor, click: () => monitor?.skipIntervalInCurrentLoop()},
{type: 'separator'},
{label: 'Web services', enabled: false},
...await this.getWebServiceItems(token) as any,
],
@@ -159,4 +175,79 @@ export default class MenuApp {
return items;
}
getActiveDiscordPresenceMonitor() {
for (const monitor of this.monitors.monitors) {
if (!monitor.presence_user) continue;
return monitor;
}
return null;
}
async setActiveDiscordPresenceUser(id: string | null) {
const monitor = this.getActiveDiscordPresenceMonitor();
if (monitor) {
if (monitor.data.user.id === id) return;
monitor.presence_user = null;
// monitor.skipIntervalInCurrentLoop();
monitor.updatePresenceForDiscord(null);
if (!monitor.user_notifications && !monitor.friend_notifications) {
this.monitors.stop(monitor.data.user.id);
}
}
if (id) await this.monitors.start(id, monitor => {
monitor.presence_user = monitor.data.nsoAccount.user.nsaId;
monitor.skipIntervalInCurrentLoop();
});
if (monitor || id) this.updateMenu();
}
async setUserNotificationsActive(id: string, active: boolean) {
const monitor = this.monitors.monitors.find(m => m.data.user.id === id);
if (monitor?.user_notifications && !active) {
monitor.user_notifications = false;
if (!monitor.presence_user && !monitor.friend_notifications) {
this.monitors.stop(monitor.data.user.id);
}
monitor.skipIntervalInCurrentLoop();
this.updateMenu();
}
if (!monitor?.user_notifications && active) await this.monitors.start(id, monitor => {
monitor.user_notifications = true;
monitor.skipIntervalInCurrentLoop();
this.updateMenu();
});
}
async setFriendNotificationsActive(id: string, active: boolean) {
const monitor = this.monitors.monitors.find(m => m.data.user.id === id);
if (monitor?.friend_notifications && !active) {
monitor.friend_notifications = false;
if (!monitor.presence_user && !monitor.user_notifications) {
this.monitors.stop(monitor.data.user.id);
}
monitor.skipIntervalInCurrentLoop();
this.updateMenu();
}
if (!monitor?.friend_notifications && active) await this.monitors.start(id, monitor => {
monitor.friend_notifications = true;
monitor.skipIntervalInCurrentLoop();
this.updateMenu();
});
}
}

View File

@@ -8,7 +8,7 @@ import { ActiveEvent, Announcement, CurrentUser, Friend, GetActiveEventResult, P
import ZncApi from '../../api/znc.js';
import type { Arguments as ParentArguments } from '../nso.js';
import { ArgumentsCamelCase, Argv, getToken, initStorage, SavedToken, YargsArguments } from '../../util.js';
import { ZncNotifications } from './notify.js';
import { NotificationManager, ZncNotifications } from './notify.js';
declare global {
namespace Express {
@@ -642,16 +642,26 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
res.setHeader('Content-Type', 'text/event-stream');
const nintendoAccountSessionToken = req.headers['authorization']!.substr(3);
const i = new ZncPresenceEventStream(
argv as any, storage, nintendoAccountSessionToken, req.znc!, req.zncAuth!,
req, res,
true, true,
);
const i = new ZncNotifications({
...argv,
userNotifications: true,
friendNotifications: true,
}, storage, nintendoAccountSessionToken, req.znc!, req.zncAuth!);
await i.init();
const es = i.notifications = new EventStreamNotificationManager(req, res);
while (true) {
await i.loop();
try {
await i.loop(true);
while (true) {
await i.loop();
}
} catch (err) {
es.sendEvent('error', {
error: (err as Error).name,
error_message: (err as Error).message,
});
res.end();
}
});
@@ -674,23 +684,12 @@ export enum ZncPresenceEventStreamEvent {
FRIEND_TITLE_STATECHANGE = '3',
}
class ZncPresenceEventStream extends ZncNotifications {
class EventStreamNotificationManager extends NotificationManager {
constructor(
argv: ArgumentsCamelCase<Arguments>,
storage: persist.LocalStorage,
token: string,
nso: ZncApi,
data: Omit<SavedToken, 'expires_at'>,
public req: express.Request,
public res: express.Response,
user: boolean,
friend: boolean,
public res: express.Response
) {
super({
...argv,
userNotifications: user,
friendNotifications: friend,
}, storage, token, nso, data);
super();
}
sendEvent(event: string | null, ...data: unknown[]) {

View File

@@ -118,7 +118,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
i.splatnet2_monitors.set(data.nsoAccount.user.nsaId, handleEnableSplatNet2Monitoring(argv, storage, token));
}
await i.init();
await i.loop(true);
while (true) {
await i.loop();
@@ -126,6 +126,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
}
export class ZncNotifications extends Loop {
notifications = new NotificationManager();
splatnet2_monitors = new Map<string, EmbeddedSplatNet2Monitor | (() => Promise<EmbeddedSplatNet2Monitor>)>();
user_notifications = true;
@@ -217,11 +218,99 @@ export class ZncNotifications extends Loop {
]);
await this.updatePresenceForNotifications(user, friends, true);
await this.updatePresenceForSplatNet2Monitors([user!]);
if (user) await this.updatePresenceForSplatNet2Monitors([user]);
await new Promise(rs => setTimeout(rs, this.update_interval * 1000));
return LoopResult.OK;
}
async updateFriendsStatusForNotifications(friends: (CurrentUser | Friend)[], initialRun?: boolean) {
this.notifications.updateFriendsStatusForNotifications(friends, initialRun);
}
async updatePresenceForNotifications(
user: CurrentUser | undefined, friends: Friend[] | undefined, initialRun?: boolean
) {
await this.updateFriendsStatusForNotifications(([] as (CurrentUser | Friend)[])
.concat(this.user_notifications && user ? [user] : [])
.concat(this.friend_notifications && friends ? friends : []), initialRun);
}
async updatePresenceForSplatNet2Monitors(friends: (CurrentUser | Friend)[]) {
for (const friend of friends) {
await this.updatePresenceForSplatNet2Monitor(friend.presence, friend.nsaId, friend.name);
}
}
async updatePresenceForSplatNet2Monitor(presence: Presence, nsa_id: string, name?: string) {
const playing = presence.state === PresenceState.PLAYING;
const monitor = this.splatnet2_monitors.get(nsa_id);
if (playing && monitor) {
const currenttitle = presence.game as Game;
const titleid = getTitleIdFromEcUrl(currenttitle.shopUri);
if (titleid && EmbeddedSplatNet2Monitor.title_ids.includes(titleid)) {
if ('enable' in monitor) {
monitor.enable();
if (!monitor.enabled) debugSplatnet2('Started monitor for user %s', name ?? nsa_id);
} else {
const m = await monitor.call(null);
this.splatnet2_monitors.set(nsa_id, m);
m.enable();
debugSplatnet2('Started monitor for user %s', name ?? nsa_id);
}
} else if ('disable' in monitor) {
if (monitor.enabled) debugSplatnet2('Stopping monitor for user %s', name ?? nsa_id);
monitor.disable();
}
} else if (monitor && 'disable' in monitor) {
if (monitor.enabled) debugSplatnet2('Stopping monitor for user %s', name ?? nsa_id);
monitor.disable();
}
}
async update() {
const {friends, user} = await this.fetch([
this.user_notifications ? 'user' : null,
this.friend_notifications ? 'friends' : null,
this.splatnet2_monitors.size ? 'user' : null,
]);
await this.updatePresenceForNotifications(user, friends);
if (user) await this.updatePresenceForSplatNet2Monitors([user]);
}
async handleError(err: ErrorResponse<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
if (err && 'response' in err && err.data?.status === 9404) {
// Token expired
debug('Renewing token');
const data = await this.nso.renewToken(this.token, this.data.user);
const existingToken: SavedToken = {
user: this.data.user,
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
await this.storage.setItem('NsoToken.' + this.token, existingToken);
return LoopResult.OK_SKIP_INTERVAL;
} else if ('code' in err && (err as any).type === 'system' && err.code === 'ETIMEDOUT') {
debug('Request timed out, waiting %ds before retrying', this.update_interval, err);
return LoopResult.OK;
} else if ('code' in err && (err as any).type === 'system' && err.code === 'ENOTFOUND') {
debug('Request error, waiting %ds before retrying', this.update_interval, err);
return LoopResult.OK;
} else {
throw err;
}
}
}
export class NotificationManager {
onFriendOnline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, ir?: boolean) {
const currenttitle = friend.presence.game as Game;
@@ -373,88 +462,6 @@ export class ZncNotifications extends Loop {
this.onlinefriends = newonlinefriends;
}
async updatePresenceForNotifications(
user: CurrentUser | undefined, friends: Friend[] | undefined, initialRun?: boolean
) {
await this.updateFriendsStatusForNotifications(([] as (CurrentUser | Friend)[])
.concat(this.user_notifications && user ? [user] : [])
.concat(this.friend_notifications && friends ? friends : []), initialRun);
}
async updatePresenceForSplatNet2Monitors(friends: (CurrentUser | Friend)[]) {
for (const friend of friends) {
await this.updatePresenceForSplatNet2Monitor(friend.presence, friend.nsaId, friend.name);
}
}
async updatePresenceForSplatNet2Monitor(presence: Presence, nsa_id: string, name?: string) {
const playing = presence.state === PresenceState.PLAYING;
const monitor = this.splatnet2_monitors.get(nsa_id);
if (playing && monitor) {
const currenttitle = presence.game as Game;
const titleid = getTitleIdFromEcUrl(currenttitle.shopUri);
if (titleid && EmbeddedSplatNet2Monitor.title_ids.includes(titleid)) {
if ('enable' in monitor) {
monitor.enable();
if (!monitor.enabled) debugSplatnet2('Started monitor for user %s', name ?? nsa_id);
} else {
const m = await monitor.call(null);
this.splatnet2_monitors.set(nsa_id, m);
m.enable();
debugSplatnet2('Started monitor for user %s', name ?? nsa_id);
}
} else if ('disable' in monitor) {
if (monitor.enabled) debugSplatnet2('Stopping monitor for user %s', name ?? nsa_id);
monitor.disable();
}
} else if (monitor && 'disable' in monitor) {
if (monitor.enabled) debugSplatnet2('Stopping monitor for user %s', name ?? nsa_id);
monitor.disable();
}
}
async update() {
const {friends, user} = await this.fetch([
this.user_notifications ? 'user' : null,
this.friend_notifications ? 'friends' : null,
this.splatnet2_monitors.size ? 'user' : null,
]);
await this.updatePresenceForNotifications(user, friends);
await this.updatePresenceForSplatNet2Monitors([user!]);
}
async handleError(err: ErrorResponse<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
if (err && 'response' in err && err.data?.status === 9404) {
// Token expired
debug('Renewing token');
const data = await this.nso.renewToken(this.token, this.data.user);
const existingToken: SavedToken = {
user: this.data.user,
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
await this.storage.setItem('NsoToken.' + this.token, existingToken);
return LoopResult.OK_SKIP_INTERVAL;
} else if ('code' in err && (err as any).type === 'system' && err.code === 'ETIMEDOUT') {
debug('Request timed out, waiting %ds before retrying', this.update_interval, err);
return LoopResult.OK;
} else if ('code' in err && (err as any).type === 'system' && err.code === 'ENOTFOUND') {
debug('Request error, waiting %ds before retrying', this.update_interval, err);
return LoopResult.OK;
} else {
throw err;
}
}
}
export class EmbeddedSplatNet2Monitor extends SplatNet2RecordsMonitor {
@@ -484,7 +491,7 @@ export class EmbeddedSplatNet2Monitor extends SplatNet2RecordsMonitor {
const i = this._running;
try {
await this.init();
await this.loop(true);
while (i === this._running) {
await this.loop();

View File

@@ -144,7 +144,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
console.warn('Not authenticated; using znc proxy');
}
await i.init();
await i.loop(true);
while (true) {
await i.loop();
@@ -175,7 +175,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
i.splatnet2_monitors.set(data.nsoAccount.user.nsaId, handleEnableSplatNet2Monitoring(argv, storage, token));
}
await i.init();
await i.loop(true);
while (true) {
await i.loop();
@@ -239,9 +239,9 @@ export class ZncDiscordPresence extends ZncNotifications {
}
await this.updatePresenceForNotifications(user, friends);
await this.updatePresenceForSplatNet2Monitors([user!]);
if (user) await this.updatePresenceForSplatNet2Monitors([user]);
await new Promise(rs => setTimeout(rs, this.update_interval * 1000));
return LoopResult.OK;
}
rpc: {client: DiscordRPC.Client, id: string} | null = null;
@@ -390,7 +390,7 @@ export class ZncDiscordPresence extends ZncNotifications {
}
await this.updatePresenceForNotifications(user, friends);
await this.updatePresenceForSplatNet2Monitors([user!]);
if (user) await this.updatePresenceForSplatNet2Monitors([user]);
}
update_presence_errors = 0;
@@ -425,7 +425,7 @@ export class ZncProxyDiscordPresence extends ZncDiscordPresence {
async init() {
await this.update();
await new Promise(rs => setTimeout(rs, this.argv.updateInterval * 1000));
return LoopResult.OK;
}
async update() {

View File

@@ -14,14 +14,13 @@ import { ErrorResponse } from '../../api/util.js';
const debug = createDebug('cli:splatnet2:monitor');
export const command = 'monitor <directory>';
export const command = 'monitor [directory]';
export const desc = 'Monitor SplatNet 2 for new user records/battles/Salmon Run results';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('directory', {
describe: 'Directory to write record data to',
type: 'string',
demandOption: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
@@ -77,7 +76,8 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const records = await splatnet.getRecords();
const stages = await splatnet.getStages();
const i = new SplatNet2RecordsMonitor(storage, token, splatnet, stages, argv.directory, argv.zncProxyUrl);
const directory = argv.directory ?? path.join(argv.dataPath, 'splatnet2');
const i = new SplatNet2RecordsMonitor(storage, token, splatnet, stages, directory, argv.zncProxyUrl);
i.update_interval = argv.updateInterval;
@@ -101,7 +101,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
records.records.player.player_rank,
records.records.player.player_type);
await i.init();
await i.loop(true);
while (true) {
await i.loop();

View File

@@ -199,15 +199,15 @@ export function hrduration(duration: number, short = false) {
export abstract class Loop {
update_interval = 60;
init(): void | Promise<void> {}
init(): void | Promise<LoopResult | void> {}
abstract update(): void | Promise<void>;
abstract update(): void | Promise<LoopResult | void>;
async loopRun(): Promise<LoopResult> {
protected async loopRun(init = false): Promise<LoopResult> {
try {
await this.update();
const result = init ? await this.init() : await this.update();
return LoopResult.OK;
return result ?? (init ? LoopResult.OK_SKIP_INTERVAL : LoopResult.OK);
} catch (err) {
return this.handleError(err as any);
}
@@ -217,13 +217,38 @@ export abstract class Loop {
throw err;
}
async loop() {
const result = await this.loopRun();
private is_loop_active = 0;
if (result === LoopResult.OK) {
await new Promise(rs => setTimeout(rs, this.update_interval * 1000));
async loop(init = false) {
try {
this.is_loop_active++;
const result = await this.loopRun(init);
if (result === LoopResult.OK) {
if (this.skip_interval_once) {
this.skip_interval_once = false;
} else {
await new Promise(rs => setTimeout(this.timeout_resolve = rs, this.update_interval * 1000));
}
}
} finally {
this.is_loop_active--;
this.skip_interval_once = false;
this.timeout_resolve = null;
}
}
private skip_interval_once = false;
private timeout_resolve: ((value: void) => void) | null = null;
skipIntervalInCurrentLoop() {
debug('Skip update interval', this.is_loop_active);
if (!this.is_loop_active) return;
this.skip_interval_once = true;
this.timeout_resolve?.call(null);
}
}
const LoopRunOk = Symbol('LoopRunOk');