chore: mobile web bridge concept (#1228)

This commit is contained in:
Mo
2022-07-07 11:55:07 -05:00
committed by GitHub
parent a59065d1d6
commit 9a3cdfbc1a
5 changed files with 177 additions and 18 deletions

View File

@@ -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 { Platform } from 'react-native'
import { WebView } from 'react-native-webview' import { WebView, WebViewMessageEvent } from 'react-native-webview'
export const MobileWebAppContainer = () => { export const MobileWebAppContainer = () => {
const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/loader.html' const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html'
const params = 'platform=' + Platform.OS const webViewRef = useRef<WebView>(null)
const device = useMemo(() => new MobileDeviceInterface(), [])
const functions = Object.getOwnPropertyNames(Object.getPrototypeOf(device))
const baselineFunctions: Record<string, any> = {
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 = ` const injectedJS = `
if (!window.location.search) {
var link = document.getElementById('web-bundle-progress-bar'); console.log = (...args) => {
link.href = './src/index.html?${params}'; window.ReactNativeWebView.postMessage('[web log] ' + args.join(' '));
link.click(); }
}`
${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 */ /* eslint-disable @typescript-eslint/no-empty-function */
return ( return (
<WebView <WebView
ref={webViewRef}
source={{ uri: sourceUri }} source={{ uri: sourceUri }}
originWhitelist={['*']} originWhitelist={['*']}
onLoad={() => {}} onLoad={() => {}}
onError={(err) => console.error('An error has occurred', err)} onError={(err) => console.error('An error has occurred', err)}
onHttpError={() => console.error('An HTTP error occurred')} onHttpError={() => console.error('An HTTP error occurred')}
onMessage={() => {}} onMessage={onMessage}
allowFileAccess={true} allowFileAccess={true}
injectedJavaScript={injectedJS} injectedJavaScript={injectedJS}
/> />

View File

@@ -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)
}
}

View File

@@ -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,
})
})
}
}

View File

@@ -1,16 +1,17 @@
import { AppStateEventType, AppStateType, TabletModeChangeData } from '@Lib/ApplicationState' import { AppStateEventType, AppStateType, TabletModeChangeData } from '@Lib/ApplicationState'
import { useHasEditor, useIsLocked } from '@Lib/SnjsHelperHooks' import { useHasEditor, useIsLocked } from '@Lib/SnjsHelperHooks'
import { ScreenStatus } from '@Lib/StatusManager' import { ScreenStatus } from '@Lib/StatusManager'
import { IsDev } from '@Lib/Utils'
import { CompositeNavigationProp, RouteProp } from '@react-navigation/native' import { CompositeNavigationProp, RouteProp } from '@react-navigation/native'
import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack' import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack'
import { HeaderTitleView } from '@Root/Components/HeaderTitleView' import { HeaderTitleView } from '@Root/Components/HeaderTitleView'
import { IoniconsHeaderButton } from '@Root/Components/IoniconsHeaderButton' import { IoniconsHeaderButton } from '@Root/Components/IoniconsHeaderButton'
import { Compose } from '@Root/Screens/Compose/Compose' 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 { SCREEN_COMPOSE, SCREEN_NOTES, SCREEN_VIEW_PROTECTED_NOTE } from '@Root/Screens/screens'
import { MainSideMenu } from '@Root/Screens/SideMenu/MainSideMenu' import { MainSideMenu } from '@Root/Screens/SideMenu/MainSideMenu'
import { NoteSideMenu } from '@Root/Screens/SideMenu/NoteSideMenu' import { NoteSideMenu } from '@Root/Screens/SideMenu/NoteSideMenu'
import { ViewProtectedNote } from '@Root/Screens/ViewProtectedNote/ViewProtectedNote' import { ViewProtectedNote } from '@Root/Screens/ViewProtectedNote/ViewProtectedNote'
import { Root } from '@Screens/Root'
import { UuidString } from '@standardnotes/snjs' import { UuidString } from '@standardnotes/snjs'
import { ICON_MENU } from '@Style/Icons' import { ICON_MENU } from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService' 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 DrawerLayout, { DrawerState } from 'react-native-gesture-handler/DrawerLayout'
import { HeaderButtons, Item } from 'react-navigation-header-buttons' import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import { ThemeContext } from 'styled-components' import { ThemeContext } from 'styled-components'
import { MobileWebAppContainer } from '../MobileWebAppContainer'
import { HeaderTitleParams } from './App' import { HeaderTitleParams } from './App'
import { ApplicationContext } from './ApplicationContext' import { ApplicationContext } from './ApplicationContext'
import { ModalStackNavigationProp } from './ModalStack' import { ModalStackNavigationProp } from './ModalStack'
const IS_DEBUGGING_WEB_APP = false
const DEFAULT_TO_WEB_APP = IsDev && IS_DEBUGGING_WEB_APP
export type AppStackNavigatorParamList = { export type AppStackNavigatorParamList = {
[SCREEN_NOTES]: HeaderTitleParams [SCREEN_NOTES]: HeaderTitleParams
[SCREEN_COMPOSE]: HeaderTitleParams & { [SCREEN_COMPOSE]: HeaderTitleParams & {
@@ -196,7 +201,7 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) =
</HeaderButtons> </HeaderButtons>
), ),
})} })}
component={Root} component={DEFAULT_TO_WEB_APP ? MobileWebAppContainer : Root}
/> />
<AppStack.Screen <AppStack.Screen
name={SCREEN_COMPOSE} name={SCREEN_COMPOSE}

View File

@@ -15,6 +15,7 @@ declare global {
electronAppVersion?: string electronAppVersion?: string
webClient?: DesktopManagerInterface webClient?: DesktopManagerInterface
electronRemoteBridge?: unknown electronRemoteBridge?: unknown
reactNativeDevice?: WebDevice
application?: WebApplication application?: WebApplication
mainApplicationGroup?: ApplicationGroup mainApplicationGroup?: ApplicationGroup
@@ -88,12 +89,14 @@ const startApplication: StartApplication = async function startApplication(
} }
if (IsWebPlatform) { if (IsWebPlatform) {
startApplication( const ReactNativeWebViewInitializationTimeout = 0
window.defaultSyncServer,
new WebDevice(WebAppVersion), setTimeout(() => {
window.enabledUnfinishedFeatures, const device = window.reactNativeDevice || new WebDevice(WebAppVersion)
window.websocketUrl, startApplication(window.defaultSyncServer, device, window.enabledUnfinishedFeatures, window.websocketUrl).catch(
).catch(console.error) console.error,
)
}, ReactNativeWebViewInitializationTimeout)
} else { } else {
window.startApplication = startApplication window.startApplication = startApplication
} }