snapdrop

A Progressive Web App for local file sharing https://github.com/RobinLinus/snapdrop snapdrop.net
git clone http://git.hanabi.in/repos/snapdrop.git
Log | Files | Refs | README | LICENSE

network.js (15328B)


      1 window.URL = window.URL || window.webkitURL;
      2 window.isRtcSupported = !!(window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection);
      3 
      4 class ServerConnection {
      5 
      6     constructor() {
      7         this._connect();
      8         Events.on('beforeunload', e => this._disconnect());
      9         Events.on('pagehide', e => this._disconnect());
     10         document.addEventListener('visibilitychange', e => this._onVisibilityChange());
     11     }
     12 
     13     _connect() {
     14         clearTimeout(this._reconnectTimer);
     15         if (this._isConnected() || this._isConnecting()) return;
     16         const ws = new WebSocket(this._endpoint());
     17         ws.binaryType = 'arraybuffer';
     18         ws.onopen = e => console.log('WS: server connected');
     19         ws.onmessage = e => this._onMessage(e.data);
     20         ws.onclose = e => this._onDisconnect();
     21         ws.onerror = e => console.error(e);
     22         this._socket = ws;
     23     }
     24 
     25     _onMessage(msg) {
     26         msg = JSON.parse(msg);
     27         console.log('WS:', msg);
     28         switch (msg.type) {
     29             case 'peers':
     30                 Events.fire('peers', msg.peers);
     31                 break;
     32             case 'peer-joined':
     33                 Events.fire('peer-joined', msg.peer);
     34                 break;
     35             case 'peer-left':
     36                 Events.fire('peer-left', msg.peerId);
     37                 break;
     38             case 'signal':
     39                 Events.fire('signal', msg);
     40                 break;
     41             case 'ping':
     42                 this.send({ type: 'pong' });
     43                 break;
     44             case 'display-name':
     45                 Events.fire('display-name', msg);
     46                 break;
     47             default:
     48                 console.error('WS: unkown message type', msg);
     49         }
     50     }
     51 
     52     send(message) {
     53         if (!this._isConnected()) return;
     54         this._socket.send(JSON.stringify(message));
     55     }
     56 
     57     _endpoint() {
     58         // hack to detect if deployment or development environment
     59         const protocol = location.protocol.startsWith('https') ? 'wss' : 'ws';
     60         const webrtc = window.isRtcSupported ? '/webrtc' : '/fallback';
     61         const url = protocol + '://' + location.host + location.pathname + 'server' + webrtc;
     62         return url;
     63     }
     64 
     65     _disconnect() {
     66         this.send({ type: 'disconnect' });
     67         this._socket.onclose = null;
     68         this._socket.close();
     69     }
     70 
     71     _onDisconnect() {
     72         console.log('WS: server disconnected');
     73         Events.fire('notify-user', 'Connection lost. Retry in 5 seconds...');
     74         clearTimeout(this._reconnectTimer);
     75         this._reconnectTimer = setTimeout(_ => this._connect(), 5000);
     76     }
     77 
     78     _onVisibilityChange() {
     79         if (document.hidden) return;
     80         this._connect();
     81     }
     82 
     83     _isConnected() {
     84         return this._socket && this._socket.readyState === this._socket.OPEN;
     85     }
     86 
     87     _isConnecting() {
     88         return this._socket && this._socket.readyState === this._socket.CONNECTING;
     89     }
     90 }
     91 
     92 class Peer {
     93 
     94     constructor(serverConnection, peerId) {
     95         this._server = serverConnection;
     96         this._peerId = peerId;
     97         this._filesQueue = [];
     98         this._busy = false;
     99     }
    100 
    101     sendJSON(message) {
    102         this._send(JSON.stringify(message));
    103     }
    104 
    105     sendFiles(files) {
    106         for (let i = 0; i < files.length; i++) {
    107             this._filesQueue.push(files[i]);
    108         }
    109         if (this._busy) return;
    110         this._dequeueFile();
    111     }
    112 
    113     _dequeueFile() {
    114         if (!this._filesQueue.length) return;
    115         this._busy = true;
    116         const file = this._filesQueue.shift();
    117         this._sendFile(file);
    118     }
    119 
    120     _sendFile(file) {
    121         this.sendJSON({
    122             type: 'header',
    123             name: file.name,
    124             mime: file.type,
    125             size: file.size
    126         });
    127         this._chunker = new FileChunker(file,
    128             chunk => this._send(chunk),
    129             offset => this._onPartitionEnd(offset));
    130         this._chunker.nextPartition();
    131     }
    132 
    133     _onPartitionEnd(offset) {
    134         this.sendJSON({ type: 'partition', offset: offset });
    135     }
    136 
    137     _onReceivedPartitionEnd(offset) {
    138         this.sendJSON({ type: 'partition-received', offset: offset });
    139     }
    140 
    141     _sendNextPartition() {
    142         if (!this._chunker || this._chunker.isFileEnd()) return;
    143         this._chunker.nextPartition();
    144     }
    145 
    146     _sendProgress(progress) {
    147         this.sendJSON({ type: 'progress', progress: progress });
    148     }
    149 
    150     _onMessage(message) {
    151         if (typeof message !== 'string') {
    152             this._onChunkReceived(message);
    153             return;
    154         }
    155         message = JSON.parse(message);
    156         console.log('RTC:', message);
    157         switch (message.type) {
    158             case 'header':
    159                 this._onFileHeader(message);
    160                 break;
    161             case 'partition':
    162                 this._onReceivedPartitionEnd(message);
    163                 break;
    164             case 'partition-received':
    165                 this._sendNextPartition();
    166                 break;
    167             case 'progress':
    168                 this._onDownloadProgress(message.progress);
    169                 break;
    170             case 'transfer-complete':
    171                 this._onTransferCompleted();
    172                 break;
    173             case 'text':
    174                 this._onTextReceived(message);
    175                 break;
    176         }
    177     }
    178 
    179     _onFileHeader(header) {
    180         this._lastProgress = 0;
    181         this._digester = new FileDigester({
    182             name: header.name,
    183             mime: header.mime,
    184             size: header.size
    185         }, file => this._onFileReceived(file));
    186     }
    187 
    188     _onChunkReceived(chunk) {
    189         if(!chunk.byteLength) return;
    190         
    191         this._digester.unchunk(chunk);
    192         const progress = this._digester.progress;
    193         this._onDownloadProgress(progress);
    194 
    195         // occasionally notify sender about our progress 
    196         if (progress - this._lastProgress < 0.01) return;
    197         this._lastProgress = progress;
    198         this._sendProgress(progress);
    199     }
    200 
    201     _onDownloadProgress(progress) {
    202         Events.fire('file-progress', { sender: this._peerId, progress: progress });
    203     }
    204 
    205     _onFileReceived(proxyFile) {
    206         Events.fire('file-received', proxyFile);
    207         this.sendJSON({ type: 'transfer-complete' });
    208     }
    209 
    210     _onTransferCompleted() {
    211         this._onDownloadProgress(1);
    212         this._reader = null;
    213         this._busy = false;
    214         this._dequeueFile();
    215         Events.fire('notify-user', 'File transfer completed.');
    216     }
    217 
    218     sendText(text) {
    219         const unescaped = btoa(unescape(encodeURIComponent(text)));
    220         this.sendJSON({ type: 'text', text: unescaped });
    221     }
    222 
    223     _onTextReceived(message) {
    224         const escaped = decodeURIComponent(escape(atob(message.text)));
    225         Events.fire('text-received', { text: escaped, sender: this._peerId });
    226     }
    227 }
    228 
    229 class RTCPeer extends Peer {
    230 
    231     constructor(serverConnection, peerId) {
    232         super(serverConnection, peerId);
    233         if (!peerId) return; // we will listen for a caller
    234         this._connect(peerId, true);
    235     }
    236 
    237     _connect(peerId, isCaller) {
    238         if (!this._conn) this._openConnection(peerId, isCaller);
    239 
    240         if (isCaller) {
    241             this._openChannel();
    242         } else {
    243             this._conn.ondatachannel = e => this._onChannelOpened(e);
    244         }
    245     }
    246 
    247     _openConnection(peerId, isCaller) {
    248         this._isCaller = isCaller;
    249         this._peerId = peerId;
    250         this._conn = new RTCPeerConnection(RTCPeer.config);
    251         this._conn.onicecandidate = e => this._onIceCandidate(e);
    252         this._conn.onconnectionstatechange = e => this._onConnectionStateChange(e);
    253         this._conn.oniceconnectionstatechange = e => this._onIceConnectionStateChange(e);
    254     }
    255 
    256     _openChannel() {
    257         const channel = this._conn.createDataChannel('data-channel', { 
    258             ordered: true,
    259             reliable: true // Obsolete. See https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/reliable
    260         });
    261         channel.binaryType = 'arraybuffer';
    262         channel.onopen = e => this._onChannelOpened(e);
    263         this._conn.createOffer().then(d => this._onDescription(d)).catch(e => this._onError(e));
    264     }
    265 
    266     _onDescription(description) {
    267         // description.sdp = description.sdp.replace('b=AS:30', 'b=AS:1638400');
    268         this._conn.setLocalDescription(description)
    269             .then(_ => this._sendSignal({ sdp: description }))
    270             .catch(e => this._onError(e));
    271     }
    272 
    273     _onIceCandidate(event) {
    274         if (!event.candidate) return;
    275         this._sendSignal({ ice: event.candidate });
    276     }
    277 
    278     onServerMessage(message) {
    279         if (!this._conn) this._connect(message.sender, false);
    280 
    281         if (message.sdp) {
    282             this._conn.setRemoteDescription(new RTCSessionDescription(message.sdp))
    283                 .then( _ => {
    284                     if (message.sdp.type === 'offer') {
    285                         return this._conn.createAnswer()
    286                             .then(d => this._onDescription(d));
    287                     }
    288                 })
    289                 .catch(e => this._onError(e));
    290         } else if (message.ice) {
    291             this._conn.addIceCandidate(new RTCIceCandidate(message.ice));
    292         }
    293     }
    294 
    295     _onChannelOpened(event) {
    296         console.log('RTC: channel opened with', this._peerId);
    297         const channel = event.channel || event.target;
    298         channel.onmessage = e => this._onMessage(e.data);
    299         channel.onclose = e => this._onChannelClosed();
    300         this._channel = channel;
    301     }
    302 
    303     _onChannelClosed() {
    304         console.log('RTC: channel closed', this._peerId);
    305         if (!this.isCaller) return;
    306         this._connect(this._peerId, true); // reopen the channel
    307     }
    308 
    309     _onConnectionStateChange(e) {
    310         console.log('RTC: state changed:', this._conn.connectionState);
    311         switch (this._conn.connectionState) {
    312             case 'disconnected':
    313                 this._onChannelClosed();
    314                 break;
    315             case 'failed':
    316                 this._conn = null;
    317                 this._onChannelClosed();
    318                 break;
    319         }
    320     }
    321 
    322     _onIceConnectionStateChange() {
    323         switch (this._conn.iceConnectionState) {
    324             case 'failed':
    325                 console.error('ICE Gathering failed');
    326                 break;
    327             default:
    328                 console.log('ICE Gathering', this._conn.iceConnectionState);
    329         }
    330     }
    331 
    332     _onError(error) {
    333         console.error(error);
    334     }
    335 
    336     _send(message) {
    337         if (!this._channel) return this.refresh();
    338         this._channel.send(message);
    339     }
    340 
    341     _sendSignal(signal) {
    342         signal.type = 'signal';
    343         signal.to = this._peerId;
    344         this._server.send(signal);
    345     }
    346 
    347     refresh() {
    348         // check if channel is open. otherwise create one
    349         if (this._isConnected() || this._isConnecting()) return;
    350         this._connect(this._peerId, this._isCaller);
    351     }
    352 
    353     _isConnected() {
    354         return this._channel && this._channel.readyState === 'open';
    355     }
    356 
    357     _isConnecting() {
    358         return this._channel && this._channel.readyState === 'connecting';
    359     }
    360 }
    361 
    362 class PeersManager {
    363 
    364     constructor(serverConnection) {
    365         this.peers = {};
    366         this._server = serverConnection;
    367         Events.on('signal', e => this._onMessage(e.detail));
    368         Events.on('peers', e => this._onPeers(e.detail));
    369         Events.on('files-selected', e => this._onFilesSelected(e.detail));
    370         Events.on('send-text', e => this._onSendText(e.detail));
    371         Events.on('peer-left', e => this._onPeerLeft(e.detail));
    372     }
    373 
    374     _onMessage(message) {
    375         if (!this.peers[message.sender]) {
    376             this.peers[message.sender] = new RTCPeer(this._server);
    377         }
    378         this.peers[message.sender].onServerMessage(message);
    379     }
    380 
    381     _onPeers(peers) {
    382         peers.forEach(peer => {
    383             if (this.peers[peer.id]) {
    384                 this.peers[peer.id].refresh();
    385                 return;
    386             }
    387             if (window.isRtcSupported && peer.rtcSupported) {
    388                 this.peers[peer.id] = new RTCPeer(this._server, peer.id);
    389             } else {
    390                 this.peers[peer.id] = new WSPeer(this._server, peer.id);
    391             }
    392         })
    393     }
    394 
    395     sendTo(peerId, message) {
    396         this.peers[peerId].send(message);
    397     }
    398 
    399     _onFilesSelected(message) {
    400         this.peers[message.to].sendFiles(message.files);
    401     }
    402 
    403     _onSendText(message) {
    404         this.peers[message.to].sendText(message.text);
    405     }
    406 
    407     _onPeerLeft(peerId) {
    408         const peer = this.peers[peerId];
    409         delete this.peers[peerId];
    410         if (!peer || !peer._peer) return;
    411         peer._peer.close();
    412     }
    413 
    414 }
    415 
    416 class WSPeer {
    417     _send(message) {
    418         message.to = this._peerId;
    419         this._server.send(message);
    420     }
    421 }
    422 
    423 class FileChunker {
    424 
    425     constructor(file, onChunk, onPartitionEnd) {
    426         this._chunkSize = 64000; // 64 KB
    427         this._maxPartitionSize = 1e6; // 1 MB
    428         this._offset = 0;
    429         this._partitionSize = 0;
    430         this._file = file;
    431         this._onChunk = onChunk;
    432         this._onPartitionEnd = onPartitionEnd;
    433         this._reader = new FileReader();
    434         this._reader.addEventListener('load', e => this._onChunkRead(e.target.result));
    435     }
    436 
    437     nextPartition() {
    438         this._partitionSize = 0;
    439         this._readChunk();
    440     }
    441 
    442     _readChunk() {
    443         const chunk = this._file.slice(this._offset, this._offset + this._chunkSize);
    444         this._reader.readAsArrayBuffer(chunk);
    445     }
    446 
    447     _onChunkRead(chunk) {
    448         this._offset += chunk.byteLength;
    449         this._partitionSize += chunk.byteLength;
    450         this._onChunk(chunk);
    451         if (this._isPartitionEnd() || this.isFileEnd()) {
    452             this._onPartitionEnd(this._offset);
    453             return;
    454         }
    455         this._readChunk();
    456     }
    457 
    458     repeatPartition() {
    459         this._offset -= this._partitionSize;
    460         this._nextPartition();
    461     }
    462 
    463     _isPartitionEnd() {
    464         return this._partitionSize >= this._maxPartitionSize;
    465     }
    466 
    467     isFileEnd() {
    468         return this._offset >= this._file.size;
    469     }
    470 
    471     get progress() {
    472         return this._offset / this._file.size;
    473     }
    474 }
    475 
    476 class FileDigester {
    477 
    478     constructor(meta, callback) {
    479         this._buffer = [];
    480         this._bytesReceived = 0;
    481         this._size = meta.size;
    482         this._mime = meta.mime || 'application/octet-stream';
    483         this._name = meta.name;
    484         this._callback = callback;
    485     }
    486 
    487     unchunk(chunk) {
    488         this._buffer.push(chunk);
    489         this._bytesReceived += chunk.byteLength || chunk.size;
    490         const totalChunks = this._buffer.length;
    491         this.progress = this._bytesReceived / this._size;
    492         if (isNaN(this.progress)) this.progress = 1
    493 
    494         if (this._bytesReceived < this._size) return;
    495         // we are done
    496         let blob = new Blob(this._buffer, { type: this._mime });
    497         this._callback({
    498             name: this._name,
    499             mime: this._mime,
    500             size: this._size,
    501             blob: blob
    502         });
    503     }
    504 
    505 }
    506 
    507 class Events {
    508     static fire(type, detail) {
    509         window.dispatchEvent(new CustomEvent(type, { detail: detail }));
    510     }
    511 
    512     static on(type, callback) {
    513         return window.addEventListener(type, callback, false);
    514     }
    515 }
    516 
    517 
    518 RTCPeer.config = {
    519     'sdpSemantics': 'unified-plan',
    520     'iceServers': [{
    521         urls: 'stun:stun.l.google.com:19302'
    522     }]
    523 }