Documentation

How the app connects two devices, and the connection API it is built on.

Introduction

WebRTC QR Tool connects two browsers peer to peer and uses QR codes instead of a signaling server: the WebRTC offer and answer are shown as QR codes and scanned by the other device. The engine is the browser's native WebRTC API, wrapped by src/utils/webrtc-native.ts. The web app uses no Rust or WebAssembly code.

What it does

  • QR signaling — the offer and answer are exchanged by QR code or copy-paste; no signaling server is involved.
  • Encrypted data channel — WebRTC always encrypts data channels with DTLS. This is done by the browser, not by app code.
  • STUN for NAT traversal — public STUN servers discover each device's public address. No TURN relay is configured today.
  • Offline mode — when the browser reports it is offline, no ICE servers are used and only local host candidates are offered.

What it does not do

  • The QR payload is plain JSON and is not encrypted: anyone who can read the QR code can read the SDP, including IP addresses in the ICE candidates.
  • Multi-party video calls are not implemented; they need a signaling server.

Getting started

The connection API is not published as a package. It is the module src/utils/webrtc-native.ts inside this app, which exports a single manager instance:

import { webrtcManager } from '@/utils/webrtc-native'

Connecting two devices

The host creates an offer, the guest turns it into an answer, and the host applies the answer. How the strings travel between the devices — QR code, copy-paste — is up to the caller.

Host and guest
import { webrtcManager } from '@/utils/webrtc-native'

// Host (device A): create the offer and show qrData as a QR code.
const { id: hostId, qrData } = await webrtcManager.createHostConnection(
  'my-app',           // free-form label, carried in the offer as "connection"
  'chat invitation',  // free-form text, carried in the offer as "message"
)

// Guest (device B): scannedOffer is the text read from the host's QR code.
// Show answerData as a QR code.
const { id: guestId, answerData } = await webrtcManager.acceptConnection(scannedOffer)

// Host (device A): scannedAnswer is the text read from the guest's QR code.
await webrtcManager.completeConnection(hostId, scannedAnswer)

Knowing when the channel is open

completeConnection() resolves as soon as the answer is applied; that only starts ICE checks. The connection is usable when the data channel opens, which the manager announces with a webrtc-state window event.

Window events
// Data channel state: 'open' | 'closed' | 'error'
window.addEventListener('webrtc-state', (event) => {
  const { connectionId, state } = (event as CustomEvent).detail
  if (state === 'open') {
    // sendMessage returns false when the channel is not open.
    webrtcManager.sendMessage(connectionId, 'hello')
  }
})

// Incoming data channel messages
window.addEventListener('webrtc-message', (event) => {
  const { connectionId, message } = (event as CustomEvent).detail
  console.log(connectionId, message)
})

Core concepts

QR payload format

Each QR code carries a JSON string. The host's offer:

Offer
{
  "id": "conn_…",
  "type": "webrtc-qr-connection",
  "connection": "my-app",
  "message": "chat invitation",
  "timestamp": "<ISO 8601 time>",
  "offer": { "type": "offer", "sdp": "v=0…" }
}

The guest's answer. offerId is the host's connection id; id is the guest's own.

Answer
{
  "type": "answer",
  "id": "conn_…",
  "offerId": "conn_…",
  "sdp": "v=0…"
}

Candidates are gathered before the payload is serialized, so the SDP already contains them (no trickle ICE). Gathering waits for end-of-candidates with a 300 ms deadline, then continues with the candidates it has; an unreachable STUN server therefore cannot block QR generation.

Security

  • Data channels are encrypted with DTLS, as the WebRTC specification requires of every browser.
  • Each SDP carries its peer's DTLS certificate fingerprint. Because the SDP is exchanged by scanning a code on the other device, a third party would have to alter the QR code itself to insert itself into the connection.
  • The QR payload is not encrypted or signed. Treat an offer or answer as readable by anyone who can see the screen.

ICE servers

src/utils/ice-config.ts resolves the ICE server list from three layers, in order:

  1. 1

    Static

    Built into the app and always present: stun:stun.elveris.work:443 (Elveris, us-ashburn-1) and stun:stun.cloudflare.com:3478 (fallback).

  2. 2

    Object storage

    If VITE_ICE_CONFIG_URL is set, a JSON document at that URL replaces the static STUN list. TURN URLs in it are rejected.

  3. 3

    Server grant

    The only source of TURN credentials: a short-lived ConnectivityGrant from the control plane. This app registers no grant fetcher, so it currently has no TURN.

When navigator.onLine is false the manager uses no ICE servers at all. Connection setup never waits on the network: it uses whatever configuration has resolved so far.

Connection flow

  1. 1

    Host creates an offer

    A peer connection and a "messages" data channel are created; the local offer is set.

  2. 2

    Host gathers ICE candidates

    Up to 300 ms, then the offer with its candidates is serialized as JSON.

  3. 3

    Guest applies the offer

    The guest scans the QR code, sets the remote description and creates an answer.

  4. 4

    Guest shows the answer

    After its own candidate gathering, the answer is shown as a QR code.

  5. 5

    Host applies the answer

    ICE connectivity checks start between the two devices.

  6. 6

    Data channel opens

    Both sides receive a webrtc-state event with state "open"; only now is the connection usable.

API reference

Methods of webrtcManager (src/utils/webrtc-native.ts).

Connection setup

createHostConnection(connectionData: string, message: string): Promise<{ id: string; qrData: string }>
Creates a peer connection with a data channel, waits for ICE gathering and returns the offer payload to show as a QR code.
acceptConnection(qrDataString: string): Promise<{ id: string; answerData: string }>
Guest side: applies an offer payload and returns the answer payload. The guest gets its own connection id.
completeConnection(connectionId: string, answerData: string): Promise<void>
Host side: applies an answer payload (also the answer to an ICE restart). Resolves when the answer is applied, not when the channel opens.

Messaging and lifecycle

sendMessage(connectionId: string, message: string | ArrayBuffer | ArrayBufferView): boolean
Sends a string or raw binary buffer over the data channel. Returns false if the connection is unknown or the channel is not open.
sendBinary(connectionId: string, data: ArrayBuffer | ArrayBufferView): boolean
Sends a raw binary buffer over the data channel directly without Base64 overhead.
getConnectionState(connectionId: string): string
"connecting", "connected", "disconnected" or "error". Unknown ids report "disconnected".
closeConnection(connectionId: string): void
Closes the data channel and the peer connection and forgets the connection.
getActiveConnections(): string[]
Ids of the connections the manager currently holds.

ICE and diagnostics

restartIce(connectionId: string): Promise<string>
Starts an ICE restart; requires signaling state "stable". Returns a restart offer for the peer.
acceptIceRestart(connectionId: string, restartData: string): Promise<string>
Peer side of restartIce(): applies the restart offer and returns an answer, which goes back through completeConnection().
setIceConfiguration(connectionId: string, config: { iceServers?, iceTransportPolicy? }): void
Replaces the ICE servers or transport policy of a running connection. Takes effect for candidates gathered after the next restartIce().
getSelectedPath(connectionId: string): Promise<SelectedPath | null>
The candidate pair ICE selected (candidate types, protocol, addresses, round-trip time, bytes), or null while none is selected.
getIceCandidateErrors(connectionId: string): IceCandidateErrorRecord[]
Candidate-gathering errors reported by the browser, for example an unreachable STUN server.
getLastIceConfig(): ResolvedIceConfig | null
The ICE configuration (sources, layer outcomes, configHash) the most recent connection used; null in offline mode.

Window events

'webrtc-state' detail: { connectionId, state: 'open' | 'closed' | 'error' }
Data channel state changes.
'webrtc-message' detail: { connectionId, message, isBinary? }
A message arrived on the data channel.
'webrtc-binary' detail: { connectionId, data: ArrayBuffer }
A raw binary packet arrived on the data channel (e.g. on-the-fly file chunk).
'webrtc-ice-state' detail: { connectionId, iceConnectionState, connectionState }
ICE and peer connection state changes.
'webrtc-ice-error' detail: IceCandidateErrorRecord
A candidate-gathering error (the same records getIceCandidateErrors() returns).

Examples

Sending a file

The data channel carries strings, so the File transfer screen sends JSON messages: one file-meta, then 64 KiB chunks as base64 in file-chunk, then file-complete. This is the sending side of that protocol (src/views/FileTransfer.vue).

File sender
import { webrtcManager } from '@/utils/webrtc-native'

const CHUNK_SIZE = 64 * 1024

function send(connectionId: string, payload: unknown) {
  if (!webrtcManager.sendMessage(connectionId, JSON.stringify(payload))) {
    throw new Error('Data channel is not open; the payload was not sent')
  }
}

function toBase64(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer)
  let binary = ''
  for (let i = 0; i < bytes.length; i += 0x8000) {
    binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
  }
  return btoa(binary)
}

async function sendFile(connectionId: string, file: File) {
  const id = crypto.randomUUID()
  const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE))

  send(connectionId, { kind: 'file-meta', id, name: file.name, size: file.size, mimeType: file.type, totalChunks })

  for (let index = 0; index < totalChunks; index += 1) {
    const start = index * CHUNK_SIZE
    const buffer = await file.slice(start, start + CHUNK_SIZE).arrayBuffer()
    send(connectionId, { kind: 'file-chunk', id, index, totalChunks, data: toBase64(buffer) })
  }

  send(connectionId, { kind: 'file-complete', id })
}

Restarting ICE after a network change

An ICE restart keeps the data channel and DTLS session and replaces only the ICE credentials and candidates. Signaling is out of band here too, so the restart offer and answer travel by QR code or paste, like the first exchange.

ICE restart
// Side A (either peer; signaling state must be 'stable')
const restartOffer = await webrtcManager.restartIce(idA)
// ...deliver restartOffer to side B by QR code or paste...

// Side B
const restartAnswer = await webrtcManager.acceptIceRestart(idB, restartOffer)
// ...deliver restartAnswer back to side A...

// Side A
await webrtcManager.completeConnection(idA, restartAnswer)

Inspecting the selected path

getSelectedPath() reads getStats() and reports which candidate pair ICE chose: host (same network), srflx (through NAT, found by STUN) or relay (TURN).

Selected path
const path = await webrtcManager.getSelectedPath(connectionId)
if (path) {
  console.log(path.localCandidateType, '->', path.remoteCandidateType)  // e.g. 'srflx' -> 'host'
  console.log(path.protocol, path.currentRoundTripTime)                  // 'udp', seconds
} else {
  console.log('ICE has not selected a candidate pair yet')
}

Troubleshooting

Browser requirements

The app needs these browser APIs. Current versions of Chrome, Edge, Firefox and Safari provide them.

  • Required
    RTCPeerConnection, RTCDataChannel — the connection and the message channel
  • Optional
    navigator.mediaDevices.getUserMedia — scanning QR codes with the camera (paste works without it)
  • Optional
    RTCPeerConnection.restartIce() — ICE restart only
  • Optional
    RTCPeerConnection.getStats() — getSelectedPath() diagnostics only