Implement APIs for web services (required for NookLink)

This commit is contained in:
Samuel Elliott 2022-04-17 00:56:08 +01:00
parent 3502b7ab87
commit 68246fbb6b
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
8 changed files with 291 additions and 9 deletions

View File

@ -29,6 +29,28 @@ const preload = {
],
};
const preload_webservice = {
input: 'src/app/preload-webservice/index.ts',
output: {
file: 'dist/app/bundle/preload-webservice.cjs',
format: 'cjs',
},
plugins: [
typescript({
noEmit: true,
declaration: false,
}),
commonjs({
// the ".ts" extension is required
extensions: ['.js', '.jsx', '.ts', '.tsx'],
esmExternals: true,
}),
],
external: [
'electron',
],
};
const browser = {
input: 'src/app/browser/index.ts',
output: {
@ -63,5 +85,6 @@ const browser = {
export default [
preload,
preload_webservice,
browser,
];

View File

@ -16,7 +16,9 @@ export const shell = electron.shell;
export const Tray = electron.Tray;
export type BrowserWindow = import('electron').BrowserWindow;
export type IpcMainInvokeEvent = import('electron').IpcMainInvokeEvent;
export type Menu = import('electron').Menu;
export type MenuItem = import('electron').MenuItem;
export type Notification = import('electron').Notification;
export type Tray = import('electron').Tray;
export type WebContents = import('electron').WebContents;

View File

@ -8,11 +8,12 @@ import { EventEmitter } from 'events';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import { WebServiceIpc } from './webservices.js';
const debug = createDebug('app:main');
const __dirname = path.join(import.meta.url.substr(7), '..');
const bundlepath = path.join(import.meta.url.substr(7), '..', '..', 'bundle');
export const bundlepath = path.join(import.meta.url.substr(7), '..', '..', 'bundle');
function createWindow() {
// Create the browser window.
@ -48,6 +49,13 @@ app.whenReady().then(async () => {
ipcMain.handle('nxapi:moon:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken-pctl.' + id));
ipcMain.handle('nxapi:moon:getcachedtoken', (e, token: string) => storage.getItem('MoonToken.' + token));
const webserviceipc = new WebServiceIpc(store);
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));
ipcMain.handle('nxapi:webserviceapi:restorePersistentData', e => webserviceipc.restorePersistentData(e));
ipcMain.handle('nxapi:webserviceapi:storePersistentData', (e, data: string) => webserviceipc.storePersistentData(e, data));
const sendToAllWindows = (channel: string, ...args: any[]) =>
BrowserWindow.getAllWindows().forEach(w => w.webContents.send(channel, ...args));
store.on('update-nintendo-accounts', () => sendToAllWindows('nxapi:accounts:shouldrefresh'));

View File

@ -41,6 +41,8 @@ export default class MenuApp {
...await this.getWebServiceItems(token) as any,
],
});
menu.append(item);
}
menu.append(new MenuItem({label: 'Add account', click: this.addNsoAccount}));
@ -146,7 +148,7 @@ export default class MenuApp {
click: async () => {
const {nso, data} = await getToken(this.store.storage, token);
await openWebService(nso, data, webservice);
await openWebService(this.store, token, nso, data, webservice);
},
}));
}

View File

@ -1,13 +1,16 @@
import * as path from 'path';
import createDebug from 'debug';
import { BrowserWindow, session, shell } from '../electron.js';
import { BrowserWindow, IpcMainInvokeEvent, session, shell, WebContents } from '../electron.js';
import ZncApi from '../../api/znc.js';
import { 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';
const debug = createDebug('app:main:webservices');
export function createWebServiceWindow(na_id: string, webservice: WebService) {
const browser_session = session.fromPartition('webservices-' + na_id, {
export function createWebServiceWindow(nsa_id: string, webservice: WebService) {
const browser_session = session.fromPartition('webservices-' + nsa_id, {
cache: false,
});
@ -18,6 +21,8 @@ export function createWebServiceWindow(na_id: string, webservice: WebService) {
title: webservice.name,
webPreferences: {
session: browser_session,
preload: path.join(bundlepath, 'preload-webservice.cjs'),
contextIsolation: false,
scrollBounce: true,
},
});
@ -26,9 +31,12 @@ export function createWebServiceWindow(na_id: string, webservice: WebService) {
}
const windows = new Map<string, BrowserWindow>();
const windowapi = new WeakMap<WebContents, [Store, string, ZncApi, SavedToken, WebService]>();
export default async function openWebService(nso: ZncApi, data: SavedToken, webservice: WebService) {
const windowid = data.user.id + ':' + webservice.id;
export default async function openWebService(
store: Store, token: string, nso: ZncApi, data: SavedToken, webservice: WebService
) {
const windowid = data.nsoAccount.user.nsaId + ':' + webservice.id;
if (windows.has(windowid)) {
const window = windows.get(windowid)!;
@ -38,10 +46,14 @@ export default async function openWebService(nso: ZncApi, data: SavedToken, webs
return;
}
const window = createWebServiceWindow(data.user.id, webservice);
const window = createWebServiceWindow(data.nsoAccount.user.nsaId, webservice);
windows.set(windowid, window);
windowapi.set(window.webContents, [store, token, nso, data, webservice]);
window.on('closed', () => {
windows.delete(windowid);
// windowapi.delete(window.webContents);
});
window.webContents.userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Mobile/15E148 Safari/604.1';
@ -77,3 +89,76 @@ export default async function openWebService(nso: ZncApi, data: SavedToken, webs
}).map(([key, value]) => key + ': ' + value).join('\n'),
});
}
export class WebServiceIpc {
constructor(
store: Store
) {}
private getWindowData(window: WebContents) {
const data = windowapi.get(window);
if (!data) {
throw new Error('Unknown window');
}
return {
store: data[0],
token: data[1],
nso: data[2],
nintendoAccountToken: data[3].nintendoAccountToken,
user: data[3].user,
nsoAccount: data[3].nsoAccount,
webservice: data[4],
};
}
async invokeNativeShare(event: IpcMainInvokeEvent, json: string): Promise<void> {
const {nsoAccount, webservice} = this.getWindowData(event.sender);
const data: NativeShareRequest = JSON.parse(json);
debug('invokeNativeShare', webservice.name, nsoAccount.user.name, data);
}
async invokeNativeShareUrl(event: IpcMainInvokeEvent, json: string): Promise<void> {
const {nsoAccount, webservice} = this.getWindowData(event.sender);
const data: NativeShareUrlRequest = JSON.parse(json);
debug('invokeNativeShareUrl', webservice.name, nsoAccount.user.name, data);
}
async requestGameWebToken(event: IpcMainInvokeEvent): Promise<string> {
// TODO: if the web service token has expired the NSO app token will probably have expired as well
// This needs to renew that token if necessary
const {nso, nsoAccount, webservice} = this.getWindowData(event.sender);
debug('Web service %s, user %s, called requestGameWebToken', webservice.name, nsoAccount.user.name);
const webserviceToken = await nso.getWebServiceToken('' + webservice.id);
return webserviceToken.result.accessToken;
}
async restorePersistentData(event: IpcMainInvokeEvent): Promise<string | undefined> {
const {store, nsoAccount, webservice} = this.getWindowData(event.sender);
debug('Web service %s, user %s, called restorePersistentData', webservice.name, nsoAccount.user.name);
const key = 'WebServicePersistentData.' + nsoAccount.user.nsaId + '.' + webservice.id;
const data: string | undefined = await store.storage.getItem(key);
return data;
}
async storePersistentData(event: IpcMainInvokeEvent, data: string): Promise<void> {
const {store, nsoAccount, webservice} = this.getWindowData(event.sender);
debug('Web service %s, user %s, called storePersistentData', webservice.name, nsoAccount.user.name, data);
const key = 'WebServicePersistentData.' + nsoAccount.user.nsaId + '.' + webservice.id;
await store.storage.setItem(key, data);
}
}

View File

@ -0,0 +1,161 @@
import { contextBridge, 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;

View File

@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron';
import * as EventEmitter from 'events';
import { EventEmitter } from 'events';
import { SavedMoonToken, SavedToken } from '../../util.js';
const ipc = {

View File

@ -28,6 +28,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
], {
stdio: 'inherit',
env: {
...process.env,
NXAPI_SKIP_UPDATE_CHECK: '1',
},
});