Add status update banners

This commit is contained in:
Samuel Elliott
2025-04-20 20:40:07 +01:00
parent 7666191600
commit 9985e32d0b
9 changed files with 707 additions and 4 deletions

View File

@@ -6,6 +6,7 @@ import Sidebar from './sidebar.js';
import Update from './update.js';
import Main from './main.js';
import { BACKGROUND_COLOUR_MAIN_DARK, BACKGROUND_COLOUR_MAIN_LIGHT, BACKGROUND_COLOUR_SECONDARY_DARK, BACKGROUND_COLOUR_SECONDARY_LIGHT, TEXT_COLOUR_DARK, TEXT_COLOUR_LIGHT } from '../constants.js';
import StatusUpdates from './status-updates.js';
const REFRESH_INTERVAL = 30 * 1000; // 30 seconds
@@ -45,6 +46,7 @@ export default function App(props: AppProps) {
<View style={[styles.main, theme.main]}>
<ScrollView style={styles.scroller} contentContainerStyle={styles.scrollerContent}>
<StatusUpdates />
<Update />
{selectedUser ? <Main key={selectedUser.user.id} user={selectedUser}

View File

@@ -0,0 +1,48 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import ipc from '../ipc.js';
import { useAccentColour, useEventListener } from '../util.js';
import type { StatusUpdate } from '../../../common/status.js';
import { BORDER_COLOUR_SECONDARY_DARK, TEXT_COLOUR_DARK, UPDATE_COLOUR } from '../constants.js';
import { Button } from '../components/index.js';
enum StatusUpdateFlag {
HIDDEN = 0,
}
export default function StatusUpdates() {
const accent_colour = useAccentColour();
const [status_updates, setStatusUpdateData] = useState<StatusUpdate[] | null>(null);
useEffect(() => (ipc.getStatusUpdateData().then(setStatusUpdateData), undefined), [ipc]);
useEventListener(ipc.events, 'status-updates', setStatusUpdateData, [ipc.events]);
return status_updates?.map(status_update => status_update.flags & (1 << StatusUpdateFlag.HIDDEN) ? null : <View style={[styles.container, status_update.colour ? {backgroundColor: status_update.colour} : null]}>
<Text style={styles.updateText}>{status_update.content}</Text>
{status_update.action ? <View style={styles.updateButton}>
<Button title={status_update.action.label}
onPress={() => ipc.openExternalUrl(status_update.action!.url)}
color={'#' + accent_colour} />
</View> : null}
</View>);
}
const styles = StyleSheet.create({
container: {
backgroundColor: UPDATE_COLOUR,
borderBottomWidth: 1,
borderBottomColor: BORDER_COLOUR_SECONDARY_DARK,
paddingVertical: 8,
paddingHorizontal: 20,
flexDirection: 'row',
alignItems: 'center',
},
updateText: {
marginVertical: 4,
flex: 1,
color: TEXT_COLOUR_DARK,
},
updateButton: {
marginLeft: 14,
},
});

View File

@@ -1,12 +1,17 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import ipc from '../ipc.js';
import { useAccentColour, useEventListener } from '../util.js';
import type { UpdateCacheData } from '../../../common/update.js';
import { TEXT_COLOUR_DARK, UPDATE_COLOUR } from '../constants.js';
import type { StatusUpdate } from '../../../common/status.js';
import { BORDER_COLOUR_SECONDARY_DARK, TEXT_COLOUR_DARK, UPDATE_COLOUR } from '../constants.js';
import { Button } from '../components/index.js';
enum StatusUpdateFlag {
SUPPRESS_UPDATE_BANNER = 1,
}
export default function Update() {
const accent_colour = useAccentColour();
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'update' });
@@ -15,7 +20,15 @@ export default function Update() {
useEffect(() => (ipc.getUpdateData().then(setUpdateData), undefined), [ipc]);
useEventListener(ipc.events, 'nxapi:update:latest', setUpdateData, [ipc.events]);
return update && 'update_available' in update && update.update_available ? <View style={styles.container}>
const [status_updates, setStatusUpdateData] = useState<StatusUpdate[] | null>(null);
useEffect(() => (ipc.getStatusUpdateData().then(setStatusUpdateData), undefined), [ipc]);
useEventListener(ipc.events, 'status-updates', setStatusUpdateData, [ipc.events]);
const status_update_suppress_update_banner = useMemo(() =>
status_updates?.find(s => s.flags & (1 << StatusUpdateFlag.SUPPRESS_UPDATE_BANNER)),
[status_updates]);
return update && 'update_available' in update && update.update_available && !status_update_suppress_update_banner ? <View style={styles.container}>
<Text style={styles.updateText}>{t('update_available', {name: update.latest.name})}</Text>
<View style={styles.updateButton}>
<Button title={t('download')}
@@ -35,6 +48,8 @@ export default function Update() {
const styles = StyleSheet.create({
container: {
backgroundColor: UPDATE_COLOUR,
borderBottomWidth: 1,
borderBottomColor: BORDER_COLOUR_SECONDARY_DARK,
paddingVertical: 8,
paddingHorizontal: 20,
flexDirection: 'row',

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, session, Settings } from 'electron';
import { app, BrowserWindow, ipcMain, Notification, session, Settings } from 'electron';
import process from 'node:process';
import * as path from 'node:path';
import { EventEmitter } from 'node:events';
@@ -24,6 +24,7 @@ import { addUserAgent } from '../../util/useragent.js';
import { initStorage, paths } from '../../util/storage.js';
import createI18n, { languages } from '../i18n/index.js';
import { CoralApiInterface } from '../../api/coral.js';
import { StatusUpdateIdentifierSymbol, StatusUpdateMonitor, StatusUpdateNotify, StatusUpdateResult, StatusUpdateSubscriber } from '../../common/status.js';
const debug = createDebug('app:main');
@@ -60,6 +61,7 @@ export class App {
readonly store: Store;
readonly monitors: PresenceMonitorManager;
readonly updater = new Updater();
readonly statusupdates = new StatusUpdateMonitor();
menu: MenuApp | null = null;
constructor(storage: persist.LocalStorage, readonly i18n: i18n) {
@@ -192,6 +194,12 @@ export async function init() {
app.setAppUserModelId('uk.org.fancy.nxapi.app');
}
appinstance.statusupdates.subscribe({
onUpdate: data => sendToAllWindows('nxapi:statusupdates', data),
});
appinstance.statusupdates.subscribe(new StatusUpdateNotificationSubscriber(appinstance));
appinstance.store.restoreMonitorState(appinstance.monitors);
const menu = new MenuApp(appinstance);
@@ -294,6 +302,65 @@ export async function handleOpenFriendCodeUri(app: App, uri: string) {
});
}
interface StatusUpdateNotificationsCache {
notified: {
id: string;
notified_at: number;
}[];
}
class StatusUpdateNotificationSubscriber implements StatusUpdateSubscriber {
constructor(readonly app: App) {
//
}
_cache: StatusUpdateNotificationsCache | null = null;
_cache_updated = false;
_load_cache: Promise<StatusUpdateNotificationsCache> | null = null;
async getNotificationsCache() {
if (this._cache) return this._cache;
if (this._load_cache) return this._load_cache;
return this._load_cache = this.app.store.storage.getItem('StatusUpdateNotifications')
.then(c => this._cache = (c as StatusUpdateNotificationsCache ?? {notified: []}))
.finally(() => this._load_cache = null);
}
async onUpdate(data: StatusUpdateResult) {
if (this._cache && this._cache_updated) {
await this.app.store.storage.setItem('StatusUpdateNotifications', this._cache);
}
}
async onInitialUpdate(data: StatusUpdateResult) {
await Promise.all(data.map(s => this.onNewStatusUpdate(s)));
}
async onNewStatusUpdate(status_update: StatusUpdateResult[0]) {
if (status_update.notify === StatusUpdateNotify.NO) return;
const cache = await this.getNotificationsCache();
const id = status_update[StatusUpdateIdentifierSymbol];
if (cache.notified.find(s => s.id === id)) {
debug('skipping status update notification, already notified', id);
return;
}
const notification = new Notification({
title: status_update.title,
body: status_update.notification_content ?? status_update.content,
silent: status_update.notify === StatusUpdateNotify.SILENT,
});
notification.show();
cache.notified.push({id, notified_at: Date.now()});
this._cache_updated = true;
}
}
class Updater {
private _cache: UpdateCacheData | null = null;
private _check: Promise<UpdateCacheData | null> | null = null;

View File

@@ -66,6 +66,8 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
handle('update:get', () => appinstance.updater.cache ?? appinstance.updater.check());
handle('update:check', () => appinstance.updater.check());
handle('statusupdates:get', () => appinstance.statusupdates.cache ?? []);
handle('statusupdates:refresh', () => appinstance.statusupdates.forceUpdate());
setTimeout(async () => {
const update = await appinstance.updater.check();

View File

@@ -6,6 +6,7 @@ import type { DiscordPresenceConfiguration, DiscordPresenceSource, DiscordStatus
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 { StatusUpdate } from '../../common/status.js';
import type { Announcements, CoralSuccessResponse, CurrentUser, Friend, FriendCodeUrl, FriendCodeUser, GetActiveEventResult, WebService, WebServices } from '../../api/coral-types.js';
import type { DiscordPresence } from '../../discord/types.js';
import type { NintendoAccountUser } from '../../api/na.js';
@@ -49,6 +50,8 @@ const ipc = {
getUpdateData: () => inv<UpdateCacheData | null>('update:get'),
checkUpdates: () => inv<UpdateCacheData | null>('update:check'),
getStatusUpdateData: () => inv<StatusUpdate[] | null>('statusupdates:get'),
forceUpdateStatusUpdates: () => inv<StatusUpdate[] | null>('statusupdates:refresh'),
listNintendoAccounts: () => inv<string[] | undefined>('accounts:list'),
addCoralAccount: () => inv<string>('accounts:add-coral'),
@@ -108,6 +111,7 @@ const ipc = {
export type NxapiElectronIpc = typeof ipc;
ipcRenderer.on('nxapi:window:refresh', () => events.emit('window:refresh') || location.reload());
ipcRenderer.on('nxapi:statusupdates', (e, s: StatusUpdate[]) => events.emit('status-updates', s));
ipcRenderer.on('nxapi:accounts:shouldrefresh', () => events.emit('update-nintendo-accounts'));
ipcRenderer.on('nxapi:discord:shouldrefresh', () => events.emit('update-discord-presence-source'));
ipcRenderer.on('nxapi:discord:presence', (e, p: DiscordPresence) => events.emit('update-discord-presence', p));

View File

@@ -9,3 +9,4 @@ export * as presenceEmbedRender from './presence-embed-render.js';
export * as presenceEmbedServer from './presence-embed-server.js';
export * as logArchive from './log-archive.js';
export * as decryptLogArchive from './decrypt-log-archive.js';
export * as status from './status.js';

47
src/cli/util/status.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { Arguments as ParentArguments } from './index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { StatusUpdateMonitor } from '../../common/status.js';
const debug = createDebug('cli:util:status');
export const command = 'status';
export const desc = 'Show nxapi service status updates';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('url', {
describe: 'Additional status update source',
type: 'array',
}).option('json', {
describe: 'Output raw JSON',
type: 'boolean',
}).option('json-pretty-print', {
describe: 'Output pretty-printed JSON',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
// const { default: config } = await import('../../common/remote-config.js');
const status = new StatusUpdateMonitor();
for (const url of argv.url ?? []) {
status.addSource(url.toString());
}
const result = await status.checkStatusUpdates();
if (argv.jsonPrettyPrint) {
console.log(JSON.stringify(result, null, 4));
return;
}
if (argv.json) {
console.log(JSON.stringify(result));
return;
}
console.log('Status updates', result);
}

517
src/common/status.ts Normal file
View File

@@ -0,0 +1,517 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { setTimeout } from 'node:timers';
import { fetch } from 'undici';
import createDebug from '../util/debug.js';
import { git, version } from '../util/product.js';
import { paths } from '../util/storage.js';
import { timeoutSignal } from '../util/misc.js';
import { ErrorResponse, ResponseSymbol } from '../api/util.js';
import { getUserAgent } from '../util/useragent.js';
const debug = createDebug('nxapi:status');
/** Maximum time in seconds to consider cached data fresh */
const MAX_FRESH = 24 * 60 * 60; // 1 day in seconds
/** Maximum time in seconds to allow using cached data after it's considered stale */
const MAX_STALE = 24 * 60 * 60; // 1 day in seconds
export const SourceSymbol = Symbol('Source');
const CachedSymbol = Symbol('Cached');
const FailedSymbol = Symbol('Failed');
export const StatusUpdateIdentifierSymbol = Symbol('StatusUpdateIdentifier');
export interface StatusUpdateResult extends Array<StatusUpdate & {
[StatusUpdateIdentifierSymbol]: string;
[SourceSymbol]: string;
}> {
[SourceSymbol]: UpdateCacheData[];
[FailedSymbol]: UpdateCacheDataFailed[];
}
export interface StatusUpdateSubscriber {
onUpdate(data: StatusUpdateResult): void;
onInitialUpdate?(data: StatusUpdateResult): void;
onNewStatusUpdate?(data: StatusUpdateResult[0]): void;
onRemovedStatusUpdate?(data: StatusUpdateResult[0]): void;
onUpdatedStatusUpdate?(data: StatusUpdateResult[0], previous: StatusUpdateResult[0]): void;
onError?(err: unknown): void;
}
interface StatusUpdateSourceHandle {
cancel(): void;
}
export class StatusUpdateMonitor {
subscribers: StatusUpdateSubscriber[] = [];
sources: [url: string, handle: StatusUpdateSourceHandle][] = [];
interval_ms = 10 * 60 * 1000; // 10 minutes
cache: StatusUpdateResult | null = null;
addSource(url: string) {
const handle: StatusUpdateSourceHandle = {
cancel: () => this.removeSource(handle),
};
this.sources.push([url, handle]);
if (this._timeout) this.forceUpdate();
}
removeSource(handle: StatusUpdateSourceHandle) {
let index;
let removed = 0;
while ((index = this.sources.findIndex(s => s[1] === handle)) >= 0) {
this.sources.splice(index, 1);
removed++;
}
return !!removed;
}
private _timeout: NodeJS.Timeout | null = null;
private _timeout_at: number | null = null;
start(now = true) {
if (this._timeout) return;
if (now) {
this._timeout = setTimeout(() => {}, 0);
this._checkStatusUpdatesInterval();
} else {
this._timeout = setTimeout(() => this._checkStatusUpdatesInterval(), this.interval_ms);
this._timeout_at = Date.now();
}
}
stop() {
if (!this._timeout) return false;
clearTimeout(this._timeout);
this._timeout = null;
this._timeout_at = null;
}
forceUpdate() {
if (this._timeout && !this._timeout_at) return;
clearTimeout(this._timeout!);
this._checkStatusUpdatesInterval();
}
subscribe(subscriber: StatusUpdateSubscriber, start = true) {
if (this.subscribers.includes(subscriber)) return;
this.subscribers.push(subscriber);
if (start) this.start();
}
unsubscribe(subscriber: StatusUpdateSubscriber, stop = true) {
let index;
let removed = 0;
while ((index = this.subscribers.indexOf(subscriber)) >= 0) {
this.subscribers.splice(index, 1);
removed++;
}
if (!removed) {
debug('attempted to unsubscribe but already not subscribed');
return;
}
if (stop && !this.subscribers.length) {
this.stop();
}
}
private async _checkStatusUpdatesInterval() {
this._timeout_at = null;
try {
const result = await this.checkStatusUpdates();
if (this.cache) {
const added = result.filter(s => !this.cache!.find(a =>
s[StatusUpdateIdentifierSymbol] === a[StatusUpdateIdentifierSymbol]));
const removed = this.cache.filter(s => !result.find(a =>
s[StatusUpdateIdentifierSymbol] === a[StatusUpdateIdentifierSymbol]));
const updated = result.map(s => {
const existing = this.cache!.find(a =>
s[StatusUpdateIdentifierSymbol] === a[StatusUpdateIdentifierSymbol]);
return existing && JSON.stringify(s) === JSON.stringify(existing) ?
[s, existing] as const : null as never;
}).filter(s => s);
if (updated.length) {
const subscribers = this.subscribers.filter(s => s.onUpdatedStatusUpdate);
debug('notifying %d subscribers of updated status updates', subscribers.length);
for (const [status_update, previous] of updated) {
for (const subscriber of subscribers) {
Promise.resolve()
.then(() => subscriber.onUpdatedStatusUpdate!.call(subscriber, status_update, previous))
.catch(err => debug('error notifying subscriber', err, subscriber));
}
}
}
if (added.length) {
const subscribers = this.subscribers.filter(s => s.onNewStatusUpdate);
debug('notifying %d subscribers of new status updates', subscribers.length);
for (const status_update of added) {
for (const subscriber of subscribers) {
Promise.resolve()
.then(() => subscriber.onNewStatusUpdate!.call(subscriber, status_update))
.catch(err => debug('error notifying subscriber', err, subscriber));
}
}
}
if (removed.length) {
const subscribers = this.subscribers.filter(s => s.onRemovedStatusUpdate);
debug('notifying %d subscribers of removed status updates', subscribers.length);
for (const status_update of removed) {
for (const subscriber of subscribers) {
Promise.resolve()
.then(() => subscriber.onRemovedStatusUpdate!.call(subscriber, status_update))
.catch(err => debug('error notifying subscriber', err, subscriber));
}
}
}
} else {
const subscribers = this.subscribers.filter(s => s.onInitialUpdate);
debug('notifying %d subscribers of new status updates', subscribers.length);
for (const subscriber of subscribers) {
Promise.resolve()
.then(() => subscriber.onInitialUpdate!.call(subscriber, result))
.catch(err => debug('error notifying subscriber', err, subscriber));
}
}
const subscribers = [...this.subscribers];
debug('notifying %d subscribers of result', subscribers.length);
for (const subscriber of subscribers) {
Promise.resolve()
.then(() => subscriber.onUpdate.call(subscriber, result))
.catch(err => debug('error notifying subscriber', err, subscriber));
}
this.cache = result;
} catch (err) {
debug('error checking status updates', err);
const subscribers = this.subscribers.filter(s => s.onError);
debug('notifying %d subscribers of error result', subscribers.length);
for (const subscriber of subscribers) {
Promise.resolve()
.then(() => subscriber.onError!.call(subscriber, err))
.catch(err => debug('error notifying subscriber', err, subscriber));
}
} finally {
if (!this._timeout || this._timeout_at) return;
this._timeout = setTimeout(() => this._checkStatusUpdatesInterval(), this.interval_ms);
this._timeout_at = Date.now();
}
}
async checkStatusUpdates() {
const source_urls = this.getSourceUrls();
debug('checking status updates', source_urls.map(u => {
const url = new URL(u[0]);
url.hash = '#' + u[1];
return url;
}));
const results = await Promise.all(source_urls.map(async ([url, params]) => {
const data = await this.fetchStatusUpdateSource(url.href);
return [data, url, params] as const;
}));
const status_updates: StatusUpdateResult = Object.assign([], {
[SourceSymbol]: results.map(r => r[0]),
[FailedSymbol]: [],
});
const ids = new Set<string>();
for (const [data, url, params] of results) {
if (!('data' in data)) {
status_updates[FailedSymbol].push(data);
continue;
}
for (const status_update of data.data.status_updates) {
const id = data.url + '#' + encodeURIComponent(status_update.id);
if (ids.has(id)) {
debug('duplicate status update %s from %s', status_update.id, data.url);
continue;
}
ids.add(id);
if (status_update.tags.length && // params.has('tag') &&
!params.getAll('tag').find(t => status_update.tags.includes(t))
) continue;
status_updates.push(Object.assign(status_update, {
[StatusUpdateIdentifierSymbol]: id,
[SourceSymbol]: data.url,
}));
}
}
return status_updates;
}
getSourceUrls() {
const source_urls: [url: URL, params: URLSearchParams][] = [];
for (const [url_str, handle] of this.sources) {
const url = new URL(url_str);
const hash = url.hash.substring(1);
url.hash = '';
const params = new URLSearchParams(hash);
const existing = source_urls.find(u => u[0].href === url.href);
if (existing) {
for (const [key, value] of params) {
existing[1].append(key, value);
}
} else {
source_urls.push([url, params]);
}
}
return source_urls;
}
async fetchStatusUpdateSource(url: string) {
await fs.mkdir(paths.cache, {recursive: true});
const cache_key = createHash('sha256').update(url).digest('base64url');
const update_cache_path = path.resolve(paths.cache, 'status-' + cache_key + '.json');
const url_parsed = new URL(url);
if (url_parsed.protocol === 'file:') {
const file = fileURLToPath(url_parsed);
const stats = await fs.stat(file);
const config: StatusUpdateSourceResult = {
...JSON.parse(await fs.readFile(file, 'utf-8')),
[SourceSymbol]: url_parsed.toString(),
};
const cache: UpdateCacheDataSuccess = {
created_at: stats.ctimeMs,
updated_at: stats.mtimeMs,
etag: null,
revalidated_at: Date.now(),
stale_at: null,
expires_at: Date.now(),
version,
revision: git?.revision ?? null,
url: url_parsed.toString(),
headers: {},
data: config,
};
return cache;
}
let data: UpdateCacheData | undefined = undefined;
let must_revalidate = true;
try {
data = JSON.parse(await fs.readFile(update_cache_path, 'utf-8'));
if (data && 'data' in data) {
Object.defineProperty(data.data, SourceSymbol, {
enumerable: true,
value: data.url,
});
}
if (data?.version !== version || data.revision !== (git?.revision ?? null)) {
debug('Cached status update data is for a different nxapi revision');
data = undefined;
}
if (data && 'data' in data && (data.stale_at ?? data.expires_at) > Date.now()) {
// Response is still fresh
return data;
}
if (data && data.expires_at > Date.now()) {
// Response is stale, but not expired
must_revalidate = false;
}
} catch (err) {}
try {
debug('Getting status updates from %s, must revalidate: %s', url, must_revalidate);
const config = await this.fetchStatusUpdateSourceHttp(url, undefined, data && 'data' in data ? {
previous: data.data,
updated_at: new Date(data.updated_at),
etag: data.etag,
} : undefined);
const response = config[ResponseSymbol];
const cache_directives = (response.headers.get('Cache-Control') ?? '')
.split(',').map(d => d.trim().toLowerCase()).filter(d => d);
const max_age_directive = cache_directives.find(d => d.startsWith('max-age='))?.substr(8);
const max_age = max_age_directive ? Math.min(parseInt(max_age_directive), MAX_FRESH) : null;
const stale_ie_directive = cache_directives.find(d => d.startsWith('stale-if-error='))?.substr(15);
const stale_ie = stale_ie_directive ? Math.min(parseInt(stale_ie_directive), MAX_STALE) : null;
const stale_at = max_age ? Date.now() + (max_age * 1000) : null;
const expires_at =
cache_directives.includes('no-store') || cache_directives.includes('no-cache') ? 0 :
stale_ie && max_age ? Date.now() + (max_age * 1000) + (stale_ie * 1000) :
stale_at ?? 0;
const new_cache: UpdateCacheDataSuccess = {
created_at: config[CachedSymbol] ? data!.created_at : Date.now(),
updated_at: new Date(response.headers.get('Last-Modified') ?? Date.now()).getTime(),
etag: response.headers.get('ETag'),
revalidated_at: config[CachedSymbol] ? Date.now() : null,
stale_at,
expires_at,
version,
revision: git?.revision ?? null,
url: response.url,
headers: Object.fromEntries(response.headers.entries()),
data: config,
};
await fs.writeFile(update_cache_path, JSON.stringify(new_cache, null, 4) + '\n', 'utf-8');
return new_cache;
} catch (err) {
// Throw if the data was never loaded or has expired
if (data && 'data' in data && !must_revalidate) throw err;
const new_cache: UpdateCacheDataFailed = {
created_at: Date.now(),
expires_at: Date.now() + 1800000, // 30 minutes
version,
revision: git?.revision ?? null,
error_message: (err as Error).message,
};
await fs.writeFile(update_cache_path, JSON.stringify(new_cache, null, 4) + '\n');
return new_cache;
}
}
async fetchStatusUpdateSourceHttp(url: string, useragent?: string, cache?: {
previous: StatusUpdateSourceResult;
updated_at: Date;
etag: string | null;
}) {
const [signal, cancel] = timeoutSignal();
const response = await fetch(url, {
headers: {
'User-Agent': getUserAgent(),
'X-nxapi-Version': version,
'X-nxapi-Revision': git?.revision ?? undefined!,
'If-Modified-Since': cache ? cache.updated_at.toUTCString() : undefined!,
'If-None-Match': cache?.etag ?? undefined!,
},
signal,
}).finally(cancel);
if (cache && response.status === 304) {
return Object.assign({}, cache.previous, {
[SourceSymbol]: url,
[ResponseSymbol]: response,
[CachedSymbol]: true,
});
}
if (response.status !== 200) {
throw new ErrorResponse('[nxapi] Unknown error', response, await response.text());
}
const data = await response.json() as StatusUpdateSourceResult;
return Object.assign(data, {
[SourceSymbol]: url,
[ResponseSymbol]: response,
[CachedSymbol]: false,
});
}
}
export type UpdateCacheData = UpdateCacheDataSuccess | UpdateCacheDataFailed;
export interface UpdateCacheDataSuccess {
created_at: number;
updated_at: number;
etag: string | null;
revalidated_at: number | null;
/** Timestamp we must attempt to update the cache, but can continue to use the data if it fails */
stale_at: number | null;
/** Timestamp we must discard the cache require re-downloading the data */
expires_at: number;
version: string;
revision: string | null;
url: string;
headers: Record<string, string | string[]>;
data: StatusUpdateSourceResult;
}
export interface UpdateCacheDataFailed {
created_at: number;
expires_at: number;
version: string;
revision: string | null;
error_message: string;
}
interface StatusUpdateSourceResult {
status_updates: StatusUpdate[];
}
export interface StatusUpdate {
id: string;
title: string;
content: string;
action: {
label: string;
url: string;
} | null;
colour: string | null;
notify: StatusUpdateNotify;
notification_content: string | null;
tags: string[];
flags: number;
}
export enum StatusUpdateNotify {
NO = 0,
SILENT = 1,
NOTIFY = 2,
}
export enum StatusUpdateFlag {
HIDDEN = 0,
SUPPRESS_UPDATE_BANNER = 1,
}