From f807719f3a180c4c551725ef1e7737956a018ff4 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Sun, 17 Apr 2022 22:29:14 +0100 Subject: [PATCH] Add Discord Rich Presence and user/friend presence notifications to the Electron app --- src/app/main/index.ts | 164 ++++++++++++++++++++++++++++++-- src/app/main/menu.ts | 95 ++++++++++++++++++- src/cli/nso/http-server.ts | 45 +++++---- src/cli/nso/notify.ts | 179 ++++++++++++++++++----------------- src/cli/nso/presence.ts | 12 +-- src/cli/splatnet2/monitor.ts | 8 +- src/util.ts | 43 +++++++-- 7 files changed, 410 insertions(+), 136 deletions(-) diff --git a/src/app/main/index.ts b/src/app/main/index.ts index 40dff6e..4135016 100644 --- a/src/app/main/index.ts +++ b/src/app/main/index.ts @@ -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(); + } +} diff --git a/src/app/main/menu.ts b/src/app/main/menu.ts index 4f10872..5e04736 100644 --- a/src/app/main/menu.ts +++ b/src/app/main/menu.ts @@ -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(); + }); + } } diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 7d180c5..299ba56 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -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) { 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, - storage: persist.LocalStorage, - token: string, - nso: ZncApi, - data: Omit, 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[]) { diff --git a/src/cli/nso/notify.ts b/src/cli/nso/notify.ts index a1c0e28..3d5019f 100644 --- a/src/cli/nso/notify.ts +++ b/src/cli/nso/notify.ts @@ -118,7 +118,7 @@ export async function handler(argv: ArgumentsCamelCase) { 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) { } export class ZncNotifications extends Loop { + notifications = new NotificationManager(); splatnet2_monitors = new Map Promise)>(); 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 | NodeJS.ErrnoException): Promise { + 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 | NodeJS.ErrnoException): Promise { - 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(); diff --git a/src/cli/nso/presence.ts b/src/cli/nso/presence.ts index e217110..bc16123 100644 --- a/src/cli/nso/presence.ts +++ b/src/cli/nso/presence.ts @@ -144,7 +144,7 @@ export async function handler(argv: ArgumentsCamelCase) { 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) { 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() { diff --git a/src/cli/splatnet2/monitor.ts b/src/cli/splatnet2/monitor.ts index 4afdf16..b93b43f 100644 --- a/src/cli/splatnet2/monitor.ts +++ b/src/cli/splatnet2/monitor.ts @@ -14,14 +14,13 @@ import { ErrorResponse } from '../../api/util.js'; const debug = createDebug('cli:splatnet2:monitor'); -export const command = 'monitor '; +export const command = 'monitor [directory]'; export const desc = 'Monitor SplatNet 2 for new user records/battles/Salmon Run results'; export function builder(yargs: Argv) { 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) { 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) { records.records.player.player_rank, records.records.player.player_type); - await i.init(); + await i.loop(true); while (true) { await i.loop(); diff --git a/src/util.ts b/src/util.ts index 65e1769..8018b46 100644 --- a/src/util.ts +++ b/src/util.ts @@ -199,15 +199,15 @@ export function hrduration(duration: number, short = false) { export abstract class Loop { update_interval = 60; - init(): void | Promise {} + init(): void | Promise {} - abstract update(): void | Promise; + abstract update(): void | Promise; - async loopRun(): Promise { + protected async loopRun(init = false): Promise { 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');