Scaffold flit: PWA + Go CLI for ephemeral E2E file transfer
Ports the yaw/2.1 identity/signaling/WebRTC transport from waste-go to both a browser PWA (with QR pairing and Web Share Target) and a headless Go CLI, trimmed to 1:1 ephemeral file transfer only. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
515
pwa/src/transport/flit.ts
Normal file
515
pwa/src/transport/flit.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
// flit transport — yaw/2.1 signaling + WebRTC, trimmed from waste-go's
|
||||
// web/src/adapter/browser.ts to 1:1 ephemeral pairing + file transfer only.
|
||||
// Dropped vs. waste-go: chat, pm, reactions, file browsing/share lists,
|
||||
// multi-network mesh. Kept: identity, ekey/offer/answer handshake, hello
|
||||
// verification, file-offer/accept/chunked transfer.
|
||||
|
||||
import sodium from 'libsodium-wrappers'
|
||||
|
||||
const BIND_PREFIX = 'yaw/2 bind'
|
||||
const EKEY_PREFIX = 'yaw/2.1 ekey'
|
||||
const FS_TIMEOUT = 2000
|
||||
const STUN = 'stun:stun.l.google.com:19302'
|
||||
const CHUNK = 64 * 1024
|
||||
|
||||
// ── TURN ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Credentials are minted server-side by the anchor's GET /turn-credentials
|
||||
// (coturn use-auth-secret scheme; see waste-go/cmd/anchor/main.go). The raw
|
||||
// shared secret never reaches the browser — only a short-lived
|
||||
// {username,credential} pair.
|
||||
export interface FlitConfig {
|
||||
signalURL: string
|
||||
turnURL?: string
|
||||
turnCredentialsURL?: string
|
||||
}
|
||||
|
||||
async function fetchTurnCredentials(url: string): Promise<{ username: string; credential: string } | null> {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
if (!data.username || !data.credential) return null
|
||||
return { username: data.username, credential: data.credential }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function iceServers(cfg: FlitConfig): Promise<RTCIceServer[]> {
|
||||
const servers: RTCIceServer[] = [{ urls: STUN }]
|
||||
if (cfg.turnURL && cfg.turnCredentialsURL) {
|
||||
const creds = await fetchTurnCredentials(cfg.turnCredentialsURL)
|
||||
if (creds) servers.push({ urls: cfg.turnURL, username: creds.username, credential: creds.credential })
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
const enc = (s: string) => new TextEncoder().encode(s)
|
||||
|
||||
function concat(...arrs: Uint8Array[]): Uint8Array {
|
||||
const n = arrs.reduce((a, b) => a + b.length, 0)
|
||||
const out = new Uint8Array(n); let o = 0
|
||||
for (const a of arrs) { out.set(a, o); o += a.length }
|
||||
return out
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export async function netHash(name: string): Promise<string> {
|
||||
const input = enc('yaw2-net:' + name)
|
||||
if (globalThis.crypto?.subtle) {
|
||||
const digest = await globalThis.crypto.subtle.digest('SHA-256', input)
|
||||
return toHex(new Uint8Array(digest))
|
||||
}
|
||||
throw new Error('SHA-256 not available in this browser runtime')
|
||||
}
|
||||
|
||||
// ── Identity ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export class Identity {
|
||||
pub: Uint8Array
|
||||
priv: Uint8Array
|
||||
id: string
|
||||
curvePriv: Uint8Array
|
||||
|
||||
constructor(kp: { publicKey: Uint8Array; privateKey: Uint8Array }) {
|
||||
this.pub = kp.publicKey
|
||||
this.priv = kp.privateKey
|
||||
this.id = sodium.to_hex(this.pub)
|
||||
this.curvePriv = sodium.crypto_sign_ed25519_sk_to_curve25519(this.priv)
|
||||
}
|
||||
|
||||
// load() always persists — this is the long-lived device identity used
|
||||
// both for the persistent keyring and to sign ephemeral sessions.
|
||||
static load(): Identity {
|
||||
const seedHex = localStorage.getItem('flit_seed')
|
||||
let kp
|
||||
if (seedHex) {
|
||||
kp = sodium.crypto_sign_seed_keypair(sodium.from_hex(seedHex))
|
||||
} else {
|
||||
kp = sodium.crypto_sign_keypair()
|
||||
localStorage.setItem('flit_seed', sodium.to_hex(kp.privateKey.slice(0, 32)))
|
||||
}
|
||||
return new Identity(kp)
|
||||
}
|
||||
|
||||
sign(data: Uint8Array): Uint8Array {
|
||||
return sodium.crypto_sign_detached(data, this.priv)
|
||||
}
|
||||
|
||||
static verify(idHex: string, data: Uint8Array, sig: Uint8Array): boolean {
|
||||
try { return sodium.crypto_sign_verify_detached(sig, data, sodium.from_hex(idHex)) }
|
||||
catch { return false }
|
||||
}
|
||||
|
||||
seal(recipIdHex: string, plaintext: Uint8Array): string {
|
||||
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(recipIdHex))
|
||||
const nonce = sodium.randombytes_buf(24)
|
||||
const ct = sodium.crypto_box_easy(plaintext, nonce, pub, this.curvePriv)
|
||||
return sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL)
|
||||
}
|
||||
|
||||
open(senderIdHex: string, boxB64: string): Uint8Array | null {
|
||||
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(senderIdHex))
|
||||
const box = sodium.from_base64(boxB64, sodium.base64_variants.ORIGINAL)
|
||||
try { return sodium.crypto_box_open_easy(box.slice(24), box.slice(0, 24), pub, this.curvePriv) }
|
||||
catch { return null }
|
||||
}
|
||||
|
||||
get short(): string {
|
||||
return this.id.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signaling ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export class Signaling {
|
||||
private ws: WebSocket | null = null
|
||||
private _closed = false
|
||||
private _backoff = 1000
|
||||
private _cbs: {
|
||||
onFrom?: (from: string, box: string) => void
|
||||
onJoin?: (id: string) => void
|
||||
onLeave?: (id: string) => void
|
||||
onReconnect?: (peers: string[]) => void
|
||||
} = {}
|
||||
|
||||
private url: string
|
||||
private identity: Identity
|
||||
private net: string
|
||||
|
||||
constructor(url: string, identity: Identity, net: string) {
|
||||
this.url = url
|
||||
this.identity = identity
|
||||
this.net = net
|
||||
}
|
||||
|
||||
connect(
|
||||
onFrom: (from: string, box: string) => void,
|
||||
onJoin: (id: string) => void,
|
||||
onLeave: (id: string) => void,
|
||||
onReconnect: (peers: string[]) => void,
|
||||
): Promise<string[]> {
|
||||
this._cbs = { onFrom, onJoin, onLeave, onReconnect }
|
||||
return this._open(true)
|
||||
}
|
||||
|
||||
private _open(initial: boolean): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.url)
|
||||
this.ws = ws
|
||||
let joined = false
|
||||
|
||||
ws.onerror = () => { if (initial && !joined) reject(new Error('signaling connection failed')) }
|
||||
ws.onclose = () => { if (!this._closed) this._scheduleReconnect() }
|
||||
ws.onmessage = (ev) => {
|
||||
let m: Record<string, unknown>
|
||||
try { m = JSON.parse(ev.data) } catch { return }
|
||||
|
||||
if (m['type'] === 'challenge') {
|
||||
const nonce = sodium.from_hex(m['nonce'] as string)
|
||||
const sig = sodium.to_hex(this.identity.sign(concat(nonce, enc(this.net))))
|
||||
ws.send(JSON.stringify({ type: 'join', id: this.identity.id, net: this.net, sig }))
|
||||
} else if (m['type'] === 'joined') {
|
||||
joined = true; this._backoff = 1000
|
||||
const peers = (m['peers'] as string[]) || []
|
||||
if (initial) resolve(peers)
|
||||
else this._cbs.onReconnect?.(peers)
|
||||
} else if (m['type'] === 'from') {
|
||||
this._cbs.onFrom?.(m['from'] as string, m['box'] as string)
|
||||
} else if (m['type'] === 'peer-join') {
|
||||
this._cbs.onJoin?.(m['id'] as string)
|
||||
} else if (m['type'] === 'peer-leave') {
|
||||
this._cbs.onLeave?.(m['id'] as string)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private _scheduleReconnect() {
|
||||
if (this._closed) return
|
||||
const delay = this._backoff
|
||||
this._backoff = Math.min(this._backoff * 2, 30000)
|
||||
setTimeout(() => { if (!this._closed) this._open(false).catch(() => {}) }, delay)
|
||||
}
|
||||
|
||||
sendTo(toId: string, box: string) {
|
||||
try { this.ws?.send(JSON.stringify({ type: 'to', to: toId, box })) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
close() { this._closed = true; this.ws?.close() }
|
||||
}
|
||||
|
||||
function gatherComplete(pc: RTCPeerConnection): Promise<void> {
|
||||
if (pc.iceGatheringState === 'complete') return Promise.resolve()
|
||||
return new Promise((res) => {
|
||||
const check = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
pc.removeEventListener('icegatheringstatechange', check)
|
||||
res()
|
||||
}
|
||||
}
|
||||
pc.addEventListener('icegatheringstatechange', check)
|
||||
setTimeout(res, 6000)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Peer connection (yaw/2.1, file transfer only) ─────────────────────────────
|
||||
|
||||
export interface FileOfferEvent { peer: string; xid: string; name: string; size: number }
|
||||
export interface FileProgressEvent { peer: string; xid: string; name: string; received: number; total: number }
|
||||
export interface FileRecvEvent { peer: string; name: string; size: number; url: string }
|
||||
|
||||
type PeerEvents = {
|
||||
connected: (verified: boolean) => void
|
||||
status: (state: RTCPeerConnectionState) => void
|
||||
fileOffer: (e: FileOfferEvent) => void
|
||||
fileProgress: (e: FileProgressEvent) => void
|
||||
fileRecv: (e: FileRecvEvent) => void
|
||||
fileCancelled: (xid: string) => void
|
||||
}
|
||||
|
||||
export class PeerConn {
|
||||
pc: RTCPeerConnection
|
||||
dc: RTCDataChannel | null = null
|
||||
verified = false
|
||||
peerAuthed = false
|
||||
created = Date.now()
|
||||
|
||||
private _esk: Uint8Array
|
||||
private _epk: Uint8Array
|
||||
peer_epk: Uint8Array | null = null
|
||||
private _ekeySent = false
|
||||
private _offerPending = false
|
||||
private _offered = false
|
||||
|
||||
private _recv: Record<string, { name: string; size: number; buf: ArrayBuffer[]; have: number; cancelled?: boolean }> = {}
|
||||
private _recvChannels: Record<string, RTCDataChannel> = {}
|
||||
private _pushQueue: Map<string, File> = new Map()
|
||||
|
||||
private identity: Identity
|
||||
private sig: Signaling
|
||||
peerId: string
|
||||
private events: Partial<PeerEvents>
|
||||
|
||||
constructor(
|
||||
identity: Identity,
|
||||
sig: Signaling,
|
||||
peerId: string,
|
||||
events: Partial<PeerEvents>,
|
||||
ice: RTCIceServer[],
|
||||
) {
|
||||
this.identity = identity
|
||||
this.sig = sig
|
||||
this.peerId = peerId
|
||||
this.events = events
|
||||
this.pc = new RTCPeerConnection({ iceServers: ice })
|
||||
const kp = sodium.crypto_box_keypair()
|
||||
this._esk = kp.privateKey
|
||||
this._epk = kp.publicKey
|
||||
this.pc.ondatachannel = (ev) => this._wire(ev.channel)
|
||||
this.pc.onconnectionstatechange = () => this.events.status?.(this.pc.connectionState)
|
||||
}
|
||||
|
||||
private _seal(obj: object, preferEph: boolean): string {
|
||||
const data = enc(JSON.stringify(obj))
|
||||
if (preferEph && this.peer_epk) {
|
||||
const nonce = sodium.randombytes_buf(24)
|
||||
const ct = sodium.crypto_box_easy(data, nonce, this.peer_epk, this._esk)
|
||||
return sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL)
|
||||
}
|
||||
return this.identity.seal(this.peerId, data)
|
||||
}
|
||||
|
||||
private _open(box: string): [Uint8Array | null, boolean] {
|
||||
if (this.peer_epk) {
|
||||
try {
|
||||
const raw = sodium.from_base64(box, sodium.base64_variants.ORIGINAL)
|
||||
return [sodium.crypto_box_open_easy(raw.slice(24), raw.slice(0, 24), this.peer_epk, this._esk), true]
|
||||
} catch { /* fall through to static */ }
|
||||
}
|
||||
return [this.identity.open(this.peerId, box), false]
|
||||
}
|
||||
|
||||
private _sendEkey() {
|
||||
if (this._ekeySent) return
|
||||
this._ekeySent = true
|
||||
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id), sodium.from_hex(this.peerId), this._epk)
|
||||
const msg = { kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk), sig: sodium.to_hex(this.identity.sign(signed)) }
|
||||
this.sig.sendTo(this.peerId, this._seal(msg, false))
|
||||
}
|
||||
|
||||
private async _onEkey(obj: Record<string, unknown>) {
|
||||
if (this.peer_epk) return
|
||||
try {
|
||||
const epkRaw = sodium.from_hex(obj['epk'] as string)
|
||||
const sigBytes = sodium.from_hex(obj['sig'] as string)
|
||||
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.peerId), sodium.from_hex(this.identity.id), epkRaw)
|
||||
if (epkRaw.length !== 32 || !Identity.verify(this.peerId, signed, sigBytes)) return
|
||||
this.peer_epk = epkRaw
|
||||
} catch { return }
|
||||
this._sendEkey()
|
||||
if (this._offerPending) await this._doOffer()
|
||||
}
|
||||
|
||||
async startOffer() {
|
||||
this._sendEkey()
|
||||
this._offerPending = true
|
||||
setTimeout(() => { if (this._offerPending) this._doOffer() }, FS_TIMEOUT)
|
||||
if (this.peer_epk) await this._doOffer()
|
||||
}
|
||||
|
||||
private async _doOffer() {
|
||||
if (this._offered) return
|
||||
this._offered = true; this._offerPending = false
|
||||
this.dc = this.pc.createDataChannel('yaw')
|
||||
this._wire(this.dc)
|
||||
await this.pc.setLocalDescription(await this.pc.createOffer())
|
||||
await gatherComplete(this.pc)
|
||||
this.sig.sendTo(this.peerId, this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true))
|
||||
}
|
||||
|
||||
async onBox(box: string) {
|
||||
const [plain, usedEph] = this._open(box)
|
||||
if (!plain) return
|
||||
this.peerAuthed = true
|
||||
let obj: Record<string, unknown>
|
||||
try { obj = JSON.parse(new TextDecoder().decode(plain)) } catch { return }
|
||||
|
||||
if (obj['kind'] === 'ekey') {
|
||||
await this._onEkey(obj)
|
||||
} else if (obj['kind'] === 'offer') {
|
||||
await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string })
|
||||
await this.pc.setLocalDescription(await this.pc.createAnswer())
|
||||
await gatherComplete(this.pc)
|
||||
this.sig.sendTo(this.peerId, this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph))
|
||||
} else if (obj['kind'] === 'answer') {
|
||||
await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string })
|
||||
}
|
||||
}
|
||||
|
||||
private _wire(channel: RTCDataChannel) {
|
||||
if (channel.label === 'yaw') {
|
||||
this.dc = channel
|
||||
channel.onopen = () => this._sendHello()
|
||||
channel.onmessage = (ev) => this._onControl(ev.data)
|
||||
if (channel.readyState === 'open') this._sendHello()
|
||||
return
|
||||
}
|
||||
if (channel.label.startsWith('f:')) {
|
||||
const xid = channel.label.slice(2)
|
||||
const rx = this._recv[xid]
|
||||
if (!rx) return
|
||||
channel.binaryType = 'arraybuffer'
|
||||
channel.onmessage = (ev) => {
|
||||
if (!(ev.data instanceof ArrayBuffer)) return
|
||||
rx.buf.push(ev.data)
|
||||
rx.have += ev.data.byteLength
|
||||
this.events.fileProgress?.({ peer: this.peerId, xid, name: rx.name, received: rx.have, total: rx.size })
|
||||
}
|
||||
channel.onclose = () => {
|
||||
if (rx.cancelled) { delete this._recv[xid]; return }
|
||||
const blob = new Blob(rx.buf)
|
||||
const url = URL.createObjectURL(blob)
|
||||
this.events.fileRecv?.({ peer: this.peerId, name: rx.name, size: rx.size, url })
|
||||
delete this._recv[xid]
|
||||
}
|
||||
this._recvChannels[xid] = channel
|
||||
}
|
||||
}
|
||||
|
||||
private _sendHello() {
|
||||
this._dc({ type: 'hello', id: this.identity.id, sig: sodium.to_hex(this.identity.sign(enc(BIND_PREFIX))) })
|
||||
}
|
||||
|
||||
private _onControl(data: string) {
|
||||
let m: Record<string, unknown>
|
||||
try { m = JSON.parse(data) } catch { return }
|
||||
|
||||
if (m['type'] === 'hello') {
|
||||
this.verified = m['id'] === this.peerId && this.peerAuthed
|
||||
this.events.connected?.(this.verified)
|
||||
} else if (m['type'] === 'file-offer') {
|
||||
this.events.fileOffer?.({ peer: this.peerId, xid: m['xid'] as string, name: m['name'] as string, size: m['size'] as number })
|
||||
} else if (m['type'] === 'file-accept') {
|
||||
const xid = m['xid'] as string
|
||||
if (this._pushQueue.has(xid)) void this._stream(xid)
|
||||
} else if (m['type'] === 'file-cancel') {
|
||||
const xid = m['xid'] as string
|
||||
if (this._recv[xid]) {
|
||||
this._recv[xid].cancelled = true
|
||||
this._recvChannels[xid]?.close()
|
||||
delete this._recvChannels[xid]
|
||||
this.events.fileCancelled?.(xid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acceptOffer(xid: string, name: string, size: number) {
|
||||
this._recv[xid] = { name, size, buf: [], have: 0 }
|
||||
this._dc({ type: 'file-accept', xid })
|
||||
}
|
||||
|
||||
rejectOffer(xid: string) {
|
||||
this._dc({ type: 'file-cancel', xid })
|
||||
}
|
||||
|
||||
sendFile(file: File) {
|
||||
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
this._pushQueue.set(xid, file)
|
||||
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
|
||||
}
|
||||
|
||||
private async _stream(xid: string) {
|
||||
const file = this._pushQueue.get(xid)
|
||||
if (!file) return
|
||||
this._pushQueue.delete(xid)
|
||||
const dc = this.pc.createDataChannel(`f:${xid}`)
|
||||
dc.binaryType = 'arraybuffer'
|
||||
await new Promise<void>(res => { dc.onopen = () => res() })
|
||||
const buf = await file.arrayBuffer()
|
||||
let offset = 0
|
||||
while (offset < buf.byteLength) {
|
||||
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
|
||||
dc.send(buf.slice(offset, offset + CHUNK))
|
||||
offset += CHUNK
|
||||
}
|
||||
dc.close()
|
||||
}
|
||||
|
||||
private _dc(obj: object) {
|
||||
if (this.dc?.readyState === 'open') this.dc.send(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
close() { try { this.pc.close() } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
// ── Session: one ephemeral or persistent pairing ──────────────────────────────
|
||||
|
||||
export class FlitSession {
|
||||
identity: Identity
|
||||
private sig: Signaling | null = null
|
||||
private peer: PeerConn | null = null
|
||||
networkId = ''
|
||||
|
||||
private cfg: FlitConfig
|
||||
|
||||
constructor(cfg: FlitConfig) {
|
||||
this.cfg = cfg
|
||||
this.identity = Identity.load()
|
||||
}
|
||||
|
||||
static async init(cfg: FlitConfig): Promise<FlitSession> {
|
||||
await sodium.ready
|
||||
return new FlitSession(cfg)
|
||||
}
|
||||
|
||||
// Join an ephemeral or named room by name; trustedPeerId, if given, is the
|
||||
// only peer this session will complete a handshake with (others are
|
||||
// ignored — defends against a third party joining a guessed room name).
|
||||
async join(roomName: string, events: Partial<PeerEvents>, trustedPeerId?: string): Promise<void> {
|
||||
const hash = await netHash(roomName)
|
||||
this.networkId = hash
|
||||
|
||||
const sig = new Signaling(this.cfg.signalURL, this.identity, hash)
|
||||
this.sig = sig
|
||||
const ice = await iceServers(this.cfg)
|
||||
|
||||
const connectTo = async (pid: string) => {
|
||||
if (pid === this.identity.id) return
|
||||
if (trustedPeerId && pid !== trustedPeerId) return
|
||||
if (this.peer) return
|
||||
this.peer = new PeerConn(this.identity, sig, pid, events, ice)
|
||||
if (this.identity.id < pid) await this.peer.startOffer()
|
||||
}
|
||||
|
||||
const present = await sig.connect(
|
||||
(from, box) => { void this._onFrom(from, box, events, ice, trustedPeerId) },
|
||||
(pid) => { void connectTo(pid) },
|
||||
() => { /* peer-leave: nothing to clean up for a single 1:1 session */ },
|
||||
(peers) => { peers.forEach(pid => void connectTo(pid)) },
|
||||
)
|
||||
for (const pid of present) await connectTo(pid)
|
||||
}
|
||||
|
||||
private async _onFrom(from: string, box: string, events: Partial<PeerEvents>, ice: RTCIceServer[], trustedPeerId?: string) {
|
||||
if (from === this.identity.id) return
|
||||
if (trustedPeerId && from !== trustedPeerId) return
|
||||
if (!this.peer) this.peer = new PeerConn(this.identity, this.sig!, from, events, ice)
|
||||
await this.peer.onBox(box)
|
||||
}
|
||||
|
||||
sendFile(file: File) { this.peer?.sendFile(file) }
|
||||
acceptOffer(xid: string, name: string, size: number) { this.peer?.acceptOffer(xid, name, size) }
|
||||
rejectOffer(xid: string) { this.peer?.rejectOffer(xid) }
|
||||
|
||||
close() {
|
||||
this.sig?.close()
|
||||
this.peer?.close()
|
||||
this.sig = null
|
||||
this.peer = null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user