Add TURN relay and an ICE self-test indicator
STUN-only pairing fails for symmetric NATs, hairpinning, and browsers
that restrict host candidates; add the openrelay TURN server. Also run
a one-shot ICE gathering self-test at startup and show the result in
the presence panel, so a browser that blocks WebRTC says so instead of
silently never finding peers.
3 changed files+99−2
src/App.tsxmodified+33−0View file
@@ -1,6 +1,7 @@
11 import {useEffect, useState, type CSSProperties} from 'react'
22 import {RegionView, type Segment, type Pt} from './render/RegionView'
33 import {useNetwork} from './useNetwork'
4+import {runIceTest, type IceTestResult} from './p2p/iceTest'
45 import type {Points} from './types'
56
67 // Discrete sample-count choices; the slider indexes into the active array.
@@ -88,6 +89,19 @@ export default function App() {
8889 const [n, setN] = useState(params.n)
8990 useEffect(() => setN(params.n), [params.n])
9091
92+ // One-shot WebRTC self-test, so a browser that blocks ICE says so instead of
93+ // silently never finding peers.
94+ const [ice, setIce] = useState<IceTestResult | null>(null)
95+ useEffect(() => {
96+ let alive = true
97+ void runIceTest().then(r => {
98+ if (alive) setIce(r)
99+ })
100+ return () => {
101+ alive = false
102+ }
103+ }, [])
104+
91105 const nonConvex = !params.convex
92106 const choices = nonConvex ? SAMPLE_CHOICES_NONCONVEX : SAMPLE_CHOICES
93107 const useLocal = nonConvex && params.local
@@ -267,6 +281,25 @@ export default function App() {
267281 {engine === 'error' && engineError && (
268282 <div style={{color: '#b91c1c', marginTop: 2}}>{engineError}</div>
269283 )}
284+ {ice &&
285+ (ice.total === 0 ? (
286+ <div style={{color: '#b91c1c', marginTop: 2}}>
287+ ⚠ this browser is blocking WebRTC (no ICE candidates) — check
288+ privacy extensions/settings
289+ </div>
290+ ) : (
291+ <div style={{color: '#94a3b8'}}>
292+ webrtc: {ice.total} candidate{ice.total === 1 ? '' : 's'} (
293+ {[
294+ ice.host && 'host',
295+ ice.srflx && 'srflx',
296+ ice.relay && 'relay'
297+ ]
298+ .filter(Boolean)
299+ .join(', ') || 'other'}
300+ )
301+ </div>
302+ ))}
270303 </div>
271304 </div>
272305 )
src/p2p/iceTest.tsadded+53−0View file
@@ -0,0 +1,53 @@
1+// A one-shot ICE self-test: gather candidates on a throwaway connection and
2+// report what this browser can produce. Zero candidates means WebRTC is being
3+// blocked (privacy extension / browser policy) and no peer connection can ever
4+// form — the UI surfaces that instead of failing silently.
5+
6+import {ICE_SERVERS} from './peer'
7+
8+export interface IceTestResult {
9+ total: number
10+ host: boolean
11+ srflx: boolean
12+ relay: boolean
13+}
14+
15+export const runIceTest = (ms = 6000): Promise<IceTestResult> =>
16+ new Promise(resolve => {
17+ const res: IceTestResult = {total: 0, host: false, srflx: false, relay: false}
18+ let pc: RTCPeerConnection
19+ try {
20+ pc = new RTCPeerConnection({iceServers: ICE_SERVERS})
21+ } catch {
22+ resolve(res)
23+ return
24+ }
25+ let settled = false
26+ const done = () => {
27+ if (settled) return
28+ settled = true
29+ clearTimeout(timer)
30+ try {
31+ pc.close()
32+ } catch {
33+ /* ignore */
34+ }
35+ resolve(res)
36+ }
37+ const timer = setTimeout(done, ms)
38+ pc.onicecandidate = e => {
39+ if (!e.candidate) {
40+ done()
41+ return
42+ }
43+ res.total++
44+ const c = e.candidate.candidate
45+ if (c.includes(' typ host')) res.host = true
46+ if (c.includes(' typ srflx')) res.srflx = true
47+ if (c.includes(' typ relay')) res.relay = true
48+ }
49+ pc.createDataChannel('icetest')
50+ pc.createOffer()
51+ .then(o => pc.setLocalDescription(o))
52+ .catch(done)
53+ })
src/p2p/peer.tsmodified+13−2View file
@@ -21,10 +21,21 @@ export interface PeerHandlers {
2121 close: () => void
2222 }
2323
24-const ICE_SERVERS: RTCIceServer[] = [
24+export const ICE_SERVERS: RTCIceServer[] = [
2525 {urls: 'stun:stun.l.google.com:19302'},
2626 {urls: 'stun:stun1.l.google.com:19302'},
27- {urls: 'stun:stun.cloudflare.com:3478'}
27+ {urls: 'stun:stun.cloudflare.com:3478'},
28+ // Free TURN relay (openrelayproject) — needed when direct/STUN pairing
29+ // fails (symmetric NAT, hairpinning, host-candidate blocking).
30+ {
31+ urls: [
32+ 'turn:openrelay.metered.ca:80',
33+ 'turn:openrelay.metered.ca:443',
34+ 'turns:openrelay.metered.ca:443'
35+ ],
36+ username: 'openrelayproject',
37+ credential: 'openrelayproject'
38+ }
2839 ]
2940
3041 // Keep binary frames well under the ~256 KB cross-browser SCTP message limit.