From 9a3cdfbc1a43a333c615621669a87d112da53184 Mon Sep 17 00:00:00 2001 From: Mo Date: Thu, 7 Jul 2022 11:55:07 -0500 Subject: [PATCH] chore: mobile web bridge concept (#1228) --- packages/mobile/MobileWebAppContainer.tsx | 125 ++++++++++++++++-- .../WebFrame/DeviceInterface.template.js | 15 +++ .../mobile/WebFrame/MessageSender.template.js | 31 +++++ packages/mobile/src/AppStack.tsx | 9 +- packages/web/src/javascripts/App.tsx | 15 ++- 5 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 packages/mobile/WebFrame/DeviceInterface.template.js create mode 100644 packages/mobile/WebFrame/MessageSender.template.js diff --git a/packages/mobile/MobileWebAppContainer.tsx b/packages/mobile/MobileWebAppContainer.tsx index c50b04d70..f5e03251c 100644 --- a/packages/mobile/MobileWebAppContainer.tsx +++ b/packages/mobile/MobileWebAppContainer.tsx @@ -1,26 +1,131 @@ -import React from 'react' +import { MobileDeviceInterface } from '@Lib/Interface' +import React, { useMemo, useRef } from 'react' import { Platform } from 'react-native' -import { WebView } from 'react-native-webview' +import { WebView, WebViewMessageEvent } from 'react-native-webview' export const MobileWebAppContainer = () => { - const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/loader.html' - const params = 'platform=' + Platform.OS + const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html' + const webViewRef = useRef(null) + + const device = useMemo(() => new MobileDeviceInterface(), []) + const functions = Object.getOwnPropertyNames(Object.getPrototypeOf(device)) + + const baselineFunctions: Record = { + isDeviceDestroyed: `(){ + return false + }`, + } + + let stringFunctions = '' + for (const [key, value] of Object.entries(baselineFunctions)) { + stringFunctions += `${key}${value}` + } + + for (const functionName of functions) { + if (functionName === 'constructor' || baselineFunctions[functionName]) { + continue + } + + stringFunctions += ` + ${functionName}(...args) { + return this.sendMessage('${functionName}', args); + } + ` + } + + const WebProcessDeviceInterface = ` + class WebProcessDeviceInterface { + constructor(messageSender) { + this.appVersion = '1.2.3' + this.environment = 1 + this.databases = [] + this.messageSender = messageSender + } + + setApplication() {} + + sendMessage(functionName, args) { + return this.messageSender.sendMessage(functionName, args) + } + + ${stringFunctions} + } + ` + + const WebProcessMessageSender = ` + class WebProcessMessageSender { + constructor() { + this.pendingMessages = [] + window.addEventListener('message', this.handleMessageFromReactNative.bind(this)) + } + + handleMessageFromReactNative(event) { + const message = event.data + try { + const parsed = JSON.parse(message) + const { messageId, returnValue } = parsed + const pendingMessage = this.pendingMessages.find((m) => m.messageId === messageId) + pendingMessage.resolve(returnValue) + this.pendingMessages.splice(this.pendingMessages.indexOf(pendingMessage), 1) + } catch (error) { + console.log('Error parsing message from React Native', message, error) + } + } + + sendMessage(functionName, args) { + const messageId = Math.random() + window.ReactNativeWebView.postMessage(JSON.stringify({ functionName: functionName, args: args, messageId })) + return new Promise((resolve) => { + this.pendingMessages.push({ + messageId, + resolve, + }) + }) + } + } + ` + const injectedJS = ` - if (!window.location.search) { - var link = document.getElementById('web-bundle-progress-bar'); - link.href = './src/index.html?${params}'; - link.click(); - }` + + console.log = (...args) => { + window.ReactNativeWebView.postMessage('[web log] ' + args.join(' ')); + } + + ${WebProcessDeviceInterface} + ${WebProcessMessageSender} + + const messageSender = new WebProcessMessageSender(); + window.reactNativeDevice = new WebProcessDeviceInterface(messageSender); + + true; + ` + + const onMessage = (event: WebViewMessageEvent) => { + const message = event.nativeEvent.data + try { + const functionData = JSON.parse(message) + void onFunctionMessage(functionData.functionName, functionData.messageId, functionData.args) + } catch (error) { + console.log('onGeneralMessage', JSON.stringify(message)) + } + } + + const onFunctionMessage = async (functionName: string, messageId: string, args: any) => { + const returnValue = await (device as any)[functionName](...args) + console.log(`Native device function ${functionName} called`) + webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue })) + } /* eslint-disable @typescript-eslint/no-empty-function */ return ( {}} onError={(err) => console.error('An error has occurred', err)} onHttpError={() => console.error('An HTTP error occurred')} - onMessage={() => {}} + onMessage={onMessage} allowFileAccess={true} injectedJavaScript={injectedJS} /> diff --git a/packages/mobile/WebFrame/DeviceInterface.template.js b/packages/mobile/WebFrame/DeviceInterface.template.js new file mode 100644 index 000000000..43adb537a --- /dev/null +++ b/packages/mobile/WebFrame/DeviceInterface.template.js @@ -0,0 +1,15 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +class WebProcessDeviceInterface { + constructor(messageSender) { + this.appVersion = '1.2.3' + this.environment = 1 + this.databases = [] + this.messageSender = messageSender + } + + setApplication() {} + + sendMessage(functionName, args) { + return this.messageSender.sendMessage(functionName, args) + } +} diff --git a/packages/mobile/WebFrame/MessageSender.template.js b/packages/mobile/WebFrame/MessageSender.template.js new file mode 100644 index 000000000..a7df0e037 --- /dev/null +++ b/packages/mobile/WebFrame/MessageSender.template.js @@ -0,0 +1,31 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +class WebProcessMessageSender { + constructor() { + this.pendingMessages = [] + window.addEventListener('message', this.handleMessageFromReactNative.bind(this)) + } + + handleMessageFromReactNative(event) { + const message = event.data + try { + const parsed = JSON.parse(message) + const { messageId, returnValue } = parsed + const pendingMessage = this.pendingMessages.find((m) => m.messageId === messageId) + pendingMessage.resolve(returnValue) + this.pendingMessages.splice(this.pendingMessages.indexOf(pendingMessage), 1) + } catch (error) { + console.log('Error parsing message from React Native', message, error) + } + } + + sendMessage(functionName, args) { + const messageId = Math.random() + window.ReactNativeWebView.postMessage(JSON.stringify({ functionName: functionName, args: args, messageId })) + return new Promise((resolve) => { + this.pendingMessages.push({ + messageId, + resolve, + }) + }) + } +} diff --git a/packages/mobile/src/AppStack.tsx b/packages/mobile/src/AppStack.tsx index 0aa321bf5..c6e303215 100644 --- a/packages/mobile/src/AppStack.tsx +++ b/packages/mobile/src/AppStack.tsx @@ -1,16 +1,17 @@ import { AppStateEventType, AppStateType, TabletModeChangeData } from '@Lib/ApplicationState' import { useHasEditor, useIsLocked } from '@Lib/SnjsHelperHooks' import { ScreenStatus } from '@Lib/StatusManager' +import { IsDev } from '@Lib/Utils' import { CompositeNavigationProp, RouteProp } from '@react-navigation/native' import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack' import { HeaderTitleView } from '@Root/Components/HeaderTitleView' import { IoniconsHeaderButton } from '@Root/Components/IoniconsHeaderButton' import { Compose } from '@Root/Screens/Compose/Compose' -import { Root } from '@Root/Screens/Root' import { SCREEN_COMPOSE, SCREEN_NOTES, SCREEN_VIEW_PROTECTED_NOTE } from '@Root/Screens/screens' import { MainSideMenu } from '@Root/Screens/SideMenu/MainSideMenu' import { NoteSideMenu } from '@Root/Screens/SideMenu/NoteSideMenu' import { ViewProtectedNote } from '@Root/Screens/ViewProtectedNote/ViewProtectedNote' +import { Root } from '@Screens/Root' import { UuidString } from '@standardnotes/snjs' import { ICON_MENU } from '@Style/Icons' import { ThemeService } from '@Style/ThemeService' @@ -20,10 +21,14 @@ import { Dimensions, Keyboard, ScaledSize } from 'react-native' import DrawerLayout, { DrawerState } from 'react-native-gesture-handler/DrawerLayout' import { HeaderButtons, Item } from 'react-navigation-header-buttons' import { ThemeContext } from 'styled-components' +import { MobileWebAppContainer } from '../MobileWebAppContainer' import { HeaderTitleParams } from './App' import { ApplicationContext } from './ApplicationContext' import { ModalStackNavigationProp } from './ModalStack' +const IS_DEBUGGING_WEB_APP = false +const DEFAULT_TO_WEB_APP = IsDev && IS_DEBUGGING_WEB_APP + export type AppStackNavigatorParamList = { [SCREEN_NOTES]: HeaderTitleParams [SCREEN_COMPOSE]: HeaderTitleParams & { @@ -196,7 +201,7 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) = ), })} - component={Root} + component={DEFAULT_TO_WEB_APP ? MobileWebAppContainer : Root} /> { + const device = window.reactNativeDevice || new WebDevice(WebAppVersion) + startApplication(window.defaultSyncServer, device, window.enabledUnfinishedFeatures, window.websocketUrl).catch( + console.error, + ) + }, ReactNativeWebViewInitializationTimeout) } else { window.startApplication = startApplication }