diff --git a/static/galene.js b/static/galene.js index f087f02..00a5a70 100644 --- a/static/galene.js +++ b/static/galene.js @@ -2328,96 +2328,11 @@ async function gotJoined(kind, group, perms, status, data, message) { } } -/** @type {Object} */ -let transferredFiles = {}; - -/** - * A file in the process of being transferred. - * - * @constructor - */ -function TransferredFile(id, userid, up, username, name, type, size) { - /** @type {string} */ - this.id = id; - /** @type {string} */ - this.userid = userid; - /** @type {boolean} */ - this.up = up; - /** @type {string} */ - this.username = username; - /** @type {string} */ - this.name = name; - /** @type {string} */ - this.type = type; - /** @type {number} */ - this.size = size; - /** @type {File} */ - this.file = null; - /** @type {RTCPeerConnection} */ - this.pc = null; - /** @type {RTCDataChannel} */ - this.dc = null; - /** @type {Array} */ - this.candidates = []; - /** @type {Array} */ - this.data = []; - /** @type {number} */ - this.datalen = 0; -} - -TransferredFile.prototype.fullid = function() { - return this.userid + (this.up ? '+' : '-') + this.id; -}; - -/** - * @param {boolean} up - * @param {string} userid - * @param {string} fileid - * @returns {TransferredFile} - */ -TransferredFile.get = function(up, userid, fileid) { - return transferredFiles[userid + (up ? '+' : '-') + fileid]; -}; - -TransferredFile.prototype.close = function() { - if(this.dc) { - this.dc.onclose = null; - this.dc.onerror = null; - this.dc.onmessage = null; - } - if(this.pc) - this.pc.close(); - this.dc = null; - this.pc = null; - this.data = []; - this.datalen = 0; - delete(transferredFiles[this.fullid()]); -} - -TransferredFile.prototype.pushData = function(data) { - if(data instanceof Blob) { - this.datalen += data.size; - } else if(data instanceof ArrayBuffer) { - this.datalen += data.byteLength; - } else { - throw new Error('unexpected type for received data'); - } - this.data.push(data); -} - -TransferredFile.prototype.getData = function() { - let blob = new Blob(this.data, {type: this.type}); - if(blob.size != this.datalen) - throw new Error('Inconsistent data size'); - this.data = []; - this.datalen = 0; - return blob; -} - /** * @param {TransferredFile} f */ -function fileTransferBox(f) { +function gotFileTransfer(f) { + f.onevent = gotFileTransferEvent; let p = document.createElement('p'); if(f.up) p.textContent = @@ -2428,27 +2343,20 @@ function fileTransferBox(f) { `User ${f.username} offered to send us a file ` + `called "${f.name}" of size ${f.size}.` let bno = null, byes = null; - if(f.up) { - bno = document.createElement('button'); - bno.textContent = 'Cancel'; - bno.onclick = function(e) { - cancelFile(f); - }; - bno.id = "bno-" + f.fullid(); - } else { + if(!f.up) { byes = document.createElement('button'); byes.textContent = 'Accept'; byes.onclick = function(e) { - getFile(f); + f.receive(); }; byes.id = "byes-" + f.fullid(); - bno = document.createElement('button'); - bno.textContent = 'Decline'; - bno.onclick = function(e) { - rejectFile(f); - }; - bno.id = "bno-" + f.fullid(); } + bno = document.createElement('button'); + bno.textContent = f.up ? 'Cancel' : 'Reject'; + bno.onclick = function(e) { + f.cancel(); + }; + bno.id = "bno-" + f.fullid(); let status = document.createElement('div'); status.id = 'status-' + f.fullid(); if(!f.up) { @@ -2500,334 +2408,49 @@ function setFileStatus(f, status, delyes, delno) { } /** - * @param {TransferredFile} f - * @param {any} message + * @this {TransferredFile} + * @param {string} state + * @param {any} [data] */ -function failFile(f, message) { - if(!f.dc) - return; - console.error('File transfer failed:', message); - setFileStatus(f, message ? `Failed: ${message}` : 'Failed.'); - f.close(); -} - -/** - * @param {string} id - * @param {File} file - */ -function offerFile(id, file) { - let fileid = newRandomId(); - let username = serverConnection.users[id].username; - let f = new TransferredFile( - fileid, id, true, username, file.name, file.type, file.size, - ); - f.file = file; - transferredFiles[f.fullid()] = f; - try { - fileTransferBox(f); - serverConnection.userMessage('offerfile', id, { - id: fileid, - name: f.name, - size: f.size, - type: f.type, - }); - } catch(e) { - displayError(e); - f.close(); - } -} - -/** - * @param {TransferredFile} f - */ -function cancelFile(f) { - serverConnection.userMessage('cancelfile', f.userid, { - id: f.id, - }); - f.close(); - setFileStatus(f, 'Cancelled.', true, true); -} - -/** - * @param {TransferredFile} f - */ -async function getFile(f) { - if(f.pc) - throw new Error("Download already in progress"); - setFileStatus(f, 'Connecting...', true); - let pc = new RTCPeerConnection(serverConnection.getRTCConfiguration()); - if(!pc) - throw new Error("Couldn't create peer connection"); - f.pc = pc; - f.candidates = []; - pc.onsignalingstatechange = function(e) { - if(pc.signalingState === 'stable') { - f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn)); - f.candidates = []; - } - }; - pc.onicecandidate = function(e) { - serverConnection.userMessage('filedownice', f.userid, { - id: f.id, - candidate: e.candidate, - }); - }; - f.dc = pc.createDataChannel('file'); - f.data = []; - f.datalen = 0; - f.dc.onclose = function(e) { - try { - closeReceiveFileData(f); - } catch(e) { - failFile(f, e); - } - }; - f.dc.onmessage = function(e) { - try { - receiveFileData(f, e.data); - } catch(e) { - failFile(f, e); - } - }; - f.dc.onerror = function(e) { - /** @ts-ignore */ - let err = e.error; - failFile(f, err); - }; - let offer = await pc.createOffer(); - if(!offer) - throw new Error("Couldn't create offer"); - await pc.setLocalDescription(offer); - serverConnection.userMessage('getfile', f.userid, { - id: f.id, - offer: pc.localDescription.sdp, - }); - setFileStatus(f, 'Negotiating...', true); -} - -/** - * @param {TransferredFile} f - */ -async function rejectFile(f) { - serverConnection.userMessage('rejectfile', f.userid, { - id: f.id, - }); - setFileStatus(f, 'Rejected.', true, true); - f.close(); -} - -/** - * @param {TransferredFile} f - * @param {string} sdp - */ -async function sendOfferedFile(f, sdp) { - if(f.pc) - throw new Error('Transfer already in progress'); - - setFileStatus(f, 'Negotiating...', true); - let pc = new RTCPeerConnection(serverConnection.getRTCConfiguration()); - if(!pc) - throw new Error("Couldn't create peer connection"); - f.pc = pc; - f.candidates = []; - pc.onicecandidate = function(e) { - serverConnection.userMessage('fileupice', f.userid, { - id: f.id, - candidate: e.candidate, - }); - }; - pc.onsignalingstatechange = function(e) { - if(pc.signalingState === 'stable') { - f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn)); - f.candidates = []; - } - }; - pc.ondatachannel = function(e) { - if(f.dc) - throw new Error('Duplicate datachannel'); - f.dc = /** @type{RTCDataChannel} */(e.channel); - f.dc.onclose = function(e) { - try { - closeSendFileData(f); - } catch(e) { - failFile(f, e); - } - }; - f.dc.onerror = function(e) { - /** @ts-ignore */ - let err = e.error; - failFile(f, err); - } - f.dc.onmessage = function(e) { - try { - ackSendFileData(f, e.data); - } catch(e) { - failFile(f, e); - } - }; - sendFileData(f).catch(e => failFile(f, e)); - }; - - await pc.setRemoteDescription({ - type: 'offer', - sdp: sdp, - }); - - let answer = await pc.createAnswer(); - if(!answer) - throw new Error("Couldn't create answer"); - await pc.setLocalDescription(answer); - serverConnection.userMessage('sendfile', f.userid, { - id: f.id, - answer: pc.localDescription.sdp, - }); - setFileStatus(f, 'Uploading...', true); -} - -/** - * @param {TransferredFile} f - * @param {string} sdp - */ -async function receiveFile(f, sdp) { - await f.pc.setRemoteDescription({ - type: 'answer', - sdp: sdp, - }); - setFileStatus(f, 'Downloading...', true); -} - -/** - * @param {TransferredFile} f - */ -async function sendFileData(f) { - let r = f.file.stream().getReader(); - - f.dc.bufferedAmountLowThreshold = 65536; - - async function write(a) { - while(f.dc.bufferedAmount > f.dc.bufferedAmountLowThreshold) { - await new Promise((resolve, reject) => { - if(!f.dc) { - reject(new Error('File is closed.')); - return; - } - f.dc.onbufferedamountlow = function(e) { - if(!f.dc) { - reject(new Error('File is closed.')); - return; - } - f.dc.onbufferedamountlow = null; - resolve(); - } - }); - } - f.dc.send(a); - f.datalen += a.length; - setFileStatus(f, `Uploading... ${f.datalen}/${f.size}`, true); - } - - while(true) { - let v = await r.read(); - if(v.done) - break; - if(!(v.value instanceof Uint8Array)) - throw new Error('Unexpected type for chunk'); - if(v.value.length <= 16384) { - await write(v.value); - } else { - for(let i = 0; i < v.value.length; i += 16384) { - let a = new Uint8Array( - v.value.buffer, i, Math.min(16384, v.value.length - i), - ); - await write(a); - } - } - } -} - -/** - * @param {TransferredFile} f - */ -function ackSendFileData(f, data) { - if(data === 'done' && f.datalen == f.size) +function gotFileTransferEvent(state, data) { + let f = this; + switch(state) { + case 'inviting': + break; + case 'connecting': + setFileStatus(f, 'Connecting...', true); + break; + case 'connected': + if(f.up) + setFileStatus(f, `Sending... ${f.datalen}/${f.size}`); + else + setFileStatus(f, `Receiving... ${f.datalen}/${f.size}`); + break; + case 'done': setFileStatus(f, 'Done.', true, true); - else - setFileStatus(f, 'Failed.', true, true); - f.dc.onclose = null; - f.dc.onerror = null; - f.close(); -} - -/** - * @param {TransferredFile} f - */ -function closeSendFileData(f) { - setFileStatus(f, 'Failed.', true, true); - f.close(); -} - -/** - * @param {TransferredFile} f - * @param {Blob|ArrayBuffer} data - */ -function receiveFileData(f, data) { - f.pushData(data); - setFileStatus(f, `Downloading... ${f.datalen}/${f.size}`, true); - - if(f.datalen < f.size) - return; - - if(f.datalen != f.size) { - setFileStatus(f, 'Failed.', true, true); - f.close(); - return; - } - - f.dc.onmessage = null; - doneReceiveFileData(f); -} - -/** - * @param {TransferredFile} f - */ -async function doneReceiveFileData(f) { - setFileStatus(f, 'Done.', true, true); - let blob = f.getData(); - - await new Promise((resolve, reject) => { - let timer = setTimeout(function(e) { resolve(); }, 2000); - f.dc.onclose = function(e) { - clearTimeout(timer); - resolve(); - }; - f.dc.onerror = function(e) { - clearTimeout(timer); - resolve(); - }; - f.dc.send('done'); - }); - - f.dc.onclose = null; - f.dc.onerror = null; - f.close(); - - let url = URL.createObjectURL(blob); - let a = document.createElement('a'); - a.href = url; - a.textContent = f.name; - a.download = f.name; - a.type = f.type; - a.click(); - URL.revokeObjectURL(url); -} - -/** - * @param {TransferredFile} f - */ -function closeReceiveFileData(f) { - if(f.datalen !== f.size) { - setFileStatus(f, 'Failed.', true, true) - f.close(); + if(!f.up) { + let url = URL.createObjectURL(data); + let a = document.createElement('a'); + a.href = url; + a.textContent = f.name; + a.download = f.name; + a.type = f.mimetype; + a.click(); + URL.revokeObjectURL(url); + } + break; + case 'cancelled': + if(data) + setFileStatus(f, `Cancelled: ${data.toString()}.`, true, true); + else + setFileStatus(f, 'Cancelled.', true, true); + break; + case 'closed': + break; + default: + console.error(`Unexpected state "${state}"`); + f.cancel(`unexpected state "${state}" (this shouldn't happen)`); + break; } } @@ -2868,70 +2491,6 @@ function gotUserMessage(id, dest, username, time, privileged, kind, message) { console.error(`Got unprivileged message of kind ${kind}`); } break; - case 'offerfile': { - let f = new TransferredFile( - message.id, id, false, username, - message.name, message.type, message.size, - ); - transferredFiles[f.fullid()] = f; - fileTransferBox(f); - break; - } - case 'cancelfile': { - let f = TransferredFile.get(false, id, message.id); - if(!f) - throw new Error('unexpected cancelfile'); - setFileStatus(f, 'Cancelled.', true, true); - f.close(); - break; - } - case 'getfile': { - let f = TransferredFile.get(true, id, message.id); - if(!f) - throw new Error('unexpected getfile'); - sendOfferedFile(f, message.offer); - break; - } - case 'rejectfile': { - let f = TransferredFile.get(true, id, message.id); - if(!f) - throw new Error('unexpected rejectfile'); - setFileStatus(f, 'Rejected.', true, true); - f.close(); - break; - } - case 'sendfile': { - let f = TransferredFile.get(false, id, message.id); - if(!f) - throw new Error('unexpected sendfile'); - receiveFile(f, message.answer); - break; - } - case 'filedownice': { - let f = TransferredFile.get(true, id, message.id); - if(!f.pc) { - console.warn('Unexpected filedownice'); - return; - } - if(f.pc.signalingState === 'stable') - f.pc.addIceCandidate(message.candidate).catch(console.warn); - else - f.candidates.push(message.candidate); - break; - } - case 'fileupice': { - let f = TransferredFile.get(false, id, message.id); - if(!f.pc) { - console.warn('Unexpected fileupice'); - return; - } - if(f.pc.signalingState === 'stable') - f.pc.addIceCandidate(message.candidate).catch(console.warn); - else - f.candidates.push(message.candidate); - break; - - } default: console.warn(`Got unknown user message ${kind}`); break; @@ -3515,7 +3074,7 @@ function sendFile(id) { let files = this.files; for(let i = 0; i < files.length; i++) { try { - offerFile(id, files[i]); + serverConnection.sendFile(id, files[i]); } catch(e) { console.error(e); displayError(e); @@ -3870,6 +3429,7 @@ async function serverConnect() { serverConnection.onjoined = gotJoined; serverConnection.onchat = addToChatbox; serverConnection.onusermessage = gotUserMessage; + serverConnection.onfiletransfer = gotFileTransfer; let url = `ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`; try { diff --git a/static/protocol.js b/static/protocol.js index d1cce9f..0d43f7f 100644 --- a/static/protocol.js +++ b/static/protocol.js @@ -202,6 +202,14 @@ function ServerConnection() { * @type {(this: ServerConnection, id: string, dest: string, username: string, time: number, privileged: boolean, kind: string, message: unknown) => void} */ this.onusermessage = null; + /** + * @type {Object} + */ + this.transferredFiles = {}; + /** + * @type {(this: ServerConnection, f: TransferredFile) => void} + */ + this.onfiletransfer = null; } /** @@ -380,6 +388,11 @@ ServerConnection.prototype.connect = async function(url) { case 'delete': if(!(m.id in sc.users)) console.warn(`Unknown user ${m.id} ${m.username}`); + for(let t in sc.transferredFiles) { + let f = sc.transferredFiles[t]; + if(f.userid === m.id) + f.fail('user has gone away'); + } delete(sc.users[m.id]); break; default: @@ -398,7 +411,9 @@ ServerConnection.prototype.connect = async function(url) { ); break; case 'usermessage': - if(sc.onusermessage) + if(m.kind === 'filetransfer') + sc.fileTransfer(m.source, m.username, m.value); + else if(sc.onusermessage) sc.onusermessage.call( sc, m.source, m.dest, m.username, m.time, m.privileged, m.kind, m.value, @@ -1419,3 +1434,581 @@ Stream.prototype.setStatsInterval = function(ms) { c.updateStats(); }, ms); }; + + +/** + * A file in the process of being transferred. + * These are stored in the ServerConnection.transferredFiles dictionary. + * + * @parm {ServerConnection} sc + * @parm {string} userid + * @parm {string} rid + * @parm {boolean} up + * @parm {string} username + * @parm {string} mimetype + * @parm {number} size + * @constructor + * + * State transitions: + * + * '' -> inviting -> connecting -> connected -> done -> closed + * any -> cancelled -> closed + * + */ +function TransferredFile(sc, userid, id, up, username, name, mimetype, size) { + /** @type {ServerConnection} */ + this.sc = sc; + /** @type {string} */ + this.userid = userid; + /** @type {string} */ + this.id = id; + /** @type {boolean} */ + this.up = up; + /** @type {string} */ + this.state = ''; + /** @type {string} */ + this.username = username; + /** @type {string} */ + this.name = name; + /** @type {string} */ + this.mimetype = mimetype; + /** @type {number} */ + this.size = size; + /** @type {File} */ + this.file = null; + /** @type {boolean} */ + this.closed = false; + /** @type {RTCPeerConnection} */ + this.pc = null; + /** @type {RTCDataChannel} */ + this.dc = null; + /** @type {Array} */ + this.candidates = []; + /** @type {Array} */ + this.data = []; + /** @type {number} */ + this.datalen = 0; + /** @type {(this: TransferredFile, type: string, [data]: string) => void} */ + this.onevent = null; +} + +/** + * The full id of this file transfer, used as a key in the transferredFiles + * dictionary. + */ +TransferredFile.prototype.fullid = function() { + return this.userid + (this.up ? '+' : '-') + this.id; +}; + +/** + * Retrieve a transferred file from the transferredFiles dictionary. + * + * @param {string} userid + * @param {string} fileid + * @param {boolean} up + * @returns {TransferredFile} + */ +ServerConnection.prototype.getTransferredFile = function(userid, fileid, up) { + return this.transferredFiles[userid + (up ? '+' : '-') + fileid]; +}; + +/** + * Close a file transfer and remove it from the transferredFiles dictionary. + * Do not call this, call 'cancel' instead. + */ +TransferredFile.prototype.close = function() { + let f = this; + if(f.state === 'closed') + return; + if(f.state !== 'done' && f.state !== 'cancelled') + console.warn( + `TransferredFile.close called in unexpected state ${f.state}`, + ); + if(f.dc) { + f.dc.onclose = null; + f.dc.onerror = null; + f.dc.onmessage = null; + } + if(f.pc) + f.pc.close(); + f.dc = null; + f.pc = null; + f.data = []; + f.datalen = 0; + delete(f.sc.transferredFiles[f.fullid()]); + f.event('closed'); +} + +/** + * Buffer a chunk of data received during a file transfer. Do not call this. + * + * @param {Blob|ArrayBuffer} data + */ +TransferredFile.prototype.bufferData = function(data) { + let f = this; + if(f.up) + throw new Error('buffering data in the wrong direction'); + if(data instanceof Blob) { + f.datalen += data.size; + } else if(data instanceof ArrayBuffer) { + f.datalen += data.byteLength; + } else { + throw new Error('unexpected type for received data'); + } + f.data.push(data); +} + +/** + * Retreive the data buffered during a file transfer. Don't call this. + * + * @returns {Blob} + */ +TransferredFile.prototype.getBufferedData = function() { + let f = this; + if(f.up) + throw new Error('buffering data in wrong direction'); + let blob = new Blob(f.data, {type: f.mimetype}); + if(blob.size != f.datalen) + throw new Error('Inconsistent data size'); + f.data = []; + f.datalen = 0; + return blob; +} + +/** + * Set the file's state, and call the onevent callback. + * + * This calls the callback even if the state didn't change, which is + * useful if the client needs to display a progress bar. + * + * @param {string} state + * @param {any} [data] + */ +TransferredFile.prototype.event = function(state, data) { + let f = this; + f.state = state; + if(f.onevent) + f.onevent.call(f, state, data); +} + + +/** + * Cancel a file transfer. + * + * Depending on the state, this will either forcibly close the connection, + * send a handshake, or do nothing. It will set the state to cancelled. + * + * @param {string|Error} [data] + */ +TransferredFile.prototype.cancel = function(data) { + let f = this; + if(f.state === 'closed') + return; + if(f.state !== '' && f.state !== 'done' && f.state !== 'cancelled') { + let m = { + type: f.up ? 'cancel' : 'reject', + id: f.id, + }; + if(data) + m.message = data.toString(); + f.sc.userMessage('filetransfer', f.userid, m); + } + if(f.state !== 'done' && f.state !== 'cancelled') + f.event('cancelled', data); + f.close(); +} + +/** + * Forcibly terminate a file transfer. + * + * This is like cancel, but will not attempt to handshake. + * Use cancel instead of this, unless you know what you are doing. + * + * @param {string|Error} [data] + */ +TransferredFile.prototype.fail = function(data) { + let f = this; + if(f.state === 'done' || f.state === 'cancelled' || f.state === 'closed') + return; + f.event('cancelled', data); + f.close(); +} + +/** + * Initiate a file upload. + * + * This will cause the onfiletransfer callback to be called, at which + * point you should set up the onevent callback. + * + * @param {string} id + * @param {File} file + */ +ServerConnection.prototype.sendFile = function(id, file) { + let sc = this; + let fileid = newRandomId(); + let user = sc.users[id]; + if(!user) + throw new Error('offering upload to unknown user'); + let f = new TransferredFile( + sc, id, fileid, true, user.username, file.name, file.type, file.size, + ); + f.file = file; + + try { + if(sc.onfiletransfer) + sc.onfiletransfer.call(sc, f); + else + throw new Error('this client does not implement file transfer'); + } catch(e) { + f.cancel(e); + return; + } + + sc.transferredFiles[f.fullid()] = f; + sc.userMessage('filetransfer', id, { + type: 'invite', + id: fileid, + name: f.name, + size: f.size, + mimetype: f.mimetype, + }); + f.event('inviting'); +} + +/** + * Receive a file. + * + * Call this after the onfiletransfer callback has yielded an incoming + * file (up field set to false). If you wish to reject the file transfer, + * call cancel instead. + */ +TransferredFile.prototype.receive = async function() { + let f = this; + if(f.up) + throw new Error('Receiving in wrong direction'); + if(f.pc) + throw new Error('Download already in progress'); + let pc = new RTCPeerConnection(serverConnection.getRTCConfiguration()); + if(!pc) { + let err = new Error("Couldn't create peer connection"); + f.fail(err); + return; + } + f.pc = pc; + f.event('connecting'); + + f.candidates = []; + pc.onsignalingstatechange = function(e) { + if(pc.signalingState === 'stable') { + f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn)); + f.candidates = []; + } + }; + pc.onicecandidate = function(e) { + serverConnection.userMessage('filetransfer', f.userid, { + type: 'downice', + id: f.id, + candidate: e.candidate, + }); + }; + f.dc = pc.createDataChannel('file'); + f.data = []; + f.datalen = 0; + f.dc.onclose = function(e) { + f.cancel('remote peer closed connection'); + }; + f.dc.onmessage = function(e) { + f.receiveData(e.data).catch(e => f.cancel(e)); + }; + f.dc.onerror = function(e) { + /** @ts-ignore */ + let err = e.error; + f.cancel(err) + }; + let offer = await pc.createOffer(); + if(!offer) { + f.cancel(new Error("Couldn't create offer")); + return; + } + await pc.setLocalDescription(offer); + f.sc.userMessage('filetransfer', f.userid, { + type: 'offer', + id: f.id, + sdp: pc.localDescription.sdp, + }); +} + +/** + * Negotiate a file transfer on the sender side. Don't call this. + * + * @param {string} sdp + */ +TransferredFile.prototype.answer = async function(sdp) { + let f = this; + if(!f.up) + throw new Error('Sending file in wrong direction'); + if(f.pc) + throw new Error('Transfer already in progress'); + let pc = new RTCPeerConnection(serverConnection.getRTCConfiguration()); + if(!pc) { + let err = new Error("Couldn't create peer connection"); + f.fail(err); + return; + } + f.pc = pc; + f.event('connecting'); + + f.candidates = []; + pc.onicecandidate = function(e) { + serverConnection.userMessage('filetransfer', f.userid, { + type: 'upice', + id: f.id, + candidate: e.candidate, + }); + }; + pc.onsignalingstatechange = function(e) { + if(pc.signalingState === 'stable') { + f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn)); + f.candidates = []; + } + }; + pc.ondatachannel = function(e) { + if(f.dc) { + f.cancel(new Error('Duplicate datachannel')); + return; + } + f.dc = /** @type{RTCDataChannel} */(e.channel); + f.dc.onclose = function(e) { + f.cancel('remote peer closed connection'); + }; + f.dc.onerror = function(e) { + /** @ts-ignore */ + let err = e.error; + f.cancel(err); + } + f.dc.onmessage = function(e) { + if(e.data === 'done' && f.datalen === f.size) { + f.event('done'); + f.dc.onclose = null; + f.dc.onerror = null; + f.close(); + } else { + f.cancel(new Error('unexpected data from receiver')); + } + } + f.send().catch(e => f.cancel(e)); + }; + + await pc.setRemoteDescription({ + type: 'offer', + sdp: sdp, + }); + + let answer = await pc.createAnswer(); + if(!answer) + throw new Error("Couldn't create answer"); + await pc.setLocalDescription(answer); + serverConnection.userMessage('filetransfer', f.userid, { + type: 'answer', + id: f.id, + sdp: pc.localDescription.sdp, + }); + + f.event('connected'); +} + +/** + * Transfer file data. Don't call this, it is called automatically + * after negotiation completes. + */ +TransferredFile.prototype.send = async function() { + let f = this; + if(!f.up) + throw new Error('sending in wrong direction'); + let r = f.file.stream().getReader(); + + f.dc.bufferedAmountLowThreshold = 65536; + + async function write(a) { + while(f.dc.bufferedAmount > f.dc.bufferedAmountLowThreshold) { + await new Promise((resolve, reject) => { + if(!f.dc) { + reject(new Error('File is closed.')); + return; + } + f.dc.onbufferedamountlow = function(e) { + if(!f.dc) { + reject(new Error('File is closed.')); + return; + } + f.dc.onbufferedamountlow = null; + resolve(); + } + }); + } + f.dc.send(a); + f.datalen += a.length; + // we're already in the connected state, but invoke callbacks to + // that the application can display progress + f.event('connected'); + } + + while(true) { + let v = await r.read(); + if(v.done) + break; + let data = v.value; + if(!(data instanceof Uint8Array)) + throw new Error('Unexpected type for chunk'); + /* Base SCTP only supports up to 16kB data chunks. There are + extensions to handle larger chunks, but they don't interoperate + between browsers, so we chop the file into small pieces. */ + if(data.length <= 16384) { + await write(data); + } else { + for(let i = 0; i < v.value.length; i += 16384) { + let d = new Uint8Array( + data.buffer, i, Math.min(16384, data.length - i), + ); + await write(d); + } + } + } +} + +/** + * @param {string} sdp + */ +TransferredFile.prototype.receiveFile = async function(sdp) { + let f = this; + if(f.up) + throw new Error('Receiving in wrong direction'); + await f.pc.setRemoteDescription({ + type: 'answer', + sdp: sdp, + }); + f.event('connected'); +} + +/** + * @param {Blob|ArrayBuffer} data + */ +TransferredFile.prototype.receiveData = async function(data) { + let f = this; + if(f.up) + throw new Error('Receiving in wrong direction'); + f.bufferData(data); + + if(f.datalen < f.size) { + f.event('connected'); + return; + } + + f.dc.onmessage = null; + + if(f.datalen != f.size) { + f.cancel('unexpected file size'); + return; + } + + let blob = f.getBufferedData(); + f.event('done', blob); + + await new Promise((resolve, reject) => { + let timer = setTimeout(function(e) { resolve(); }, 2000); + f.dc.onclose = function(e) { + clearTimeout(timer); + resolve(); + }; + f.dc.onerror = function(e) { + clearTimeout(timer); + resolve(); + }; + f.dc.send('done'); + }); + + f.close(); +} + +/** + * @param {string} id + * @param {string} username + * @param {object} message + */ +ServerConnection.prototype.fileTransfer = function(id, username, message) { + let sc = this; + switch(message.type) { + case 'invite': { + let f = new TransferredFile( + sc, id, message.id, false, username, + message.name, message.mimetype, message.size, + ); + f.state = 'inviting'; + + try { + if(sc.onfiletransfer) + sc.onfiletransfer.call(sc, f); + else + f.cancel('this client does not implement file transfer'); + } catch(e) { + f.cancel(e); + return; + } + + if(f.fullid() in sc.transferredFiles) { + console.error('Duplicate id for file transfer'); + f.cancel("duplicate id (this shouldn't happen)"); + return; + } + sc.transferredFiles[f.fullid()] = f; + break; + } + case 'offer': { + let f = sc.getTransferredFile(id, message.id, true); + if(!f) { + console.error('Unexpected offer for file transfer'); + return; + } + f.answer(message.sdp).catch(e => f.cancel(e)); + break; + } + case 'answer': { + let f = sc.getTransferredFile(id, message.id, false); + if(!f) { + console.error('Unexpected answer for file transfer'); + return; + } + f.receiveFile(message.sdp).catch(e => f.cancel(e)); + break; + } + case 'downice': + case 'upice': { + let f = sc.getTransferredFile( + id, message.id, message.type === 'downice', + ); + if(!f || !f.pc) { + console.warn(`Unexpected ${message.type} for file transfer`); + return; + } + if(f.pc.signalingState === 'stable') + f.pc.addIceCandidate(message.candidate).catch(console.warn); + else + f.candidates.push(message.candidate); + break; + } + case 'cancel': + case 'reject': { + let f = sc.getTransferredFile(id, message.id, message.type === 'reject'); + if(!f) { + console.error(`Unexpected ${message.type} for file transfer`); + return; + } + f.event('cancelled'); + f.close(); + break; + } + default: + console.error(`Unknown filetransfer message ${message.type}`); + break; + } +}