EngagementHost.html (6196B)
1 <!doctype html> 2 <html> 3 <head> 4 <meta charset="utf-8"> 5 </head> 6 <body> 7 <script> 8 (() => { 9 const peers = new Map(); 10 const closedEngagementIDs = new Set(); 11 12 function post(message) { 13 window.webkit.messageHandlers.engagement.postMessage(message); 14 } 15 16 function postError(engagementID, error) { 17 post({ 18 type: "onError", 19 engagementID, 20 message: error && error.message ? error.message : String(error) 21 }); 22 } 23 24 function iceServers() { 25 return [{ urls: "stun:stun.cloudflare.com:3478" }]; 26 } 27 28 function createPeer(engagementID) { 29 teardown(engagementID); 30 closedEngagementIDs.delete(engagementID); 31 32 const pc = new RTCPeerConnection({ 33 iceServers: iceServers() 34 }); 35 const peer = { pc, channel: null, closed: false }; 36 peers.set(engagementID, peer); 37 38 pc.ondatachannel = (event) => { 39 attachChannel(engagementID, event.channel); 40 }; 41 pc.onconnectionstatechange = () => { 42 if (pc.connectionState === "failed" || 43 pc.connectionState === "disconnected" || 44 pc.connectionState === "closed") { 45 postClose(engagementID); 46 } 47 }; 48 pc.oniceconnectionstatechange = () => { 49 if (pc.iceConnectionState === "failed" || 50 pc.iceConnectionState === "disconnected" || 51 pc.iceConnectionState === "closed") { 52 postClose(engagementID); 53 } 54 }; 55 return peer; 56 } 57 58 function attachChannel(engagementID, channel) { 59 const peer = peers.get(engagementID); 60 if (!peer) { 61 channel.close(); 62 return; 63 } 64 peer.channel = channel; 65 channel.binaryType = "arraybuffer"; 66 channel.onopen = () => post({ type: "onChannelOpen", engagementID }); 67 channel.onclose = () => postClose(engagementID); 68 channel.onerror = (event) => postError(engagementID, event.error || "Data channel error"); 69 channel.onmessage = async (event) => { 70 try { 71 let bytes; 72 if (event.data instanceof ArrayBuffer) { 73 bytes = new Uint8Array(event.data); 74 } else if (event.data instanceof Blob) { 75 bytes = new Uint8Array(await event.data.arrayBuffer()); 76 } else { 77 bytes = new TextEncoder().encode(String(event.data)); 78 } 79 post({ 80 type: "onChannelMessage", 81 engagementID, 82 message: bytesToBase64(bytes) 83 }); 84 } catch (error) { 85 postError(engagementID, error); 86 } 87 }; 88 } 89 90 async function waitForIceComplete(pc) { 91 if (pc.iceGatheringState === "complete") { 92 return; 93 } 94 await new Promise((resolve) => { 95 const onChange = () => { 96 if (pc.iceGatheringState === "complete") { 97 pc.removeEventListener("icegatheringstatechange", onChange); 98 resolve(); 99 } 100 }; 101 pc.addEventListener("icegatheringstatechange", onChange); 102 }); 103 } 104 105 function signalFromLocalDescription(pc) { 106 const sdp = pc.localDescription ? pc.localDescription.sdp : ""; 107 return { 108 sdp, 109 candidates: candidatesFromSDP(sdp) 110 }; 111 } 112 113 function candidatesFromSDP(sdp) { 114 return sdp 115 .split(/\r?\n/) 116 .filter((line) => line.startsWith("a=candidate:")) 117 .map((line) => line.slice("a=".length)); 118 } 119 120 function bytesToBase64(bytes) { 121 let binary = ""; 122 for (let i = 0; i < bytes.length; i += 1) { 123 binary += String.fromCharCode(bytes[i]); 124 } 125 return btoa(binary); 126 } 127 128 function base64ToBytes(base64) { 129 const binary = atob(base64); 130 const bytes = new Uint8Array(binary.length); 131 for (let i = 0; i < binary.length; i += 1) { 132 bytes[i] = binary.charCodeAt(i); 133 } 134 return bytes; 135 } 136 137 function postClose(engagementID) { 138 if (closedEngagementIDs.has(engagementID)) { 139 return; 140 } 141 closedEngagementIDs.add(engagementID); 142 const peer = peers.get(engagementID); 143 if (peer) { 144 peer.closed = true; 145 } 146 post({ type: "onChannelClose", engagementID }); 147 } 148 149 async function createOffer(engagementID) { 150 try { 151 const peer = createPeer(engagementID); 152 const channel = peer.pc.createDataChannel("crossmate", { 153 ordered: false, 154 maxRetransmits: 0 155 }); 156 attachChannel(engagementID, channel); 157 await peer.pc.setLocalDescription(await peer.pc.createOffer()); 158 await waitForIceComplete(peer.pc); 159 const signal = signalFromLocalDescription(peer.pc); 160 post({ type: "onSignal", engagementID, signal }); 161 return signal; 162 } catch (error) { 163 postError(engagementID, error); 164 throw error; 165 } 166 } 167 168 async function acceptOfferAndReply(engagementID, signal) { 169 try { 170 const peer = createPeer(engagementID); 171 await peer.pc.setRemoteDescription({ type: "offer", sdp: signal.sdp }); 172 await peer.pc.setLocalDescription(await peer.pc.createAnswer()); 173 await waitForIceComplete(peer.pc); 174 const reply = signalFromLocalDescription(peer.pc); 175 post({ type: "onSignal", engagementID, signal: reply }); 176 return reply; 177 } catch (error) { 178 postError(engagementID, error); 179 throw error; 180 } 181 } 182 183 async function acceptReply(engagementID, signal) { 184 try { 185 const peer = peers.get(engagementID); 186 if (!peer) { 187 throw new Error("No peer connection for engagement " + engagementID); 188 } 189 await peer.pc.setRemoteDescription({ type: "answer", sdp: signal.sdp }); 190 return true; 191 } catch (error) { 192 postError(engagementID, error); 193 throw error; 194 } 195 } 196 197 function send(engagementID, base64Message) { 198 const peer = peers.get(engagementID); 199 if (!peer || !peer.channel || peer.channel.readyState !== "open") { 200 throw new Error("Engagement channel is not open"); 201 } 202 peer.channel.send(base64ToBytes(base64Message)); 203 return true; 204 } 205 206 function teardown(engagementID) { 207 const peer = peers.get(engagementID); 208 if (!peer) { 209 return true; 210 } 211 postClose(engagementID); 212 if (peer.channel) { 213 peer.channel.close(); 214 } 215 peer.pc.close(); 216 peers.delete(engagementID); 217 return true; 218 } 219 220 window.crossmateEngagement = { 221 createOffer, 222 acceptOfferAndReply, 223 acceptReply, 224 send, 225 teardown 226 }; 227 })(); 228 </script> 229 </body> 230 </html>