diff --git a/lib/tunnelbroker/secondary-tunnelbroker-connection.js b/lib/tunnelbroker/secondary-tunnelbroker-connection.js new file mode 100644 --- /dev/null +++ b/lib/tunnelbroker/secondary-tunnelbroker-connection.js @@ -0,0 +1,19 @@ +// @flow + +import type { MessageToDeviceRequest } from '../types/tunnelbroker/message-to-device-request-types'; + +type RemoveCallback = () => void; + +export type SecondaryTunnelbrokerConnection = { + // Used by an inactive tab to send messages + sendMessage: MessageToDeviceRequest => mixed, + // Active tab receives messages from inactive tabs + onSendMessage: ((MessageToDeviceRequest) => mixed) => RemoveCallback, + + // Active tab sets the message status of messages from inactive tabs + setMessageStatus: (messageID: string, error: ?string) => mixed, + // Inactive tabs receive message status and resolve or reject promises + onMessageStatus: ( + ((messageID: string, error: ?string) => mixed), + ) => RemoveCallback, +}; diff --git a/lib/tunnelbroker/tunnelbroker-context.js b/lib/tunnelbroker/tunnelbroker-context.js --- a/lib/tunnelbroker/tunnelbroker-context.js +++ b/lib/tunnelbroker/tunnelbroker-context.js @@ -4,6 +4,7 @@ import * as React from 'react'; import uuid from 'uuid'; +import type { SecondaryTunnelbrokerConnection } from './secondary-tunnelbroker-connection.js'; import { tunnnelbrokerURL } from '../facts/tunnelbroker.js'; import { peerToPeerMessageHandler } from '../handlers/peer-to-peer-message-handler.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; @@ -58,6 +59,7 @@ +shouldBeClosed?: boolean, +onClose?: () => mixed, +initMessage: ?ConnectionInitializationMessage, + +secondaryTunnelbrokerConnection?: SecondaryTunnelbrokerConnection, }; function createAnonymousInitMessage( @@ -76,6 +78,7 @@ shouldBeClosed, onClose, initMessage: initMessageProp, + secondaryTunnelbrokerConnection, } = props; const [connected, setConnected] = React.useState(false); const listeners = React.useRef>(new Set()); @@ -269,12 +272,32 @@ onClose, ]); + const sendMessageToDeviceRequest: ( + request: MessageToDeviceRequest, + ) => Promise = React.useCallback( + request => { + return new Promise((resolve, reject) => { + const socketActive = connected && socket.current; + if (!shouldBeClosed && !socketActive) { + throw new Error('Tunnelbroker not connected'); + } + promises.current[request.clientMessageID] = { + resolve, + reject, + }; + if (socketActive) { + socket.current?.send(JSON.stringify(request)); + } else { + secondaryTunnelbrokerConnection?.sendMessage(request); + } + }); + }, + [connected, secondaryTunnelbrokerConnection, shouldBeClosed], + ); + const sendMessage: (message: ClientMessageToDevice) => Promise = React.useCallback( (message: ClientMessageToDevice) => { - if (!connected || !socket.current) { - throw new Error('Tunnelbroker not connected'); - } const clientMessageID = uuid.v4(); const messageToDevice: MessageToDeviceRequest = { type: tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST, @@ -283,17 +306,53 @@ payload: message.payload, }; - return new Promise((resolve, reject) => { - promises.current[clientMessageID] = { - resolve, - reject, - }; - socket.current?.send(JSON.stringify(messageToDevice)); - }); + return sendMessageToDeviceRequest(messageToDevice); }, - [connected], + [sendMessageToDeviceRequest], ); + React.useEffect( + () => + secondaryTunnelbrokerConnection?.onSendMessage(message => { + if (shouldBeClosed) { + // We aren't supposed to be handling it + return; + } + + void (async () => { + try { + await sendMessageToDeviceRequest(message); + secondaryTunnelbrokerConnection.setMessageStatus( + message.clientMessageID, + ); + } catch (error) { + secondaryTunnelbrokerConnection.setMessageStatus( + message.clientMessageID, + error, + ); + } + })(); + }), + [ + secondaryTunnelbrokerConnection, + sendMessageToDeviceRequest, + shouldBeClosed, + ], + ); + + React.useEffect( + () => + secondaryTunnelbrokerConnection?.onMessageStatus((messageID, error) => { + if (error) { + promises.current[messageID].reject(error); + } else { + promises.current[messageID].resolve(); + } + delete promises.current[messageID]; + }), + [secondaryTunnelbrokerConnection], + ); + const addListener = React.useCallback( (listener: TunnelbrokerSocketListener) => { listeners.current.add(listener); diff --git a/web/app.react.js b/web/app.react.js --- a/web/app.react.js +++ b/web/app.react.js @@ -28,10 +28,12 @@ } from 'lib/selectors/loading-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { extractMajorDesktopVersion } from 'lib/shared/version-utils.js'; +import type { SecondaryTunnelbrokerConnection } from 'lib/tunnelbroker/secondary-tunnelbroker-connection.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { WebNavInfo } from 'lib/types/nav-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; +import type { MessageToDeviceRequest } from 'lib/types/tunnelbroker/message-to-device-request-types.js'; import { getConfig, registerConfig } from 'lib/utils/config.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; @@ -361,6 +363,119 @@ } } +const WEB_TUNNELBROKER_CHANNEL = new BroadcastChannel('shared-tunnelbroker'); +const WEB_TUNNELBROKER_MESSAGE_TYPES = Object.freeze({ + SEND_MESSAGE: 'send-message', + MESSAGE_STATUS: 'message-status', +}); + +function useOtherTabsTunnelbrokerConnection(): SecondaryTunnelbrokerConnection { + const onSendMessageCallbacks = React.useRef< + Set<(MessageToDeviceRequest) => mixed>, + >(new Set()); + + const onMessageStatusCallbacks = React.useRef< + Set<(messageID: string, error: ?string) => mixed>, + >(new Set()); + + React.useEffect(() => { + const messageHandler = (event: MessageEvent) => { + if (typeof event.data !== 'object' || !event.data) { + console.log( + 'Invalid message received from shared ' + + 'tunnelbroker broadcast channel', + event.data, + ); + return; + } + const data = event.data; + if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE) { + if (typeof data.message !== 'object' || !data.message) { + console.log( + 'Invalid tunnelbroker message request received ' + + 'from shared tunnelbroker broadcast channel', + event.data, + ); + return; + } + // We know that the input was already validated + const message: MessageToDeviceRequest = (data.message: any); + + for (const callback of onSendMessageCallbacks.current) { + callback(message); + } + } else if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS) { + if (typeof data.messageID !== 'string') { + console.log( + 'Missing message id in message status message ' + + 'from shared tunnelbroker broadcast channel', + ); + return; + } + const messageID = data.messageID; + + if ( + typeof data.error !== 'string' && + data.error !== null && + data.error !== undefined + ) { + console.log( + 'Invalid error in message status message ' + + 'from shared tunnelbroker broadcast channel', + data.error, + ); + return; + } + const error = data.error; + + for (const callback of onMessageStatusCallbacks.current) { + callback(messageID, error); + } + } else { + console.log( + 'Invalid message type ' + + 'from shared tunnelbroker broadcast channel', + data, + ); + } + }; + + WEB_TUNNELBROKER_CHANNEL.addEventListener('message', messageHandler); + return () => + WEB_TUNNELBROKER_CHANNEL.removeEventListener('message', messageHandler); + }, [onMessageStatusCallbacks, onSendMessageCallbacks]); + + return React.useMemo( + () => ({ + sendMessage: message => + WEB_TUNNELBROKER_CHANNEL.postMessage({ + type: WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE, + message, + }), + onSendMessage: callback => { + onSendMessageCallbacks.current.add(callback); + return () => { + onSendMessageCallbacks.current.delete(callback); + }; + }, + setMessageStatus: (messageID, error) => { + WEB_TUNNELBROKER_CHANNEL.postMessage({ + type: WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS, + messageID, + error, + }); + }, + onMessageStatus: callback => { + onMessageStatusCallbacks.current.add(callback); + return () => { + onMessageStatusCallbacks.current.delete(callback); + }; + }, + }), + [onMessageStatusCallbacks, onSendMessageCallbacks], + ); +} + const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); @@ -409,12 +524,16 @@ const { lockStatus, releaseLock } = useWebLock(TUNNELBROKER_LOCK_NAME); + const secondaryTunnelbrokerConnection: SecondaryTunnelbrokerConnection = + useOtherTabsTunnelbrokerConnection(); + return (