chore: mobile web bridge concept (#1228)
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
15
packages/mobile/WebFrame/DeviceInterface.template.js
Normal file
15
packages/mobile/WebFrame/DeviceInterface.template.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/mobile/WebFrame/MessageSender.template.js
Normal file
31
packages/mobile/WebFrame/MessageSender.template.js
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user