terminal: support to connect via webrtc (#659)

* terminal: support to connect via webrtc

* npm package

* update webrtc npm package

* remove console.log

* fix test
This commit is contained in:
Evgeny Poberezkin 2022-05-17 08:37:00 +01:00 committed by GitHub
parent 84bf815e5c
commit 295cec7c53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 231 additions and 113 deletions

View File

@ -431,8 +431,9 @@ function callCryptoFunction() {
};
}
function decodeAesKey(aesKey) {
const keyData = callCrypto.decodeBase64(callCrypto.encodeAscii(aesKey));
return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const keyData = callCrypto.decodeBase64url(callCrypto.encodeAscii(aesKey));
console.log("keyData", keyData);
return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
}
function concatN(...bs) {
const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0));
@ -445,9 +446,9 @@ function callCryptoFunction() {
function randomIV() {
return crypto.getRandomValues(new Uint8Array(IV_LENGTH));
}
const base64chars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0)));
const base64lookup = new Array(256);
base64chars.forEach((c, i) => (base64lookup[c] = i));
const base64urlChars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split("").map((c) => c.charCodeAt(0)));
const base64urlLookup = new Array(256);
base64urlChars.forEach((c, i) => (base64urlLookup[c] = i));
const char_equal = "=".charCodeAt(0);
function encodeAscii(s) {
const a = new Uint8Array(s.length);
@ -462,16 +463,16 @@ function callCryptoFunction() {
s += String.fromCharCode(a[i]);
return s;
}
function encodeBase64(a) {
function encodeBase64url(a) {
const len = a.length;
const b64len = Math.ceil(len / 3) * 4;
const b64 = new Uint8Array(b64len);
let j = 0;
for (let i = 0; i < len; i += 3) {
b64[j++] = base64chars[a[i] >> 2];
b64[j++] = base64chars[((a[i] & 3) << 4) | (a[i + 1] >> 4)];
b64[j++] = base64chars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)];
b64[j++] = base64chars[a[i + 2] & 63];
b64[j++] = base64urlChars[a[i] >> 2];
b64[j++] = base64urlChars[((a[i] & 3) << 4) | (a[i + 1] >> 4)];
b64[j++] = base64urlChars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)];
b64[j++] = base64urlChars[a[i + 2] & 63];
}
if (len % 3)
b64[b64len - 1] = char_equal;
@ -479,7 +480,7 @@ function callCryptoFunction() {
b64[b64len - 2] = char_equal;
return b64;
}
function decodeBase64(b64) {
function decodeBase64url(b64) {
let len = b64.length;
if (len % 4)
return;
@ -496,10 +497,10 @@ function callCryptoFunction() {
let i = 0;
let pos = 0;
while (i < len) {
const enc1 = base64lookup[b64[i++]];
const enc2 = i < len ? base64lookup[b64[i++]] : 0;
const enc3 = i < len ? base64lookup[b64[i++]] : 0;
const enc4 = i < len ? base64lookup[b64[i++]] : 0;
const enc1 = base64urlLookup[b64[i++]];
const enc2 = i < len ? base64urlLookup[b64[i++]] : 0;
const enc3 = i < len ? base64urlLookup[b64[i++]] : 0;
const enc4 = i < len ? base64urlLookup[b64[i++]] : 0;
if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined)
return;
bytes[pos++] = (enc1 << 2) | (enc2 >> 4);
@ -513,8 +514,8 @@ function callCryptoFunction() {
decodeAesKey,
encodeAscii,
decodeAscii,
encodeBase64,
decodeBase64,
encodeBase64url,
decodeBase64url,
};
}
// If the worker is used for decryption, this function code (as string) is used to load the worker via Blob

View File

@ -26,6 +26,7 @@ dependencies:
- email-validate == 2.3.*
- exceptions == 0.10.*
- filepath == 1.4.*
- http-types == 0.12.*
- mtl == 2.2.*
- optparse-applicative >= 0.15 && < 0.17
- process == 1.6.*

View File

@ -1,17 +1,30 @@
{
"name": "simplex-chat-webrtc",
"version": "0.0.1",
"description": "WebRTC call in browser and webview",
"name": "@simplex-chat/webrtc",
"version": "0.0.2",
"description": "WebRTC call in browser and webview for SimpleX Chat clients",
"main": "dist/call.js",
"types": "dist/call.d.ts",
"files": [
"dist"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "prettier --write --ignore-unknown . && tsc && ./copy"
},
"repository": {
"type": "git",
"url": "git+https://github.com/simplex-chat/simplex-chat.git"
},
"keywords": [
"SimpleX",
"WebRTC"
],
"author": "",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"homepage": "https://github.com/simplex-chat/simplex-chat/packages/simplex-chat-webrtc#readme",
"author": "SimpleX Chat",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@types/lz-string": "^1.3.34",

View File

@ -569,8 +569,8 @@ interface CallCrypto {
decodeAesKey: (aesKey: string) => Promise<CryptoKey>
encodeAscii: (s: string) => Uint8Array
decodeAscii: (a: Uint8Array) => string
encodeBase64: (a: Uint8Array) => Uint8Array
decodeBase64: (b64: Uint8Array) => Uint8Array | undefined
encodeBase64url: (a: Uint8Array) => Uint8Array
decodeBase64url: (b64: Uint8Array) => Uint8Array | undefined
}
interface RTCEncodedVideoFrame {
@ -624,8 +624,8 @@ function callCryptoFunction(): CallCrypto {
}
function decodeAesKey(aesKey: string): Promise<CryptoKey> {
const keyData = callCrypto.decodeBase64(callCrypto.encodeAscii(aesKey))
return crypto.subtle.importKey("raw", keyData!, {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"])
const keyData = callCrypto.decodeBase64url(callCrypto.encodeAscii(aesKey))
return crypto.subtle.importKey("raw", keyData!, {name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"])
}
function concatN(...bs: Uint8Array[]): Uint8Array {
@ -641,12 +641,12 @@ function callCryptoFunction(): CallCrypto {
return crypto.getRandomValues(new Uint8Array(IV_LENGTH))
}
const base64chars = new Uint8Array(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0))
const base64urlChars = new Uint8Array(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split("").map((c) => c.charCodeAt(0))
)
const base64lookup = new Array(256) as (number | undefined)[]
base64chars.forEach((c, i) => (base64lookup[c] = i))
const base64urlLookup = new Array(256) as (number | undefined)[]
base64urlChars.forEach((c, i) => (base64urlLookup[c] = i))
const char_equal = "=".charCodeAt(0)
@ -663,17 +663,17 @@ function callCryptoFunction(): CallCrypto {
return s
}
function encodeBase64(a: Uint8Array): Uint8Array {
function encodeBase64url(a: Uint8Array): Uint8Array {
const len = a.length
const b64len = Math.ceil(len / 3) * 4
const b64 = new Uint8Array(b64len)
let j = 0
for (let i = 0; i < len; i += 3) {
b64[j++] = base64chars[a[i] >> 2]
b64[j++] = base64chars[((a[i] & 3) << 4) | (a[i + 1] >> 4)]
b64[j++] = base64chars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)]
b64[j++] = base64chars[a[i + 2] & 63]
b64[j++] = base64urlChars[a[i] >> 2]
b64[j++] = base64urlChars[((a[i] & 3) << 4) | (a[i + 1] >> 4)]
b64[j++] = base64urlChars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)]
b64[j++] = base64urlChars[a[i + 2] & 63]
}
if (len % 3) b64[b64len - 1] = char_equal
@ -682,7 +682,7 @@ function callCryptoFunction(): CallCrypto {
return b64
}
function decodeBase64(b64: Uint8Array): Uint8Array | undefined {
function decodeBase64url(b64: Uint8Array): Uint8Array | undefined {
let len = b64.length
if (len % 4) return
let bLen = (len * 3) / 4
@ -701,10 +701,10 @@ function callCryptoFunction(): CallCrypto {
let i = 0
let pos = 0
while (i < len) {
const enc1 = base64lookup[b64[i++]]
const enc2 = i < len ? base64lookup[b64[i++]] : 0
const enc3 = i < len ? base64lookup[b64[i++]] : 0
const enc4 = i < len ? base64lookup[b64[i++]] : 0
const enc1 = base64urlLookup[b64[i++]]
const enc2 = i < len ? base64urlLookup[b64[i++]] : 0
const enc3 = i < len ? base64urlLookup[b64[i++]] : 0
const enc4 = i < len ? base64urlLookup[b64[i++]] : 0
if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined) return
bytes[pos++] = (enc1 << 2) | (enc2 >> 4)
bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2)
@ -719,8 +719,8 @@ function callCryptoFunction(): CallCrypto {
decodeAesKey,
encodeAscii,
decodeAscii,
encodeBase64,
decodeBase64,
encodeBase64url,
decodeBase64url,
}
}

View File

@ -1,103 +1,113 @@
;(async function run() {
const START_E2EE_CALL_BTN = "start-e2ee-call"
const START_CALL_BTN = "start-call"
// const START_E2EE_CALL_BTN = "start-e2ee-call"
// const START_CALL_BTN = "start-call"
const URL_FOR_PEER = "url-for-peer"
const COPY_URL_FOR_PEER_BTN = "copy-url-for-peer"
// const COPY_URL_FOR_PEER_BTN = "copy-url-for-peer"
const DATA_FOR_PEER = "data-for-peer"
const COPY_DATA_FOR_PEER_BTN = "copy-data-for-peer"
// const COPY_DATA_FOR_PEER_BTN = "copy-data-for-peer"
const PASS_DATA_TO_PEER_TEXT = "pass-data-to-peer"
const CHAT_COMMAND_FOR_PEER = "chat-command-for-peer"
const SIMPLEX_CHAT_COMMAND = "simplex-chat-command"
const COPY_SIMPLEX_CHAT_COMMAND_BTN = "copy-simplex-chat-command"
const COMMAND_TO_PROCESS = "command-to-process"
const PROCESS_COMMAND_BTN = "process-command"
const urlForPeer = document.getElementById(URL_FOR_PEER)
const dataForPeer = document.getElementById(DATA_FOR_PEER)
const passDataToPeerText = document.getElementById(PASS_DATA_TO_PEER_TEXT)
const chatCommandForPeer = document.getElementById(CHAT_COMMAND_FOR_PEER)
const simplexChatCommand = document.getElementById(SIMPLEX_CHAT_COMMAND)
const commandToProcess = document.getElementById(COMMAND_TO_PROCESS)
const processCommandButton = document.getElementById(PROCESS_COMMAND_BTN)
const startE2EECallButton = document.getElementById(START_E2EE_CALL_BTN)
const {resp} = await processCommand({command: {type: "capabilities", useWorker: true}})
if (resp?.capabilities?.encryption) {
startE2EECallButton.onclick = startCall(true)
} else {
startE2EECallButton.style.display = "none"
}
const startCallButton = document.getElementById(START_CALL_BTN)
startCallButton.onclick = startCall()
const copyUrlButton = document.getElementById(COPY_URL_FOR_PEER_BTN)
copyUrlButton.onclick = () => {
navigator.clipboard.writeText(urlForPeer.innerText)
commandToProcess.style.display = ""
processCommandButton.style.display = ""
}
const copyDataButton = document.getElementById(COPY_DATA_FOR_PEER_BTN)
copyDataButton.onclick = () => {
navigator.clipboard.writeText(dataForPeer.innerText)
commandToProcess.style.display = ""
processCommandButton.style.display = ""
// const startE2EECallButton = document.getElementById(START_E2EE_CALL_BTN)
// const {resp} = await processCommand({command: {type: "capabilities", useWorker: true}})
// if (resp?.capabilities?.encryption) {
// startE2EECallButton.onclick = startCall(true)
// } else {
// startE2EECallButton.style.display = "none"
// }
// const startCallButton = document.getElementById(START_CALL_BTN)
// startCallButton.onclick = startCall()
// const copyUrlButton = document.getElementById(COPY_URL_FOR_PEER_BTN)
// copyUrlButton.onclick = () => {
// navigator.clipboard.writeText(urlForPeer.innerText)
// commandToProcess.style.display = ""
// processCommandButton.style.display = ""
// }
// const copyDataButton = document.getElementById(COPY_DATA_FOR_PEER_BTN)
// copyDataButton.onclick = () => {
// navigator.clipboard.writeText(dataForPeer.innerText)
// commandToProcess.style.display = ""
// processCommandButton.style.display = ""
// }
const copySimplexChatCommandButton = document.getElementById(COPY_SIMPLEX_CHAT_COMMAND_BTN)
copySimplexChatCommandButton.onclick = () => {
navigator.clipboard.writeText(simplexChatCommand.innerText)
if (simplexChatCommand.innerText.startsWith("/_call offer")) {
commandToProcess.style.display = ""
processCommandButton.style.display = ""
}
}
processCommandButton.onclick = () => {
sendCommand(JSON.parse(commandToProcess.value))
commandToProcess.value = ""
}
const parsed = new URLSearchParams(document.location.hash.substring(1))
let apiCallStr = parsed.get("command")
if (apiCallStr) {
startE2EECallButton.style.display = "none"
startCallButton.style.display = "none"
await sendCommand(JSON.parse(decodeURIComponent(apiCallStr)))
let commandStr = parsed.get("command")
if (commandStr) {
// startE2EECallButton.style.display = "none"
// startCallButton.style.display = "none"
await sendCommand(JSON.parse(decodeURIComponent(commandStr)))
}
function startCall(encryption) {
return async () => {
let aesKey
if (encryption) {
const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"])
const keyBytes = await crypto.subtle.exportKey("raw", key)
aesKey = callCrypto.decodeAscii(callCrypto.encodeBase64(new Uint8Array(keyBytes)))
}
sendCommand({command: {type: "start", media: "video", aesKey, useWorker: true}})
startE2EECallButton.style.display = "none"
startCallButton.style.display = "none"
}
}
// function startCall(encryption) {
// return async () => {
// let aesKey
// if (encryption) {
// const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"])
// const keyBytes = await crypto.subtle.exportKey("raw", key)
// aesKey = callCrypto.decodeAscii(callCrypto.encodeBase64url(new Uint8Array(keyBytes)))
// }
// startE2EECallButton.style.display = "none"
// startCallButton.style.display = "none"
// await sendCommand({type: "start", media: "video", aesKey, useWorker: true})
// }
// }
async function sendCommand(apiCall) {
async function sendCommand(command) {
try {
console.log(apiCall)
const {command} = apiCall
const {resp} = await processCommand(apiCall)
console.log(command)
const {resp} = await processCommand({command})
console.log(resp)
switch (resp.type) {
case "offer": {
const {media, aesKey} = command
const {media} = command
const {offer, iceCandidates, capabilities} = resp
const peerWCommand = {
command: {type: "offer", offer, iceCandidates, media, aesKey: capabilities.encryption ? aesKey : undefined, useWorker: true},
}
const aesKey = capabilities.encryption ? command.aesKey : undefined
const peerWCommand = {type: "offer", offer, iceCandidates, media, aesKey, useWorker: true}
const url = new URL(document.location)
parsed.set("command", encodeURIComponent(JSON.stringify(peerWCommand)))
url.hash = parsed.toString()
urlForPeer.innerText = url.toString()
dataForPeer.innerText = JSON.stringify(peerWCommand)
copyUrlButton.style.display = ""
copyDataButton.style.display = ""
// const webRTCCallOffer = {callType: {media, capabilities}, rtcSession: {rtcSession: offer, rtcIceCandidates: iceCandidates}}
// const peerChatCommand = `/_call @${parsed.contact} offer ${JSON.stringify(webRTCCallOffer)}`
// chatCommandForPeer.innerText = peerChatCommand
const webRTCCallOffer = {callType: {media, capabilities}, rtcSession: {rtcSession: offer, rtcIceCandidates: iceCandidates}}
const peerChatCommand = `/_call offer @${parsed.get("contact_id")} ${JSON.stringify(webRTCCallOffer)}`
simplexChatCommand.innerText = peerChatCommand
// copyUrlButton.style.display = ""
// copyDataButton.style.display = ""
copySimplexChatCommandButton.style.display = ""
return
}
case "answer": {
const {answer, iceCandidates} = resp
const peerWCommand = {command: {type: "answer", answer, iceCandidates}}
const peerWCommand = {type: "answer", answer, iceCandidates}
dataForPeer.innerText = JSON.stringify(peerWCommand)
copyUrlButton.style.display = "none"
copyDataButton.style.display = ""
// const webRTCSession = {rtcSession: answer, rtcIceCandidates: iceCandidates}
// const peerChatCommand = `/_call @${parsed.contact} answer ${JSON.stringify(webRTCSession)}`
// chatCommandForPeer.innerText = peerChatCommand
const webRTCSession = {rtcSession: answer, rtcIceCandidates: iceCandidates}
const peerChatCommand = `/_call answer @${parsed.get("contact_id")} ${JSON.stringify(webRTCSession)}`
// copyUrlButton.style.display = "none"
// copyDataButton.style.display = ""
copySimplexChatCommandButton.style.display = ""
simplexChatCommand.innerText = peerChatCommand
return
}
case "ok":

View File

@ -21,10 +21,11 @@
<video id="local-video-stream" muted autoplay playsinline></video>
<div id="ui-overlay">
<div>
<button id="start-e2ee-call" type="submit">Start call with e2ee</button>
<!-- <button id="start-e2ee-call" type="submit">Start call with e2ee</button>
<button id="start-call" type="submit">Start call</button>
<button id="copy-url-for-peer" type="submit" style="display: none">Copy url for your contact</button>
<button id="copy-data-for-peer" type="submit" style="display: none">Copy data for your contact</button>
<button id="copy-data-for-peer" type="submit" style="display: none">Copy data for your contact</button> -->
<button id="copy-simplex-chat-command" type="submit" style="display: none">Copy SimpleX Chat command</button>
<p id="pass-data-to-peer" style="display: none">Send copied data back to your contact</p>
</div>
<div>
@ -41,7 +42,7 @@
</div>
<div id="url-for-peer" style="display: none"></div>
<div id="data-for-peer" style="display: none"></div>
<div id="chat-command-for-peer" style="display: none"></div>
<div id="simplex-chat-command" style="display: none"></div>
</div>
</body>
<footer>

View File

@ -70,6 +70,7 @@ library
, email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, http-types ==0.12.*
, mtl ==2.2.*
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
@ -106,6 +107,7 @@ executable simplex-bot
, email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, http-types ==0.12.*
, mtl ==2.2.*
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
@ -143,6 +145,7 @@ executable simplex-bot-advanced
, email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, http-types ==0.12.*
, mtl ==2.2.*
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
@ -181,6 +184,7 @@ executable simplex-chat
, email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, http-types ==0.12.*
, mtl ==2.2.*
, network ==3.1.*
, optparse-applicative >=0.15 && <0.17
@ -228,6 +232,7 @@ test-suite simplex-chat-test
, exceptions ==0.10.*
, filepath ==1.4.*
, hspec ==2.7.*
, http-types ==0.12.*
, mtl ==2.2.*
, network ==3.1.*
, optparse-applicative >=0.15 && <0.17

View File

@ -445,6 +445,9 @@ processChatCommand = \case
forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected $ Just msgId
toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci
pure CRCmdOk
SendCallInvitation cName callType -> withUser $ \User {userId} -> do
contactId <- withStore $ \st -> getContactIdByName st userId cName
processChatCommand $ APISendCallInvitation contactId callType
APIRejectCall contactId ->
-- party accepting call
withCurrentCall contactId $ \userId ct Call {chatItemId, callState} -> case callState of
@ -2176,6 +2179,7 @@ chatCommandP =
<|> "/_accept " *> (APIAcceptContact <$> A.decimal)
<|> "/_reject " *> (APIRejectContact <$> A.decimal)
<|> "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP)
<|> ("/call @" <|> "/call ") *> (SendCallInvitation <$> displayName <*> pure defaultCallType)
<|> "/_call reject @" *> (APIRejectCall <$> A.decimal)
<|> "/_call offer @" *> (APISendCallOffer <$> A.decimal <* A.space <*> jsonP)
<|> "/_call answer @" *> (APISendCallAnswer <$> A.decimal <* A.space <*> jsonP)

View File

@ -96,6 +96,9 @@ data CallType = CallType
}
deriving (Eq, Show, Generic, FromJSON)
defaultCallType :: CallType
defaultCallType = CallType CMVideo $ CallCapabilities {encryption = True}
instance ToJSON CallType where toEncoding = J.genericToEncoding J.defaultOptions
-- | * Types for chat protocol

View File

@ -113,6 +113,7 @@ data ChatCommand
| APIAcceptContact Int64
| APIRejectContact Int64
| APISendCallInvitation ContactId CallType
| SendCallInvitation ContactName CallType
| APIRejectCall ContactId
| APISendCallOffer ContactId WebRTCCallOffer
| APISendCallAnswer ContactId WebRTCSession

View File

@ -1,4 +1,5 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
@ -10,8 +11,10 @@
module Simplex.Chat.View where
import Data.Aeson (ToJSON)
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Function (on)
import Data.Int (Int64)
import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
@ -21,7 +24,10 @@ import qualified Data.Text as T
import Data.Time.Clock (DiffTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Time.LocalTime (ZonedTime (..), localDay, localTimeOfDay, timeOfDayToTime, utcToZonedTime)
import GHC.Generics (Generic)
import qualified Network.HTTP.Types as Q
import Numeric (showFFloat)
import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Help
import Simplex.Chat.Markdown
@ -31,8 +37,10 @@ import Simplex.Chat.Store (StoreError (..))
import Simplex.Chat.Styled
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON)
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.Util (bshow)
import System.Console.ANSI.Types
@ -141,9 +149,9 @@ responseToView testView = \case
["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e ->
["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
CRCallInvitation {contact} -> ["call invitation from " <> ttyContact' contact]
CRCallOffer {contact} -> ["call offer from " <> ttyContact' contact]
CRCallAnswer {contact} -> ["call answer from " <> ttyContact' contact]
CRCallInvitation {contact, callType, sharedKey} -> viewCallInvitation contact callType sharedKey
CRCallOffer {contact, callType, offer, sharedKey} -> viewCallOffer contact callType offer sharedKey
CRCallAnswer {contact, answer} -> viewCallAnswer contact answer
CRCallExtraInfo {contact} -> ["call extra info from " <> ttyContact' contact]
CRCallEnded {contact} -> ["call with " <> ttyContact' contact <> " ended"]
CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"]
@ -636,6 +644,70 @@ fileProgress :: [Integer] -> Integer -> Integer -> StyledString
fileProgress chunksNum chunkSize fileSize =
sShow (sum chunksNum * chunkSize * 100 `div` fileSize) <> "% of " <> humanReadableSize fileSize
viewCallInvitation :: Contact -> CallType -> Maybe C.Key -> [StyledString]
viewCallInvitation ct@Contact {contactId} callType@CallType {media} sharedKey =
[ ttyContact' ct <> " wants to connect with you via WebRTC " <> callMediaStr callType <> " call " <> encryptedCall callType,
"To accept the call, please open the link below in your browser" <> supporedBrowsers callType,
"",
"https://simplex.chat/call#" <> plain queryString
]
where
aesKey = B.unpack . strEncode . C.unKey <$> sharedKey
queryString =
Q.renderSimpleQuery
False
[ ("command", LB.toStrict . J.encode $ WCCallStart {media, aesKey, useWorker = True}),
("contact_id", B.pack $ show contactId)
]
viewCallOffer :: Contact -> CallType -> WebRTCSession -> Maybe C.Key -> [StyledString]
viewCallOffer ct@Contact {contactId} callType@CallType {media} WebRTCSession {rtcSession = offer, rtcIceCandidates = iceCandidates} sharedKey =
[ ttyContact' ct <> " accepted your WebRTC " <> callMediaStr callType <> " call " <> encryptedCall callType,
"To connect, please open the link below in your browser" <> supporedBrowsers callType,
"",
"https://simplex.chat/call#" <> plain queryString
]
where
aesKey = B.unpack . strEncode . C.unKey <$> sharedKey
queryString =
Q.renderSimpleQuery
False
[ ("command", LB.toStrict . J.encode $ WCCallOffer {offer, iceCandidates, media, aesKey, useWorker = True}),
("contact_id", B.pack $ show contactId)
]
viewCallAnswer :: Contact -> WebRTCSession -> [StyledString]
viewCallAnswer ct WebRTCSession {rtcSession = answer, rtcIceCandidates = iceCandidates} =
[ ttyContact' ct <> " continued the WebRTC call",
"To connect, please paste the data below in your browser window you opened earlier and click Connect button",
"",
plain . LB.toStrict . J.encode $ WCCallAnswer {answer, iceCandidates}
]
callMediaStr :: CallType -> StyledString
callMediaStr CallType {media} = case media of
CMVideo -> "video"
CMAudio -> "audio"
encryptedCall :: CallType -> StyledString
encryptedCall CallType {capabilities = CallCapabilities {encryption}} =
if encryption then "(e2e encrypted)" else "(not e2e encrypted)"
supporedBrowsers :: CallType -> StyledString
supporedBrowsers CallType {capabilities = CallCapabilities {encryption}}
| encryption = " (only Chrome and Safari support e2e encryption for WebRTC, Safari requires enabling WebRTC insertable streams)"
| otherwise = ""
data WCallCommand
= WCCallStart {media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool}
| WCCallOffer {offer :: Text, iceCandidates :: Text, media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool}
| WCCallAnswer {answer :: Text, iceCandidates :: Text}
deriving (Generic)
instance ToJSON WCallCommand where
toEncoding = J.genericToEncoding . taggedObjectJSON $ dropPrefix "WCCall"
toJSON = J.genericToJSON . taggedObjectJSON $ dropPrefix "WCCall"
viewChatError :: ChatError -> [StyledString]
viewChatError = \case
ChatError err -> case err of

View File

@ -9,6 +9,7 @@ import ChatClient
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
import Control.Concurrent.STM
import Control.Monad (forM_)
import Data.Aeson (ToJSON)
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
@ -1912,6 +1913,9 @@ testWebRTCCallOffer =
serialize :: ToJSON a => a -> String
serialize = B.unpack . LB.toStrict . J.encode
repeatM_ :: Int -> IO a -> IO ()
repeatM_ n a = forM_ [1 .. n] $ const a
testNegotiateCall :: IO ()
testNegotiateCall =
testChat2 aliceProfile bobProfile $ \alice bob -> do
@ -1920,13 +1924,15 @@ testNegotiateCall =
alice ##> ("/_call invite @2 " <> serialize testCallType)
alice <## "ok"
alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: calling...")])
bob <## "call invitation from alice"
bob <## "alice wants to connect with you via WebRTC video call (e2e encrypted)"
repeatM_ 3 $ getTermLine bob
bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: calling...")])
-- bob accepts call by sending WebRTC offer
bob ##> ("/_call offer @2 " <> serialize testWebRTCCallOffer)
bob <## "ok"
bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: accepted")])
alice <## "call offer from bob"
alice <## "bob accepted your WebRTC video call (e2e encrypted)"
repeatM_ 3 $ getTermLine alice
alice <## "message updated" -- call chat item updated
alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: accepted")])
-- alice confirms call by sending WebRTC answer
@ -1936,7 +1942,8 @@ testNegotiateCall =
"message updated"
]
alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: connecting...")])
bob <## "call answer from alice"
bob <## "alice continued the WebRTC call"
repeatM_ 3 $ getTermLine bob
bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: connecting...")])
-- participants can update calls as connected
alice ##> "/_call status @2 connected"