Documentation
How the app connects two devices, and the connection API it is built on.
Contents
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.
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.
// 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:
{
"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.
{
"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
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
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
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
Host creates an offer
A peer connection and a "messages" data channel are created; the local offer is set.
- 2
Host gathers ICE candidates
Up to 300 ms, then the offer with its candidates is serialized as JSON.
- 3
Guest applies the offer
The guest scans the QR code, sets the remote description and creates an answer.
- 4
Guest shows the answer
After its own candidate gathering, the answer is shown as a QR code.
- 5
Host applies the answer
ICE connectivity checks start between the two devices.
- 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).
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.
// 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).
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
Camera not working
The app cannot access the camera.
- Open the app over HTTPS (or on localhost): browsers only allow camera access in a secure context.
- Grant camera permission when prompted, or re-enable it in the browser's site settings.
- No camera available? Use the paste option to exchange the offer and answer as text.
Connection does not open
The answer was applied but the data channel never opens.
- Check both devices have network access; offline, they must be on the same local network.
- Listen for webrtc-ice-error or call getIceCandidateErrors() to see whether the STUN servers are reachable.
- Firewalls that block UDP and symmetric NATs usually need a TURN relay. The app has none configured; setIceConfiguration() followed by restartIce() applies one you supply to a running connection.
QR code not scanning
The camera does not detect the QR code.
- Improve the lighting and hold the camera steady at a distance where the whole code is in view.
- Increase the screen brightness of the device showing the code.
- The offer contains the full SDP, so the code is dense; if it will not scan, use the paste option.
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