mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-11 06:17:08 +00:00
16bda26022
* core: derive JSON with TH * fix tests * simplify events * reduce diff * fix * update simplexmq * update simplexmq
199 lines
9.2 KiB
Haskell
199 lines
9.2 KiB
Haskell
{-# LANGUAGE DeriveAnyClass #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE FlexibleContexts #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE PatternSynonyms #-}
|
|
{-# LANGUAGE TemplateHaskell #-}
|
|
|
|
module Simplex.Chat.Remote.Protocol where
|
|
|
|
import Control.Logger.Simple
|
|
import Control.Monad
|
|
import Control.Monad.Except
|
|
import Data.Aeson ((.=))
|
|
import qualified Data.Aeson as J
|
|
import qualified Data.Aeson.Key as JK
|
|
import qualified Data.Aeson.KeyMap as JM
|
|
import Data.Aeson.TH (deriveJSON)
|
|
import qualified Data.Aeson.Types as JT
|
|
import Data.ByteString (ByteString)
|
|
import Data.ByteString.Builder (Builder, word32BE, lazyByteString)
|
|
import qualified Data.ByteString.Lazy as BL
|
|
import Data.String (fromString)
|
|
import Data.Text (Text)
|
|
import Data.Text.Encoding (decodeUtf8)
|
|
import Data.Word (Word32)
|
|
import qualified Network.HTTP.Types as N
|
|
import qualified Network.HTTP2.Client as H
|
|
import Network.Transport.Internal (decodeWord32)
|
|
import Simplex.Chat.Controller (ChatResponse)
|
|
import Simplex.Chat.Remote.Types
|
|
import Simplex.Messaging.Crypto.File (CryptoFile)
|
|
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag)
|
|
import Simplex.Messaging.Transport.Buffer (getBuffered)
|
|
import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk)
|
|
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect)
|
|
import Simplex.Messaging.Transport.HTTP2.File (hReceiveFile, hSendFile)
|
|
import Simplex.Messaging.Util (liftEitherError, liftEitherWith, tshow, whenM)
|
|
import System.FilePath ((</>))
|
|
import UnliftIO
|
|
import UnliftIO.Directory (doesFileExist, getFileSize)
|
|
|
|
data RemoteCommand
|
|
= RCHello {deviceName :: Text}
|
|
| RCSend {command :: Text} -- TODO maybe ChatCommand here?
|
|
| RCRecv {wait :: Int} -- this wait should be less than HTTP timeout
|
|
| -- local file encryption is determined by the host, but can be overridden for videos
|
|
RCStoreFile {fileSize :: Word32, encrypt :: Maybe Bool} -- requires attachment
|
|
| RCGetFile {filePath :: FilePath}
|
|
deriving (Show)
|
|
|
|
data RemoteResponse
|
|
= RRHello {encoding :: PlatformEncoding, deviceName :: Text}
|
|
| RRChatResponse {chatResponse :: ChatResponse}
|
|
| RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout
|
|
| RRFileStored {fileSource :: CryptoFile}
|
|
| RRFile {fileSize :: Word32} -- provides attachment
|
|
| RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- ^ The protocol error happened on the server side
|
|
deriving (Show)
|
|
|
|
-- Force platform-independent encoding as the types aren't UI-visible
|
|
$(deriveJSON (taggedObjectJSON $ dropPrefix "RC") ''RemoteCommand)
|
|
$(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse)
|
|
|
|
-- * Client side / desktop
|
|
|
|
createRemoteHostClient :: HTTP2Client -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient
|
|
createRemoteHostClient httpClient desktopName = do
|
|
logInfo "Sending initial hello"
|
|
(_getNext, rr) <- sendRemoteCommand httpClient localEncoding Nothing RCHello {deviceName = desktopName}
|
|
case rr of
|
|
rrh@RRHello {encoding, deviceName = mobileName} -> do
|
|
logInfo $ "Got initial hello: " <> tshow rrh
|
|
when (encoding == PEKotlin && localEncoding == PESwift) $ throwError RPEIncompatibleEncoding
|
|
pure RemoteHostClient {remoteEncoding = encoding, remoteDeviceName = mobileName, httpClient}
|
|
_ -> throwError $ RPEUnexpectedResponse $ tshow rr
|
|
|
|
closeRemoteHostClient :: MonadIO m => RemoteHostClient -> m ()
|
|
closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client httpClient
|
|
|
|
-- ** Commands
|
|
|
|
remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse
|
|
remoteSend RemoteHostClient {httpClient, remoteEncoding} cmd = do
|
|
(_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCSend {command = decodeUtf8 cmd}
|
|
case rr of
|
|
RRChatResponse cr -> pure cr
|
|
_ -> throwError $ RPEUnexpectedResponse $ tshow rr
|
|
|
|
remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse)
|
|
remoteRecv RemoteHostClient {httpClient, remoteEncoding} ms = do
|
|
(_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCRecv {wait=ms}
|
|
case rr of
|
|
RRChatEvent cr_ -> pure cr_
|
|
_ -> throwError $ RPEUnexpectedResponse $ tshow rr
|
|
|
|
remoteStoreFile :: RemoteHostClient -> FilePath -> Maybe Bool -> ExceptT RemoteProtocolError IO CryptoFile
|
|
remoteStoreFile RemoteHostClient {httpClient, remoteEncoding} localPath encrypt = do
|
|
(_getNext, rr) <- withFile localPath ReadMode $ \h -> do
|
|
fileSize' <- hFileSize h
|
|
when (fileSize' > toInteger (maxBound :: Word32)) $ throwError RPEFileTooLarge
|
|
let fileSize = fromInteger fileSize'
|
|
sendRemoteCommand httpClient remoteEncoding (Just (h, fileSize)) RCStoreFile {encrypt, fileSize}
|
|
case rr of
|
|
RRFileStored {fileSource} -> pure fileSource
|
|
_ -> throwError $ RPEUnexpectedResponse $ tshow rr
|
|
|
|
-- TODO this should work differently for CLI and UI clients
|
|
-- CLI - potentially, create new unique names and report them as created
|
|
-- UI - always use the same names and report error if file already exists
|
|
-- alternatively, CLI should also use a fixed folder for remote session
|
|
-- Possibly, path in the database should be optional and CLI commands should allow configuring it per session or use temp or download folder
|
|
remoteGetFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath
|
|
remoteGetFile RemoteHostClient {httpClient, remoteEncoding} baseDir filePath = do
|
|
(getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCGetFile {filePath}
|
|
expectedSize <- case rr of
|
|
RRFile {fileSize} -> pure fileSize
|
|
_ -> throwError $ RPEUnexpectedResponse $ tshow rr
|
|
whenM (liftIO $ doesFileExist localFile) $ throwError RPEStoredFileExists
|
|
rc <- liftIO $ withFile localFile WriteMode $ \h -> hReceiveFile getNext h expectedSize
|
|
when (rc /= 0) $ throwError RPEInvalidSize
|
|
whenM ((== expectedSize) . fromIntegral <$> getFileSize localFile) $ throwError RPEInvalidSize
|
|
pure localFile
|
|
where
|
|
localFile = baseDir </> filePath
|
|
|
|
sendRemoteCommand :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse)
|
|
sendRemoteCommand http remoteEncoding attachment_ rc = do
|
|
HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect http httpRequest Nothing
|
|
(header, getNext) <- parseHTTP2Body response respBody
|
|
rr <- liftEitherWith (RPEInvalidJSON . fromString) $ J.eitherDecodeStrict header >>= JT.parseEither J.parseJSON . convertJSON remoteEncoding localEncoding
|
|
pure (getNext, rr)
|
|
where
|
|
httpRequest = H.requestStreaming N.methodPost "/" mempty $ \send flush -> do
|
|
send $ sizePrefixedEncode rc
|
|
case attachment_ of
|
|
Nothing -> pure ()
|
|
Just (h, sz) -> hSendFile h send sz
|
|
flush
|
|
|
|
-- * Transport-level wrappers
|
|
|
|
convertJSON :: PlatformEncoding -> PlatformEncoding -> J.Value -> J.Value
|
|
convertJSON _remote@PEKotlin _local@PEKotlin = id
|
|
convertJSON PESwift PESwift = id
|
|
convertJSON PESwift PEKotlin = owsf2tagged
|
|
convertJSON PEKotlin PESwift = error "unsupported convertJSON: K/S" -- guarded by createRemoteHostClient
|
|
|
|
-- | Convert swift single-field sum encoding into tagged/discriminator-field
|
|
owsf2tagged :: J.Value -> J.Value
|
|
owsf2tagged = fst . convert
|
|
where
|
|
convert val = case val of
|
|
J.Object o
|
|
| JM.size o == 2 ->
|
|
case JM.toList o of
|
|
[OwsfTag, o'] -> tagged o'
|
|
[o', OwsfTag] -> tagged o'
|
|
_ -> props
|
|
| otherwise -> props
|
|
where
|
|
props = (J.Object $ fmap owsf2tagged o, False)
|
|
J.Array a -> (J.Array $ fmap owsf2tagged a, False)
|
|
_ -> (val, False)
|
|
-- `tagged` converts the pair of single-field object encoding to tagged encoding.
|
|
-- It sets innerTag returned by `convert` to True to prevent the tag being overwritten.
|
|
tagged (k, v) = (J.Object pairs, True)
|
|
where
|
|
(v', innerTag) = convert v
|
|
pairs = case v' of
|
|
-- `innerTag` indicates that internal object already has tag,
|
|
-- so the current tag cannot be inserted into it.
|
|
J.Object o
|
|
| innerTag -> pair
|
|
| otherwise -> JM.insert TaggedObjectJSONTag tag o
|
|
_ -> pair
|
|
tag = J.String $ JK.toText k
|
|
pair = JM.fromList [TaggedObjectJSONTag .= tag, TaggedObjectJSONData .= v']
|
|
|
|
pattern OwsfTag :: (JK.Key, J.Value)
|
|
pattern OwsfTag = (SingleFieldJSONTag, J.Bool True)
|
|
|
|
-- | Convert a command or a response into 'Builder'.
|
|
sizePrefixedEncode :: J.ToJSON a => a -> Builder
|
|
sizePrefixedEncode value = word32BE (fromIntegral $ BL.length json) <> lazyByteString json
|
|
where
|
|
json = J.encode value
|
|
|
|
-- | Parse HTTP request or response to a size-prefixed chunk and a function to read more.
|
|
parseHTTP2Body :: HTTP2BodyChunk a => a -> HTTP2Body -> ExceptT RemoteProtocolError IO (ByteString, Int -> IO ByteString)
|
|
parseHTTP2Body hr HTTP2Body {bodyBuffer} = do
|
|
rSize <- liftIO $ decodeWord32 <$> getNext 4
|
|
when (rSize > fromIntegral (maxBound :: Int)) $ throwError RPEInvalidSize
|
|
r <- liftIO $ getNext $ fromIntegral rSize
|
|
pure (r, getNext)
|
|
where
|
|
getNext sz = getBuffered bodyBuffer sz Nothing $ getBodyChunk hr
|