crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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>