Commit 3bdd82f0 authored by Juliusz Chroboczek's avatar Juliusz Chroboczek

Rework file transfer.

Split into the protocol (in protocol.js) and the user interface
(in galene.js).  Make the state automaton explicit, and improve
error-handling.  The new protocol is incompatible with the old one.
parent 7d4133d1
......@@ -2328,96 +2328,11 @@ async function gotJoined(kind, group, perms, status, data, message) {
}
}
/** @type {Object<string,TransferredFile>} */
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<RTCIceCandidateInit>} */
this.candidates = [];
/** @type {Array<Blob|ArrayBuffer>} */
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
*/
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
* @this {TransferredFile}
* @param {string} state
* @param {any} [data]
*/
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 {
......
......@@ -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<string,TransferredFile>}
*/
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<RTCIceCandidateInit>} */
this.candidates = [];
/** @type {Array<Blob|ArrayBuffer>} */
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;
}
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment