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:
parent
84bf815e5c
commit
295cec7c53
@ -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
|
||||
|
@ -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.*
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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":
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -113,6 +113,7 @@ data ChatCommand
|
||||
| APIAcceptContact Int64
|
||||
| APIRejectContact Int64
|
||||
| APISendCallInvitation ContactId CallType
|
||||
| SendCallInvitation ContactName CallType
|
||||
| APIRejectCall ContactId
|
||||
| APISendCallOffer ContactId WebRTCCallOffer
|
||||
| APISendCallAnswer ContactId WebRTCSession
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user