diff --git a/src/app/main/ipc.ts b/src/app/main/ipc.ts index caca4f3..a5f2b76 100644 --- a/src/app/main/ipc.ts +++ b/src/app/main/ipc.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, clipboard, dialog, IpcMain, KeyboardEvent, LoginIte import * as util from 'node:util'; import createDebug from 'debug'; import { User } from 'discord-rpc'; -import openWebService, { WebServiceIpc, WebServiceValidationError } from './webservices.js'; +import openWebService, { QrCodeReaderOptions, WebServiceIpc, WebServiceValidationError } from './webservices.js'; import { createWindow, getWindowConfiguration } from './windows.js'; import { DiscordPresenceConfiguration, DiscordPresenceSource, WindowType } from '../common/types.js'; import { CurrentUser, Friend, Game, PresenceState, WebService } from '../../api/coral-types.js'; @@ -197,6 +197,11 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) { 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)); + ipcMain.handle('nxapi:webserviceapi:openQrCodeReader', (e, data: QrCodeReaderOptions) => webserviceipc.openQrCodeReader(e, data)); + ipcMain.handle('nxapi:webserviceapi:closeQrCodeReader', e => webserviceipc.closeQrCodeReader(e)); + ipcMain.handle('nxapi:webserviceapi:sendMessage', (e, data: string) => webserviceipc.sendMessage(e, data)); + ipcMain.handle('nxapi:webserviceapi:copyToClipboard', (e, data: string) => webserviceipc.copyToClipboard(e, data)); + ipcMain.handle('nxapi:webserviceapi:downloadImages', (e, data: string) => webserviceipc.downloadImages(e, data)); ipcMain.handle('nxapi:webserviceapi:completeLoading', e => webserviceipc.completeLoading(e)); store.on('update-nintendo-accounts', () => sendToAllWindows('nxapi:accounts:shouldrefresh')); diff --git a/src/app/main/webservices.ts b/src/app/main/webservices.ts index 9a5df92..0cb0720 100644 --- a/src/app/main/webservices.ts +++ b/src/app/main/webservices.ts @@ -4,13 +4,12 @@ import * as fs from 'node:fs/promises'; import { Buffer } from 'node:buffer'; import * as util from 'node:util'; import createDebug from 'debug'; -import { app, BrowserWindow, dialog, IpcMainInvokeEvent, nativeTheme, ShareMenu, shell, WebContents } from './electron.js'; +import { app, BrowserWindow, clipboard, dialog, IpcMainInvokeEvent, nativeImage, nativeTheme, Notification, ShareMenu, shell, WebContents } from './electron.js'; import fetch from 'node-fetch'; import CoralApi from '../../api/coral.js'; -import { dev } from '../../util/product.js'; import { CurrentUser, WebService, WebServiceToken } from '../../api/coral-types.js'; import { Store } from './index.js'; -import type { NativeShareRequest, NativeShareUrlRequest } from '../preload-webservice/znca-js-api.js'; +import type { DownloadImagesRequest, NativeShareRequest, NativeShareUrlRequest, QrCodeReaderCameraOptions, QrCodeReaderCheckinOptions, QrCodeReaderCheckinResult, QrCodeReaderPhotoLibraryOptions, SendMessageOptions } from '../preload-webservice/znca-js-api.js'; import { SavedToken } from '../../common/auth/coral.js'; import { createWebServiceWindow } from './windows.js'; import { askUserForUri } from './util.js'; @@ -201,6 +200,11 @@ export interface WebServiceData { url: string; } +export interface QrCodeReaderOptions { + type: 'camera' | 'photolibrary' | 'checkin'; + data: string; +} + export class WebServiceIpc { constructor( store: Store @@ -251,7 +255,7 @@ export class WebServiceIpc { if (data.text) texts.push(data.text); if (data.hashtags) texts.push(data.hashtags.map(t => '#' + t).join(' ')); - const imagepath = await this.downloadShareImage(data); + const imagepath = await this.downloadShareImage(data.image_url); const menu = new ShareMenu({ texts, @@ -261,9 +265,9 @@ export class WebServiceIpc { menu.popup({window: BrowserWindow.fromWebContents(event.sender)!}); } - private async downloadShareImage(req: NativeShareRequest) { + private async downloadShareImage(image_url: string) { const dir = app.getPath('downloads'); - const basename = path.basename(new URL(req.image_url).pathname); + const basename = path.basename(new URL(image_url).pathname); const extname = path.extname(basename); let filename; let i = 0; @@ -274,9 +278,9 @@ export class WebServiceIpc { filename = i === 1 ? basename : basename.substr(0, basename.length - extname.length) + ' ' + i + extname; } while (await this.pathExists(path.join(dir, filename))); - debug('Downloading image %s to %s as %s', req.image_url, dir, filename); + debug('Downloading image %s to %s as %s', image_url, dir, filename); - const response = await fetch(req.image_url, { + const response = await fetch(image_url, { headers: { 'User-Agent': '', }, @@ -343,6 +347,83 @@ export class WebServiceIpc { await store.storage.setItem(key, data); } + async openQrCodeReader(event: IpcMainInvokeEvent, options: QrCodeReaderOptions): Promise { + const {nsoAccount, webservice} = this.getWindowData(event.sender); + + debug('openQrCodeReader', webservice.name, nsoAccount.user.name, options); + + if (options.type === 'checkin') { + const request: QrCodeReaderCheckinOptions = JSON.parse(options.data); + + const result: QrCodeReaderCheckinResult = { + status: 'ERROR', + text: null, + }; + + return JSON.stringify(result); + } + + // camera/photolibrary + const request: QrCodeReaderCameraOptions | QrCodeReaderPhotoLibraryOptions = JSON.parse(options.data); + + return ''; + } + + async closeQrCodeReader(event: IpcMainInvokeEvent): Promise { + const {nsoAccount, webservice} = this.getWindowData(event.sender); + + debug('closeQrCodeReader', webservice.name, nsoAccount.user.name); + + // + } + + async sendMessage(event: IpcMainInvokeEvent, json: string): Promise { + const {nsoAccount, webservice} = this.getWindowData(event.sender); + + const data: SendMessageOptions = JSON.parse(json); + + debug('sendMessage', webservice.name, nsoAccount.user.name, data); + + if (data.type === 'B_SHOW_SUCCESS') { + dialog.showMessageBox(BrowserWindow.fromWebContents(event.sender)!, { + message: data.message, + }); + } else if (data.type === 'B_SHOW_ERROR') { + dialog.showMessageBox(BrowserWindow.fromWebContents(event.sender)!, { + type: 'error', + message: data.message, + }); + } else { + debug('Unsupported message type', data.type); + } + } + + async copyToClipboard(event: IpcMainInvokeEvent, data: string): Promise { + const {nsoAccount, webservice} = this.getWindowData(event.sender); + + debug('copyToClipboard', webservice.name, nsoAccount.user.name, data); + + clipboard.writeText(data); + } + + async downloadImages(event: IpcMainInvokeEvent, json: string): Promise { + const {nsoAccount, webservice} = this.getWindowData(event.sender); + + const data: DownloadImagesRequest = JSON.parse(json); + + debug('downloadImages', webservice.name, nsoAccount.user.name, data); + + for (const url of data.image_urls) { + const imagepath = await this.downloadShareImage(url); + + new Notification({ + title: 'Image saved from ' + webservice.name, + body: 'Image downloaded to ' + imagepath, + icon: nativeImage.createFromPath(imagepath), + }).show(); + } + } + async completeLoading(event: IpcMainInvokeEvent): Promise { const {nsoAccount, webservice} = this.getWindowData(event.sender); diff --git a/src/app/preload-webservice/ipc.ts b/src/app/preload-webservice/ipc.ts index 1366472..5e7e4de 100644 --- a/src/app/preload-webservice/ipc.ts +++ b/src/app/preload-webservice/ipc.ts @@ -1,7 +1,7 @@ import { ipcRenderer, IpcRendererEvent } from 'electron'; import { EventEmitter } from 'events'; import createDebug from 'debug'; -import { WebServiceData } from '../main/webservices.js'; +import { QrCodeReaderOptions, WebServiceData } from '../main/webservices.js'; const debug = createDebug('app:preload-webservice:ipc'); @@ -14,6 +14,11 @@ const ipc = { 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, + openQrCodeReader: (data: QrCodeReaderOptions) => ipcRenderer.invoke('nxapi:webserviceapi:openQrCodeReader', data) as Promise, + closeQrCodeReader: () => ipcRenderer.invoke('nxapi:webserviceapi:closeQrCodeReader') as Promise, + sendMessage: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:sendMessage', data) as Promise, + copyToClipboard: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:copyToClipboard', data) as Promise, + downloadImages: (data: string) => ipcRenderer.invoke('nxapi:webserviceapi:downloadImages', data) as Promise, completeLoading: () => ipcRenderer.invoke('nxapi:webserviceapi:completeLoading') as Promise, }; diff --git a/src/app/preload-webservice/znca-js-api.ts b/src/app/preload-webservice/znca-js-api.ts index 73bfac0..531182f 100644 --- a/src/app/preload-webservice/znca-js-api.ts +++ b/src/app/preload-webservice/znca-js-api.ts @@ -7,58 +7,143 @@ const debug = createDebug('app:preload-webservice:znca-js-api'); // 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; + interface Window extends Partial { + jsBridge?: WebServiceJsApi; - /** - * 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?: (data: string) => void; - - // NookLink - openQRCodeReader?: (data: string) => void; - // NookLink - openQRCodeReaderFromPhotoLibrary?: (data: string) => void; onQRCodeRead?: (data: string) => void; - // NookLink - closeQRCodeReader?: () => void; - // NookLink - closeQRCodeReaderFromPhotoLibrary?: () => void; - - // Unused - sendMessage?(data: string): void; - // SplatNet 3 - copyToClipboard?(data: string): void; - // SplatNet 3 - openQRCodeReaderForCheckin?(data: string): void; onQRCodeReadForCheckin?(data: string): void; - // SplatNet 3 - downloadImages?(imagesJson: string): void; - // SplatNet 3 - completeLoading?(): void; - // SplatNet 3 - closeWebView?(): void; - // SplatNet 3 - reloadExtension?(): void; } } +interface WebServiceJsApi { + /** + * Downloads an image and opens the native share menu. + * + * Used by SplatNet 2. + * + * Called as: + * + * ```js + * window.invokeNativeShare(JSON.stringify({text: e.text, image_url: e.url, hashtags: e.hashtags})) + * ``` + */ + invokeNativeShare(data: string): void; + /** + * Opens the native share menu. + * + * Used by Smash World. + * + * ```js + * window.invokeNativeShareUrl(JSON.stringify({url: '', text: ''})) + * ``` + */ + invokeNativeShareUrl(data: string): void; + + /** + * Requests a web service token from the Coral API. + * `window.onGameWebTokenReceive` is called with the returned token. + * + * Used by NookLink and SplatNet 3. + */ + requestGameWebToken(): void; + + /** + * Load persistent data for this web service. + * `window.onPersistentDataRestore` is called with the stored data. + * + * Used by NookLink. + */ + restorePersistentData(): void; + /** + * Store persistent data for this web service. + * `window.onPersistentDataStore` is called when complete. + * + * Used by NookLink. + */ + storePersistentData(data: string): void; + + /** + * Open the QR code reader. + * `window.onQRCodeRead` is called with the base64-encoded result. + * + * Used by NookLink. + */ + openQRCodeReader(data: string): void; + /** + * Open the QR code reader. + * `window.onQRCodeRead` is called with the base64-encoded result. + * + * Used by NookLink. + */ + openQRCodeReaderFromPhotoLibrary(data: string): void; + /** + * Close the QR code reader. + * + * Used by NookLink. + */ + closeQRCodeReader(): void; + /** + * Close the QR code reader. + * + * Used by NookLink. + */ + closeQRCodeReaderFromPhotoLibrary(): void; + + /** + * Send a message to the app's main thread. + * This is used to show native message dialogs and control the QR code reader. + * + * Used by NookLink. + */ + sendMessage(data: string): void; + + /** + * Writes text to the clipboard. + * + * Used by SplatNet 3. + */ + copyToClipboard(data: string): void; + + /** + * Opens the QR code reader. + * `window.onQRCodeReadForCheckin` is called with a JSON document containing the result. + * + * Used by SplatNet 3. + */ + openQRCodeReaderForCheckin(data: string): void; + + /** + * Download images and save them to the photo library. + * + * Used by SplatNet 3. + */ + downloadImages(imagesJson: string): void; + + /** + * Report the web service is ready to show and hide the loading screen. + * Web services that set the `fullScreen` attribute to `true` must call this. + * + * Used by SplatNet 3. + */ + completeLoading(): void; + /** + * Closes the web service. + * Web services that set the `fullScreen` attribute to `true` must have a button that calls this. + * + * Used by SplatNet 3. + */ + closeWebView(): void; + /** + * Asks the OS to reload any native widget extensions. + * + * Used by SplatNet 3. + */ + reloadExtension(): void; +} + // // Share // @@ -86,9 +171,6 @@ function invokeNativeShareUrl(data: string) { ipc.invokeNativeShareUrl(data); } -window.invokeNativeShare = invokeNativeShare; -window.invokeNativeShareUrl = invokeNativeShareUrl; - // // Web service token // @@ -103,8 +185,6 @@ function requestGameWebToken() { }); } -window.requestGameWebToken = requestGameWebToken; - // // Persistent data // @@ -125,68 +205,126 @@ function storePersistentData(data: string) { }); } -window.restorePersistentData = restorePersistentData; -window.storePersistentData = storePersistentData; - // // QR code scanner // -function openQrCodeReader(data: string) { +export interface QrCodeReaderCameraOptions { + messageResources: { + Camera_Page_Title: string; + Camera_Label_WaitingCameraPermission: string; + Camera_Label_WaitingCameraPermissionDescription: string; + Camera_Label_ChangeSetting: string; + Camera_Label_Searching: string; + Camera_Label_ProDialog1stQRCode: string; + Camera_Label_ProDialog1stQRCodeDescription: string; + Camera_Label_Pro2ndQRCodeRead: string; + Camera_Label_Pro3rdQRCodeRead: string; + Camera_Label_Pro4thQRCodeRead: string; + Cmn_Dialog_Button_Ok: string; + Cmn_Dialog_Button_Close: string; + }; +} +export interface QrCodeReaderPhotoLibraryOptions { + messageResources: { + PhotoLibrary_Page_Title: string; + PhotoLibrary_Label_WaitingPhotoLibraryPermission: string; + PhotoLibrary_Label_WaitingPhotoLibraryPermissionDescription: string; + PhotoLibrary_Label_ChangeSetting: string; + PhotoLibrary_Label_Header: string; + PhotoLibrary_Label_Notice: string; + PhotoLibrary_Label_SelectPhoto: string; + PhotoLibrary_Label_ProDialog1stQRCode: string; + PhotoLibrary_Label_ProDialog1stQRCodeDescription: string; + PhotoLibrary_Label_Pro2ndQRCodeRead: string; + PhotoLibrary_Label_Pro3rdQRCodeRead: string; + PhotoLibrary_Label_Pro4thQRCodeRead: string; + Cmn_Dialog_Button_Ok: string; + Cmn_Dialog_Button_Close: string; + Error_Dialog_Message_Multiple_Error: string; + Error_Dialog_Message_Unknown_Error: string; + }; +} + +function openQrCodeReader(/** JSON.stringify(data: QrCodeReaderCameraOptions) */ data: string) { debug('openQRCodeReader called', data); - Promise.resolve().then(() => { - const base64EncodeText = ''; + ipc.openQrCodeReader({ + type: 'camera', + data, + }).then(result => { + const base64EncodeText = result; window.onQRCodeRead?.call(null, base64EncodeText); }); } function closeQrCodeReader() { - // + ipc.closeQrCodeReader(); } -function openQrCodeReaderFromPhotoLibrary(data: string) { +function openQrCodeReaderFromPhotoLibrary(/** JSON.stringify(data: QrCodeReaderPhotoLibraryOptions) */ data: string) { debug('openQRCodeReaderFromPhotoLibrary called', data); - Promise.resolve().then(() => { - const base64EncodeText = ''; + ipc.openQrCodeReader({ + type: 'photolibrary', + data, + }).then(result => { + const base64EncodeText = result; window.onQRCodeRead?.call(null, base64EncodeText); }); } function closeQrCodeReaderFromPhotoLibrary() { - // + ipc.closeQrCodeReader(); } -function openQRCodeReaderForCheckin(data: string) { - // +export interface QrCodeReaderCheckinOptions { + source: 'camera' | 'photo_library'; +} +export type QrCodeReaderCheckinResult = { + status: 'SUCCEEDED'; + /** base64 encoded data */ + text: string; +} | { + status: 'CANCELLED' | 'ERROR'; + text: null; +}; - Promise.resolve().then(() => { - const base64EncodeText = ''; - window.onQRCodeReadForCheckin?.call(null, base64EncodeText); +function openQRCodeReaderForCheckin(/** JSON.stringify(data: QrCodeReaderCheckinOptions) */ data: string) { + debug('openQRCodeReaderForCheckin called', data); + + ipc.openQrCodeReader({ + type: 'checkin', + data, + }).then(result => { + window.onQRCodeReadForCheckin?.call(null, result); }); } -window.openQRCodeReader = openQrCodeReader; -window.openQRCodeReaderFromPhotoLibrary = openQrCodeReaderFromPhotoLibrary; -window.closeQRCodeReader = closeQrCodeReader; -window.closeQRCodeReaderFromPhotoLibrary = closeQrCodeReaderFromPhotoLibrary; -window.openQRCodeReaderForCheckin = openQRCodeReaderForCheckin; - // // Other // -function sendMessage(data: string) { - // +export interface SendMessageOptions { + type: 'B_SHOW_SUCCESS' | 'B_SHOW_ERROR' | 'B_SET_INDEX'; + message: string; +} + +function sendMessage(/** JSON.stringify(data: SendMessageOptions) */ data: string) { debug('sendMessage called', data); + ipc.sendMessage(data); } function copyToClipboard(data: string) { - // debug('copyToClipboard called', data); + ipc.copyToClipboard(data); } -function downloadImages(imagesJson: string) { +export interface DownloadImagesRequest { + image_urls: string[]; +} + +function downloadImages(/** JSON.stringify(data: DownloadImagesRequest) */ imagesJson: string) { debug('downloadImages called', imagesJson); + ipc.downloadImages(imagesJson); } function completeLoading() { @@ -201,9 +339,24 @@ function reloadExtension() { debug('reloadExtension called'); } -window.sendMessage = sendMessage; -window.copyToClipboard = copyToClipboard; -window.downloadImages = downloadImages; -window.completeLoading = completeLoading; -window.closeWebView = closeWebView; -window.reloadExtension = reloadExtension; +const api: WebServiceJsApi = { + invokeNativeShare, + invokeNativeShareUrl, + requestGameWebToken, + restorePersistentData, + storePersistentData, + openQRCodeReader: openQrCodeReader, + closeQRCodeReader: closeQrCodeReader, + openQRCodeReaderFromPhotoLibrary: openQrCodeReaderFromPhotoLibrary, + closeQRCodeReaderFromPhotoLibrary: closeQrCodeReaderFromPhotoLibrary, + sendMessage, + copyToClipboard, + openQRCodeReaderForCheckin: openQRCodeReaderForCheckin, + downloadImages, + completeLoading, + closeWebView, + reloadExtension, +}; + +window.jsBridge = api; +Object.assign(window, api);