From 68246fbb6b0e52d60d9311b489570be29d9bf8a1 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Sun, 17 Apr 2022 00:56:08 +0100 Subject: [PATCH] Implement APIs for web services (required for NookLink) --- rollup.config.js | 23 ++++ src/app/electron.ts | 2 + src/app/main/index.ts | 10 +- src/app/main/menu.ts | 4 +- src/app/main/webservices.ts | 97 +++++++++++++++-- src/app/preload-webservice/index.ts | 161 ++++++++++++++++++++++++++++ src/app/preload/index.ts | 2 +- src/cli/app.ts | 1 + 8 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 src/app/preload-webservice/index.ts diff --git a/rollup.config.js b/rollup.config.js index 9b3ef50..8364f6e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -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, ]; diff --git a/src/app/electron.ts b/src/app/electron.ts index 5773056..8ca2e0a 100644 --- a/src/app/electron.ts +++ b/src/app/electron.ts @@ -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; diff --git a/src/app/main/index.ts b/src/app/main/index.ts index 060f863..40dff6e 100644 --- a/src/app/main/index.ts +++ b/src/app/main/index.ts @@ -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')); diff --git a/src/app/main/menu.ts b/src/app/main/menu.ts index 1795989..68516e1 100644 --- a/src/app/main/menu.ts +++ b/src/app/main/menu.ts @@ -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); }, })); } diff --git a/src/app/main/webservices.ts b/src/app/main/webservices.ts index 31c926c..8c3e44f 100644 --- a/src/app/main/webservices.ts +++ b/src/app/main/webservices.ts @@ -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(); +const windowapi = new WeakMap(); -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 { + 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 { + 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 { + // 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 { + 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 { + 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); + } +} diff --git a/src/app/preload-webservice/index.ts b/src/app/preload-webservice/index.ts new file mode 100644 index 0000000..9332aff --- /dev/null +++ b/src/app/preload-webservice/index.ts @@ -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, + invokeNativeShareUrl: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:invokeNativeShareUrl', data) as Promise, + requestGameWebToken: () => ipcRenderer.invoke('nxapi:webserviceapi:requestGameWebToken') as Promise, + restorePersistentData: () => ipcRenderer.invoke('nxapi:webserviceapi:restorePersistentData') as Promise, + storePersistentData: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:storePersistentData', data) as Promise, +}; + +// 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; diff --git a/src/app/preload/index.ts b/src/app/preload/index.ts index 7610946..9e46a7c 100644 --- a/src/app/preload/index.ts +++ b/src/app/preload/index.ts @@ -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 = { diff --git a/src/cli/app.ts b/src/cli/app.ts index fc34ddb..4d57a37 100644 --- a/src/cli/app.ts +++ b/src/cli/app.ts @@ -28,6 +28,7 @@ export async function handler(argv: ArgumentsCamelCase) { ], { stdio: 'inherit', env: { + ...process.env, NXAPI_SKIP_UPDATE_CHECK: '1', }, });