Fix reloading NookLink and handle errors updating presence in the app

This commit is contained in:
Samuel Elliott
2022-04-30 11:33:30 +01:00
parent ec700faa9b
commit 03aab05619
12 changed files with 3162 additions and 183 deletions

2923
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -66,12 +66,14 @@
"@types/yargs": "^17.0.9",
"caxa": "^2.1.0",
"electron": "^17.2.0",
"electron-builder": "^23.0.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-native-web": "^0.17.7",
"rollup": "^2.70.1",
"rollup-plugin-polyfill-node": "^0.8.0",
"ts-json-schema-generator": "^1.0.0",
"tslib": "^2.4.0",
"typescript": "^4.7.0-dev.20220308"
}
}

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import fetch, { RequestInfo, RequestInit, Response } from 'node-fetch';
import { ErrorResponse } from '../../index.js';
import { ErrorResponse } from '../../api/util.js';
export enum RequestState {
NOT_LOADING,

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, nativeImage, Notification } from '../electron.js';
import { app, BrowserWindow, dialog, ipcMain, nativeImage, Notification } from '../electron.js';
import * as path from 'path';
import { EventEmitter } from 'events';
import createDebug from 'debug';
@@ -6,12 +6,13 @@ import * as persist from 'node-persist';
import fetch from 'node-fetch';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import { getToken, initStorage, paths } from '../../util.js';
import { dev, getToken, initStorage, LoopResult, 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 { CurrentUser, Friend, Game, ZncErrorResponse } from '../../api/znc-types.js';
import { NotificationManager } from '../../cli/nso/notify.js';
import { ErrorResponse } from '../../api/util.js';
const debug = createDebug('app:main');
@@ -31,6 +32,7 @@ function createWindow() {
});
mainWindow.loadFile(path.join(bundlepath, 'index.html'));
if (dev) mainWindow.webContents.openDevTools();
}
app.whenReady().then(async () => {
@@ -53,6 +55,7 @@ app.whenReady().then(async () => {
ipcMain.handle('nxapi:moon:getcachedtoken', (e, token: string) => storage.getItem('MoonToken.' + token));
const webserviceipc = new WebServiceIpc(store);
ipcMain.on('nxapi:webserviceapi:getWebServiceSync', e => e.returnValue = webserviceipc.getWebService(e));
ipcMain.handle('nxapi:webserviceapi:invokeNativeShare', (e, data: string) => webserviceipc.invokeNativeShare(e, data));
ipcMain.handle('nxapi:webserviceapi:invokeNativeShareUrl', (e, data: string) => webserviceipc.invokeNativeShareUrl(e, data));
ipcMain.handle('nxapi:webserviceapi:requestGameWebToken', e => webserviceipc.requestGameWebToken(e));
@@ -241,6 +244,20 @@ export class EmbeddedPresenceMonitor extends ZncDiscordPresence {
this._running = 0;
}
}
async handleError(err: ErrorResponse<ZncErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
try {
return super.handleError(err);
} catch (err) {
if (err instanceof Error) {
dialog.showErrorBox(err.name, err.stack ?? err.message);
} else {
dialog.showErrorBox('Error', err as any);
}
return LoopResult.OK;
}
}
}
export class ElectronNotificationManager extends NotificationManager {

View File

@@ -2,10 +2,10 @@ import * as path from 'path';
import createDebug from 'debug';
import { BrowserWindow, IpcMainInvokeEvent, session, shell, WebContents } from '../electron.js';
import ZncApi from '../../api/znc.js';
import { SavedToken } from '../../util.js';
import { dev, SavedToken } from '../../util.js';
import { WebService } from '../../api/znc-types.js';
import { bundlepath, Store } from './index.js';
import type { NativeShareRequest, NativeShareUrlRequest } from '../preload-webservice/index.js';
import type { NativeShareRequest, NativeShareUrlRequest } from '../preload-webservice/znca-js-api.js';
const debug = createDebug('app:main:webservices');
@@ -87,7 +87,7 @@ export default async function openWebService(
webserviceToken,
});
window.webContents.openDevTools();
if (dev) window.webContents.openDevTools();
window.loadURL(url.toString(), {
extraHeaders: Object.entries({
@@ -98,6 +98,11 @@ export default async function openWebService(
});
}
export interface WebServiceData {
webservice: WebService;
url: string;
}
export class WebServiceIpc {
constructor(
store: Store
@@ -121,6 +126,22 @@ export class WebServiceIpc {
};
}
getWebService(event: IpcMainInvokeEvent): WebServiceData {
const {user, webservice} = this.getWindowData(event.sender);
const url = new URL(webservice.uri);
url.search = new URLSearchParams({
lang: user.language,
na_country: user.country,
na_lang: user.language,
}).toString();
return {
webservice,
url: url.toString(),
};
}
async invokeNativeShare(event: IpcMainInvokeEvent, json: string): Promise<void> {
const {nsoAccount, webservice} = this.getWindowData(event.sender);

View File

@@ -1,161 +1,7 @@
import { ipcRenderer } from 'electron';
import createDebug from 'debug';
// Logs are written to the browser window developer tools, and are hidden by default (enable verbose logs)
const debug = createDebug('app:preload-webservice');
const ipc = {
invokeNativeShare: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:invokeNativeShare', data) as Promise<void>,
invokeNativeShareUrl: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:invokeNativeShareUrl', data) as Promise<void>,
requestGameWebToken: () => ipcRenderer.invoke('nxapi:webserviceapi:requestGameWebToken') as Promise<string>,
restorePersistentData: () => ipcRenderer.invoke('nxapi:webserviceapi:restorePersistentData') as Promise<string | undefined>,
storePersistentData: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:storePersistentData', data) as Promise<void>,
};
// NookLink throws an error if restorePersistentData doesn't exist (and helpfully discards it somewhere)
// All others aren't required
declare global {
interface Window {
/**
* window.invokeNativeShare(JSON.stringify({text: e.text, image_url: e.url, hashtags: e.hashtags}))
*/
// SplatNet 2
invokeNativeShare?: (data: string) => void;
/**
* window.invokeNativeShareUrl(JSON.stringify({url: '', text: ''}))
*/
// Smash World
invokeNativeShareUrl?: (data: string) => void;
// NookLink
requestGameWebToken?: () => void;
onGameWebTokenReceive?: (token: string) => void;
// NookLink
restorePersistentData?: () => void;
onPersistentDataRestore?: (data: string) => void;
// NookLink
storePersistentData?: (data: string) => void;
onPersistentDataStore?: () => void;
// NookLink
openQRCodeReader?: (data: string) => void;
// NookLink
openQRCodeReaderFromPhotoLibrary?: (data: string) => void;
onQRCodeRead?: (data: string) => void;
// NookLink
closeQRCodeReader?: () => void;
// NookLink
closeQRCodeReaderFromPhotoLibrary?: () => void;
}
}
//
// Share
//
// Used by SplatNet 2 and Smash World (but both have a different implementation because Nintendo)
//
export interface NativeShareRequest {
text: string;
image_url: string;
hashtags: string[];
}
function invokeNativeShare(data: string) {
debug('invokeNativeShare called', data);
ipc.invokeNativeShare(data);
}
export interface NativeShareUrlRequest {
url: string;
text: string;
}
function invokeNativeShareUrl(data: string) {
debug('invokeNativeShareUrl called', data);
ipc.invokeNativeShareUrl(data);
}
window.invokeNativeShare = invokeNativeShare;
window.invokeNativeShareUrl = invokeNativeShareUrl;
//
// Web service token
//
// Used by NookLink
//
function requestGameWebToken() {
debug('requestGameWebToken called');
ipc.requestGameWebToken().then(token => {
window.onGameWebTokenReceive?.call(null, token);
});
}
window.requestGameWebToken = requestGameWebToken;
//
// Persistent data
//
// Used by NookLink
//
function restorePersistentData() {
debug('restorePersistentData called');
ipc.restorePersistentData().then(data => {
window.onPersistentDataRestore?.call(null, data ?? '');
});
}
function storePersistentData(data: string) {
debug('storePersistentData called', data);
ipc.storePersistentData(data).then(() => {
window.onPersistentDataStore?.call(null);
});
}
window.restorePersistentData = restorePersistentData;
window.storePersistentData = storePersistentData;
//
// QR code scanner
//
// Used by NookLink
//
function openQrCodeReader(data: string) {
debug('openQRCodeReader called', data);
Promise.resolve().then(() => {
const base64EncodeText = '';
window.onQRCodeRead?.call(null, base64EncodeText);
});
}
function closeQrCodeReader() {
//
}
function openQrCodeReaderFromPhotoLibrary(data: string) {
debug('openQRCodeReaderFromPhotoLibrary called', data);
Promise.resolve().then(() => {
const base64EncodeText = '';
window.onQRCodeRead?.call(null, base64EncodeText);
});
}
function closeQrCodeReaderFromPhotoLibrary() {
//
}
window.openQRCodeReader = openQrCodeReader;
window.openQRCodeReaderFromPhotoLibrary = openQrCodeReaderFromPhotoLibrary;
window.closeQRCodeReader = closeQrCodeReader;
window.closeQRCodeReaderFromPhotoLibrary = closeQrCodeReaderFromPhotoLibrary;
import './znca-js-api.js';
import './quirks/nooklink.js';

View File

@@ -0,0 +1,21 @@
import { ipcRenderer } from 'electron';
import createDebug from 'debug';
import { WebServiceData } from '../main/webservices.js';
const debug = createDebug('app:preload-webservice:ipc');
const ipc = {
getWebServiceSync: () => ipcRenderer.sendSync('nxapi:webserviceapi:getWebServiceSync') as WebServiceData,
invokeNativeShare: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:invokeNativeShare', data) as Promise<void>,
invokeNativeShareUrl: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:invokeNativeShareUrl', data) as Promise<void>,
requestGameWebToken: () => ipcRenderer.invoke('nxapi:webserviceapi:requestGameWebToken') as Promise<string>,
restorePersistentData: () => ipcRenderer.invoke('nxapi:webserviceapi:restorePersistentData') as Promise<string | undefined>,
storePersistentData: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:storePersistentData', data) as Promise<void>,
};
export default ipc;
export const {webservice, url: webserviceurl} = ipc.getWebServiceSync();
debug('Web service', webservice);
debug('Web service URL', webserviceurl);

View File

@@ -0,0 +1,22 @@
import createDebug from 'debug';
import { webservice, webserviceurl } from '../ipc.js';
const debug = createDebug('app:preload-webservice:quirks:nooklink');
//
// NookLink must start at the main page, otherwise it will fail to load.
// This is required so refreshing the page works.
//
const NOOKLINK_WEBSERVICE_ID = 4953919198265344;
if (webservice.id === NOOKLINK_WEBSERVICE_ID) {
const url = new URL(location.href);
const initurl = new URL(webserviceurl);
debug('URL', url, initurl);
if (url.origin === initurl.origin && url.href !== initurl.href) {
history.replaceState(null, document.title, initurl);
}
}

View File

@@ -0,0 +1,152 @@
import createDebug from 'debug';
import ipc from './ipc.js';
const debug = createDebug('app:preload-webservice:znca-js-api');
// NookLink throws an error if restorePersistentData doesn't exist (and helpfully discards it somewhere)
// All others aren't required
declare global {
interface Window {
/**
* window.invokeNativeShare(JSON.stringify({text: e.text, image_url: e.url, hashtags: e.hashtags}))
*/
// SplatNet 2
invokeNativeShare?: (data: string) => void;
/**
* window.invokeNativeShareUrl(JSON.stringify({url: '', text: ''}))
*/
// Smash World
invokeNativeShareUrl?: (data: string) => void;
// NookLink
requestGameWebToken?: () => void;
onGameWebTokenReceive?: (token: string) => void;
// NookLink
restorePersistentData?: () => void;
onPersistentDataRestore?: (data: string) => void;
// NookLink
storePersistentData?: (data: string) => void;
onPersistentDataStore?: () => void;
// NookLink
openQRCodeReader?: (data: string) => void;
// NookLink
openQRCodeReaderFromPhotoLibrary?: (data: string) => void;
onQRCodeRead?: (data: string) => void;
// NookLink
closeQRCodeReader?: () => void;
// NookLink
closeQRCodeReaderFromPhotoLibrary?: () => void;
}
}
//
// Share
//
// Used by SplatNet 2 and Smash World (but both have a different implementation because Nintendo)
//
export interface NativeShareRequest {
text: string;
image_url: string;
hashtags: string[];
}
function invokeNativeShare(data: string) {
debug('invokeNativeShare called', data);
ipc.invokeNativeShare(data);
}
export interface NativeShareUrlRequest {
url: string;
text: string;
}
function invokeNativeShareUrl(data: string) {
debug('invokeNativeShareUrl called', data);
ipc.invokeNativeShareUrl(data);
}
window.invokeNativeShare = invokeNativeShare;
window.invokeNativeShareUrl = invokeNativeShareUrl;
//
// Web service token
//
// Used by NookLink
//
function requestGameWebToken() {
debug('requestGameWebToken called');
ipc.requestGameWebToken().then(token => {
window.onGameWebTokenReceive?.call(null, token);
});
}
window.requestGameWebToken = requestGameWebToken;
//
// Persistent data
//
// Used by NookLink
//
function restorePersistentData() {
debug('restorePersistentData called');
ipc.restorePersistentData().then(data => {
window.onPersistentDataRestore?.call(null, data ?? '');
});
}
function storePersistentData(data: string) {
debug('storePersistentData called', data);
ipc.storePersistentData(data).then(() => {
window.onPersistentDataStore?.call(null);
});
}
window.restorePersistentData = restorePersistentData;
window.storePersistentData = storePersistentData;
//
// QR code scanner
//
// Used by NookLink
//
function openQrCodeReader(data: string) {
debug('openQRCodeReader called', data);
Promise.resolve().then(() => {
const base64EncodeText = '';
window.onQRCodeRead?.call(null, base64EncodeText);
});
}
function closeQrCodeReader() {
//
}
function openQrCodeReaderFromPhotoLibrary(data: string) {
debug('openQRCodeReaderFromPhotoLibrary called', data);
Promise.resolve().then(() => {
const base64EncodeText = '';
window.onQRCodeRead?.call(null, base64EncodeText);
});
}
function closeQrCodeReaderFromPhotoLibrary() {
//
}
window.openQRCodeReader = openQrCodeReader;
window.openQRCodeReaderFromPhotoLibrary = openQrCodeReaderFromPhotoLibrary;
window.closeQRCodeReader = closeQrCodeReader;
window.closeQRCodeReaderFromPhotoLibrary = closeQrCodeReaderFromPhotoLibrary;

View File

@@ -55,7 +55,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const table = new Table({
head: [
'ID',
'NA ID',
'NSA ID',
'Name',
'Status',
'Favourite?',
@@ -73,10 +73,13 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
friend.name,
online ?
'name' in friend.presence.game ?
'Playing ' + friend.presence.game.name + '; played for ' +
'Playing ' + friend.presence.game.name + ';\nplayed for ' +
hrduration(friend.presence.game.totalPlayTime) + ' since ' +
new Date(friend.presence.game.firstPlayedAt * 1000).toLocaleDateString('en-GB') :
'Online' :
friend.presence.state === PresenceState.INACTIVE ?
'Console online' + (friend.presence.logoutAt ?
'; last seen ' + new Date(friend.presence.logoutAt * 1000).toISOString() : '') :
friend.presence.logoutAt ?
'Last seen ' + new Date(friend.presence.logoutAt * 1000).toISOString() :
'Offline',

View File

@@ -184,7 +184,7 @@ export class ZncNotifications extends Loop {
name: '',
isFriend: true,
isFavoriteFriend: false,
isServiceUser: true,
isServiceUser: false,
friendCreatedAt: 0,
presence: await nso.fetch<Presence>('/friend/' + r.friend + '/presence'),
};
@@ -377,7 +377,7 @@ export class NotificationManager {
lastpresence ? ' (console was already online)' : '',
currenttitle.name, JSON.stringify(currenttitle.sysDescription),
hrduration(currenttitle.totalPlayTime),
new Date((currenttitle.firstPlayedAt ?? 0) * 1000).toString());
currenttitle.firstPlayedAt ? new Date(currenttitle.firstPlayedAt * 1000).toString() : 'now');
this.onFriendOnline(friend, prev, initialRun);
@@ -415,7 +415,7 @@ export class NotificationManager {
lasttitle.name, JSON.stringify(lasttitle.sysDescription),
lastpresence.state === PresenceState.ONLINE ? '' : ' (' + lastpresence.state + ')',
hrduration(currenttitle.totalPlayTime),
new Date((currenttitle.firstPlayedAt ?? 0) * 1000).toString());
currenttitle.firstPlayedAt ? new Date(currenttitle.firstPlayedAt * 1000).toString() : 'now');
this.onFriendPlayingChangeTitle(friend, prev, initialRun);
} else if (

View File

@@ -116,7 +116,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
if (argv.showEvent) throw new Error('--presence-url not compatible with --show-event');
if (argv.friendNsaid) throw new Error('--presence-url not compatible with --friend-nsaid');
if (argv.userNotifications) throw new Error('--presence-url not compatible with --user-notifications');
if (argv.friendNotifications) throw new Error('--presence-url not compatible with --user-notifications');
if (argv.friendNotifications) throw new Error('--presence-url not compatible with --friend-notifications');
const i = new ZncProxyDiscordPresence(argv, argv.presenceUrl);