Files
2ea1ec0d50 ui: reconnect call (#7711)
* Update Call.hs

* Update Internal.hs

* add reconnecting string

* Update WebRTC.kt

* desktop: show reconnecting state, keep call duration

* android keep connection time correct wiith adding reconnecting

* Update CallTypes.swift add reconnecting status on IOS

* Update WebRTC.swift add reconnecting

* add reconnecting and make sure call duration is correct

* Update call.ts

* core: regenerate call.js from call.ts

* Update Call.hs revert call status change

minimization

* Update Internal.hs rever to minimize changes

* Update CallTypes.swift minimize changes

* Update WebRTC.kt minimize changes

* Update CallView.desktop.kt desktop shows reconnecting without sending to core

* Update CallView.android.kt show reconnecting without sending it to the core

* Update ActiveCallView.swift show reconnecting to ui without sending to core

* Update CallView.desktop.kt add reconnecting sound on reconnect

* Update CallView.android.kt add reconnecting sound

* Update ActiveCallView.swift play reconnecting sound when reconnecting

* Update RecAndPlay.desktop.kt

* rebuild

* refactor

* build

* android, desktop: restart ICE to reconnect calls

* ios: restart ICE to reconnect calls

* webrtc: add call reconnection e2e tests

* android, desktop: fix lost call commands

---------

Co-authored-by: GuavaGoat <320862384+GuavaGoat@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-10-19 12:17:16 +01:00

148 lines
4.8 KiB
JavaScript

// Runs a local SMP server and chat cores with WebSocket API, so that call signaling goes via the chat protocol.
const {spawn, execFileSync} = require("child_process")
const fs = require("fs")
const net = require("net")
const path = require("path")
const {sleep, log} = require("./harness")
const START_TIMEOUT = 30000
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.on("error", reject)
server.listen(0, "127.0.0.1", () => {
const {port} = server.address()
server.close(() => resolve(port))
})
})
}
async function waitUntil(what, check) {
const deadline = Date.now() + START_TIMEOUT
while (!(await check())) {
if (Date.now() > deadline) throw new Error(`${what} did not start in ${START_TIMEOUT}ms`)
await sleep(200)
}
}
function canConnect(port) {
return new Promise((resolve) => {
const socket = net.connect(port, "127.0.0.1", () => {
socket.destroy()
resolve(true)
})
socket.on("error", () => resolve(false))
})
}
function spawnLogged(name, bin, args, env) {
const proc = spawn(bin, args, {env, stdio: ["ignore", "pipe", "pipe"]})
const output = (data) => log(`${name}:`, data.toString().trimEnd())
proc.stdout.on("data", output)
proc.stderr.on("data", output)
return proc
}
async function startSmpServer(bin, dir) {
const env = {...process.env, SMP_SERVER_CFG_PATH: path.join(dir, "cfg"), SMP_SERVER_LOG_PATH: path.join(dir, "log")}
const init = ["init", "--ip", "127.0.0.1", "--no-password", "--disable-web", "--disable-store-log", "-y"]
const fingerprint = execFileSync(bin, init, {env, stdio: ["ignore", "pipe", "pipe"]})
.toString()
.match(/Fingerprint: (\S+)/)[1]
const port = await freePort()
const iniFile = path.join(dir, "cfg", "smp-server.ini")
const ini = fs
.readFileSync(iniFile, "utf8")
.replace(/^port = .*$/m, `port = ${port}`)
.replace(/^host = .*$/m, "host = localhost")
fs.writeFileSync(iniFile, ini)
const proc = spawnLogged("smp-server", bin, ["start"], env)
await waitUntil("SMP server", () => canConnect(port))
return {address: `smp://${fingerprint}@localhost:${port}`, stop: () => proc.kill()}
}
class ChatClient {
constructor(name, proc, ws) {
this.name = name
this.proc = proc
this.ws = ws
this.corrId = 0
this.pending = new Map()
this.events = []
this.waiters = []
this.onEvent = async () => {}
ws.addEventListener("message", (e) => this.receive(JSON.parse(e.data)))
}
static async start(bin, dir, name, smpServer) {
const port = await freePort()
const args = ["-d", path.join(dir, name), "-s", smpServer, "-p", String(port), "--user-display-name", name]
const proc = spawnLogged(`chat ${name}`, bin, args, process.env)
await waitUntil(`chat ${name}`, () => canConnect(port))
const ws = new WebSocket(`ws://127.0.0.1:${port}`)
await new Promise((resolve, reject) => {
ws.addEventListener("open", resolve, {once: true})
ws.addEventListener("error", reject, {once: true})
})
return new ChatClient(name, proc, ws)
}
receive({corrId, resp}) {
if (corrId) {
this.pending.get(corrId)?.(resp)
this.pending.delete(corrId)
return
}
log(`${this.name} event:`, resp.type)
this.events.push(resp)
this.onEvent(resp).catch((e) => log(`${this.name} event error`, e))
this.waiters = this.waiters.filter((w) => !(w.pred(resp) && (w.resolve(resp), true)))
}
async cmd(cmd) {
const corrId = String(++this.corrId)
log(`${this.name} cmd:`, cmd.slice(0, 100))
const resp = await new Promise((resolve) => {
this.pending.set(corrId, resolve)
this.ws.send(JSON.stringify({corrId, cmd}))
})
if (resp.type === "chatCmdError") throw new Error(`${this.name} ${cmd.split(" ", 2).join(" ")}: ${JSON.stringify(resp)}`)
return resp
}
waitEvent(pred, timeout, what) {
const found = this.events.find(pred)
if (found) return Promise.resolve(found)
return new Promise((resolve, reject) => {
const waiter = {
pred,
resolve: (resp) => {
clearTimeout(timer)
resolve(resp)
},
}
this.waiters.push(waiter)
const timer = setTimeout(() => {
this.waiters = this.waiters.filter((w) => w !== waiter)
reject(new Error(`${this.name}: no ${what} event in ${timeout}ms`))
}, timeout)
})
}
stop() {
this.ws.close()
this.proc.kill()
}
}
async function connectContacts(alice, bob) {
const inv = await alice.cmd("/c")
await bob.cmd(`/c ${inv.connLinkInvitation.connFullLink}`)
const connected = (c) => c.waitEvent((e) => e.type === "contactConnected", START_TIMEOUT, "contactConnected")
const [a, b] = await Promise.all([connected(alice), connected(bob)])
return [a.contact.contactId, b.contact.contactId]
}
module.exports = {startSmpServer, ChatClient, connectContacts}