From f22abca7832fec2df2b0ece3ec86440b772970d4 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Sun, 4 Feb 2024 18:02:10 +0100 Subject: [PATCH 01/53] Implement new status 'connecting', automatic reconnect on disconnect and auto resume of transfer + sending of queued messages. (fixes #260 and #247) --- public/lang/en.json | 4 +- public/scripts/network.js | 523 ++++++++++++++++++++++++++------------ public/scripts/ui.js | 378 +++++++++++++++------------ 3 files changed, 589 insertions(+), 316 deletions(-) diff --git a/public/lang/en.json b/public/lang/en.json index 54a05c6..f8698be 100644 --- a/public/lang/en.json +++ b/public/lang/en.json @@ -176,9 +176,11 @@ "click-to-send-share-mode": "Click to send {{descriptor}}", "click-to-send": "Click to send files or right click to send a message", "connection-hash": "To verify the security of the end-to-end encryption, compare this security number on both devices", + "connecting": "Connecting…", "preparing": "Preparing…", "waiting": "Waiting…", "processing": "Processing…", - "transferring": "Transferring…" + "transferring": "Transferring…", + "receiving": "Receiving…" } } diff --git a/public/scripts/network.js b/public/scripts/network.js index 9128442..eb705ab 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -88,7 +88,9 @@ class ServerConnection { _onOpen() { console.log('WS: server connected'); Events.fire('ws-connected'); - if (this._isReconnect) Events.fire('notify-user', Localization.getTranslation("notifications.connected")); + if (this._isReconnect) { + Events.fire('notify-user', Localization.getTranslation("notifications.connected")); + } } _onPairDeviceInitiate() { @@ -101,6 +103,7 @@ class ServerConnection { _onPairDeviceJoin(pairKey) { if (!this._isConnected()) { + // Todo: instead use pending outbound ws queue setTimeout(() => this._onPairDeviceJoin(pairKey), 1000); return; } @@ -336,6 +339,10 @@ class Peer { this._evaluateAutoAccept(); } + _setIsCaller(isCaller) { + this._isCaller = isCaller; + } + sendJSON(message) { this._send(JSON.stringify(message)); } @@ -433,6 +440,14 @@ class Peer { : false; } + _onPeerConnected() { + if (this._digester) { + // Reconnection during receiving of file. Send request for restart + const offset = this._digester._bytesReceived; + this._requestResendFromOffset(offset); + } + } + async requestFileTransfer(files) { let header = []; let totalSize = 0; @@ -472,8 +487,8 @@ class Peer { Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'wait'}) } - async sendFiles() { - for (let i=0; i 1) { this._abortTransfer(); + return; } - this._onDownloadProgress(progress); + if (progress === 1) { + this._digester = null; + } + + Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'receive'}); // occasionally notify sender about our progress - if (progress - this._lastProgress < 0.005 && progress !== 1) return; - this._lastProgress = progress; - this._sendProgress(progress); + if (progress - this._lastProgress >= 0.005 || progress === 1) { + this._lastProgress = progress; + this._sendProgress(progress); + } } - _onDownloadProgress(progress) { + _onProgress(progress) { Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'transfer'}); } @@ -654,25 +696,33 @@ class Peer { Events.fire('file-received', fileBlob); this._filesReceived.push(fileBlob); - if (!this._requestAccepted.header.length) { - this._busy = false; - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'process'}); - Events.fire('files-received', {peerId: this._peerId, files: this._filesReceived, imagesOnly: this._requestAccepted.imagesOnly, totalSize: this._requestAccepted.totalSize}); - this._filesReceived = []; - this._requestAccepted = null; - } + + if (this._requestAccepted.header.length) return; + + // We are done receiving + this._busy = false; + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'process'}); + Events.fire('files-received', { + peerId: this._peerId, + files: this._filesReceived, + imagesOnly: this._requestAccepted.imagesOnly, + totalSize: this._requestAccepted.totalSize + }); + this._filesReceived = []; + this._requestAccepted = null; } _onFileTransferCompleted() { this._chunker = null; - if (!this._filesQueue.length) { - this._busy = false; - Events.fire('notify-user', Localization.getTranslation("notifications.file-transfer-completed")); - Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app - } - else { + if (this._filesQueue.length) { this._dequeueFile(); + return; } + + // No more files in queue. Transfer is complete + this._busy = false; + Events.fire('notify-user', Localization.getTranslation("notifications.file-transfer-completed")); + Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app } _onFileTransferRequestResponded(message) { @@ -725,99 +775,293 @@ class RTCPeer extends Peer { super(serverConnection, isCaller, peerId, roomType, roomId); this.rtcSupported = true; - this.rtcConfig = rtcConfig + this.rtcConfig = rtcConfig; + + this.pendingInboundMessages = []; + this.pendingOutboundMessages = []; + + Events.on('beforeunload', e => this._onBeforeUnload(e)); + Events.on('pagehide', _ => this._onPageHide()); - if (!this._isCaller) return; // we will listen for a caller this._connect(); } - _connect() { - if (!this._conn || this._conn.signalingState === "closed") this._openConnection(); + _isConnected() { + return this._conn && this._conn.connectionState === 'connected'; + } - if (this._isCaller) { - this._openChannel(); - } - else { - this._conn.ondatachannel = e => this._onChannelOpened(e); - } + _isConnecting() { + return this._conn + && ( + this._conn.connectionState === 'new' + || this._conn.connectionState === 'connecting' + ); + } + + _isChannelOpen() { + return this._channel && this._channel.readyState === 'open'; + } + + _isChannelConnecting() { + return this._channel && this._channel.readyState === 'connecting'; + } + + _isStable() { + return this._isChannelOpen() && this._isConnected(); + } + + _connect() { + if (this._isStable()) return; + + Events.fire('peer-connecting', this._peerId); + + this._openConnection(); + // TOdo: one channel for messages - one for data? + this._openChannel(); } _openConnection() { - this._conn = new RTCPeerConnection(this.rtcConfig); - this._conn.onicecandidate = e => this._onIceCandidate(e); - this._conn.onicecandidateerror = e => this._onError(e); - this._conn.onconnectionstatechange = _ => this._onConnectionStateChange(); - this._conn.oniceconnectionstatechange = e => this._onIceConnectionStateChange(e); + const conn = new RTCPeerConnection(this.rtcConfig); + conn.onnegotiationneeded = _ => this._onNegotiationNeeded(); + conn.onsignalingstatechange = _ => this._onSignalingStateChanged(); + conn.oniceconnectionstatechange = _ => this._onIceConnectionStateChange(); + conn.onicegatheringstatechange = _ => this._onIceGatheringStateChanged(); + conn.onconnectionstatechange = _ => this._onConnectionStateChange(); + conn.onicecandidate = e => this._onIceCandidate(e); + conn.onicecandidateerror = e => this._onIceCandidateError(e); + + this._conn = conn; + + this._evaluatePendingInboundMessages() + .then((count) => { + if (count) { + console.log("Pending inbound messages evaluated."); + } + }); } - _openChannel() { - if (!this._conn) return; + async _onNegotiationNeeded() { + console.log('RTC: Negotiation needed'); - const channel = this._conn.createDataChannel('data-channel', { - ordered: true, - reliable: true // Obsolete. See https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/reliable - }); - channel.onopen = e => this._onChannelOpened(e); - channel.onerror = e => this._onError(e); - - this._conn - .createOffer() - .then(d => this._onDescription(d)) - .catch(e => this._onError(e)); + if (this._isCaller) { + // Creating offer if required + console.log('RTC: Creating offer'); + const description = await this._conn.createOffer(); + await this._handleLocalDescription(description); + } } - _onDescription(description) { - // description.sdp = description.sdp.replace('b=AS:30', 'b=AS:1638400'); - this._conn - .setLocalDescription(description) - .then(_ => this._sendSignal({ sdp: description })) - .catch(e => this._onError(e)); + _onSignalingStateChanged() { + console.log('RTC: Signaling state changed:', this._conn.signalingState); + } + + _onIceConnectionStateChange() { + console.log('RTC: ICE connection state changed:', this._conn.iceConnectionState); + } + + _onIceGatheringStateChanged() { + console.log('RTC: ICE gathering state changed:', this._conn.iceConnectionState); + } + + _onConnectionStateChange() { + console.log('RTC: Connection state changed:', this._conn.connectionState); + switch (this._conn.connectionState) { + case 'disconnected': + this._refresh(); + break; + case 'failed': + console.warn('RTC connection failed'); + // TOdo: implement ws fallback as real fallback + this._refresh(); + } } _onIceCandidate(event) { - if (!event.candidate) return; - this._sendSignal({ ice: event.candidate }); + this._handleLocalCandidate(event.candidate); } - onServerMessage(message) { - if (!this._conn) this._connect(); - - if (message.sdp) { - this._conn - .setRemoteDescription(message.sdp) - .then(_ => { - if (message.sdp.type === 'offer') { - return this._conn - .createAnswer() - .then(d => this._onDescription(d)); - } - }) - .catch(e => this._onError(e)); - } - else if (message.ice) { - this._conn - .addIceCandidate(new RTCIceCandidate(message.ice)) - .catch(e => this._onError(e)); - } + _onIceCandidateError(error) { + console.error(error); } - _onChannelOpened(event) { - console.log('RTC: channel opened with', this._peerId); - const channel = event.channel || event.target; + _openChannel() { + const channel = this._conn.createDataChannel('data-channel', { + ordered: true, + negotiated: true, + id: 0 + }); channel.binaryType = 'arraybuffer'; - channel.onmessage = e => this._onMessage(e.data); + channel.onopen = _ => this._onChannelOpened(); channel.onclose = _ => this._onChannelClosed(); + channel.onerror = e => this._onChannelError(e); + channel.onmessage = e => this._onMessage(e.data); + this._channel = channel; - Events.on('beforeunload', e => this._onBeforeUnload(e)); - Events.on('pagehide', _ => this._onPageHide()); - Events.fire('peer-connected', {peerId: this._peerId, connectionHash: this.getConnectionHash()}); } - _onMessage(message) { - if (typeof message === 'string') { - console.log('RTC:', JSON.parse(message)); + _onChannelOpened() { + console.log('RTC: Channel opened with', this._peerId); + console.debug(this.getConnectionHash()) + console.debug(this._conn) + console.debug(this._channel) + Events.fire('peer-connected', {peerId: this._peerId, connectionHash: this.getConnectionHash()}); + super._onPeerConnected(); + while (this._isChannelOpen() && this.pendingOutboundMessages.length > 0) { + this._sendViaChannel(this.pendingOutboundMessages.shift()); } - super._onMessage(message); + } + + _onChannelClosed() { + console.log('RTC: Channel closed', this._peerId); + this._refresh(); + } + + _onChannelError(error) { + console.error(error); + } + + + async _handleLocalDescription(localDescription) { + await this._conn.setLocalDescription(localDescription); + + console.log("RTC: Sending local description"); + this._sendSignal({ signalType: 'description', description: localDescription }); + } + + async _handleRemoteDescription(remoteDescription) { + console.log("RTC: Received remote description"); + await this._conn.setRemoteDescription(remoteDescription); + + if (!this._isCaller) { + // Creating answer if required + console.log('RTC: Creating answer'); + const localDescription = await this._conn.createAnswer(); + await this._handleLocalDescription(localDescription); + } + } + + _handleLocalCandidate(candidate) { + console.log("RTC: Sending local candidate"); + this._sendSignal({ signalType: 'candidate', candidate: candidate }); + + if (candidate === null) { + this.localIceCandidatesSent = true; + } + } + + async _handleRemoteCandidate(candidate) { + console.log("RTC: Received remote candidate"); + if (candidate !== null) { + await this._conn.addIceCandidate(candidate); + } + else { + this.remoteIceCandidatesReceived = true; + } + } + + async _evaluatePendingInboundMessages() { + let inboundMessagesEvaluatedCount = 0; + while (this.pendingInboundMessages.length > 0) { + const message = this.pendingInboundMessages.shift(); + console.log("Evaluate pending inbound message:", message); + await this.onServerMessage(message); + inboundMessagesEvaluatedCount++; + } + return inboundMessagesEvaluatedCount; + } + + async onServerMessage(message) { + if (this._conn === null) { + console.debug("Not ready yet. Pending needed indeed?") + this.pendingInboundMessages.push(message); + return; + } + + switch (message.signalType) { + case 'description': + await this._handleRemoteDescription(message.description); + break; + case 'candidate': + await this._handleRemoteCandidate(message.candidate); + break; + default: + console.warn(this.name, 'Unknown message type:', message.type); + break; + } + } + + _disconnect() { + Events.fire('peer-disconnected', this._peerId); + } + + _refresh() { + Events.fire('peer-connecting', this._peerId); + this._closeChannelAndConnection(); + + this._connect(); // reopen the channel + } + + _closeChannelAndConnection() { + if (this._channel) { + this._channel.onopen = null; + this._channel.onclose = null; + this._channel.onerror = null; + this._channel.onmessage = null; + this._channel.close(); + this._channel = null; + } + if (this._conn) { + this._conn.onnegotiationneeded = null; + this._conn.onsignalingstatechange = null; + this._conn.oniceconnectionstatechange = null; + this._conn.onicegatheringstatechange = null; + this._conn.onconnectionstatechange = null; + this._conn.onicecandidate = null; + this._conn.onicecandidateerror = null; + this._conn.close(); + this._conn = null; + } + this.localIceCandidatesSent = false; + this.remoteIceCandidatesReceived = false; + } + + _onBeforeUnload(e) { + if (this._busy) { + e.preventDefault(); + return Localization.getTranslation("notifications.unfinished-transfers-warning"); + } + } + + _onPageHide() { + this._disconnect(); + } + + _send(message) { + // Todo: if channel or connection is closed or disconnected: do not send + // put messages in queue and send after reconnection. + // this._pendingMessages[]; + if (!this._isStable() || this.pendingOutboundMessages.length > 0) { + // queue messages if not connected OR if connected AND queue is not empty + this.pendingOutboundMessages.push(message); + return; + } + this._sendViaChannel(message); + } + + _sendViaChannel(message) { + this._channel.send(message); + } + + _sendSignal(message) { + message.type = 'signal'; + message.to = this._peerId; + message.roomType = this._getRoomTypes()[0]; + message.roomId = this._roomIds[this._getRoomTypes()[0]]; + this._server.send(message); + } + + sendDisplayName(displayName) { + super.sendDisplayName(displayName); } getConnectionHash() { @@ -886,54 +1130,12 @@ class RTCPeer extends Peer { } } - _onIceConnectionStateChange() { - switch (this._conn.iceConnectionState) { - case 'failed': - this._onError('ICE Gathering failed'); - break; - default: - console.log('ICE Gathering', this._conn.iceConnectionState); + _onMessage(message) { + if (typeof message === 'string') { + // Todo: Test speed increase without prints? --> print only on debug mode via URL argument `?debug_mode=true` + console.log('RTC:', JSON.parse(message)); } - } - - _onError(error) { - console.error(error); - } - - _send(message) { - if (!this._channel) this.refresh(); - this._channel.send(message); - } - - _sendSignal(signal) { - signal.type = 'signal'; - signal.to = this._peerId; - signal.roomType = this._getRoomTypes()[0]; - signal.roomId = this._roomIds[this._getRoomTypes()[0]]; - this._server.send(signal); - } - - refresh() { - // check if channel is open. otherwise create one - if (this._isConnected() || this._isConnecting()) return; - - // only reconnect if peer is caller - if (!this._isCaller) return; - - this._connect(); - } - - _isConnected() { - return this._channel && this._channel.readyState === 'open'; - } - - _isConnecting() { - return this._channel && this._channel.readyState === 'connecting'; - } - - sendDisplayName(displayName) { - if (!this._isConnected()) return; - super.sendDisplayName(displayName); + super._onMessage(message); } } @@ -1020,9 +1222,7 @@ class PeersManager { this.peers[peerId].onServerMessage(message); } - _refreshPeer(peerId, roomType, roomId) { - if (!this._peerExists(peerId)) return false; - + _refreshPeer(isCaller, peerId, roomType, roomId) { const peer = this.peers[peerId]; const roomTypesDiffer = Object.keys(peer._roomIds)[0] !== roomType; const roomIdsDiffer = peer._roomIds[roomType] !== roomId; @@ -1036,17 +1236,22 @@ class PeersManager { return true; } - peer.refresh(); + // reconnect peer - caller/waiter might be switched + peer._setIsCaller(isCaller); + peer._refresh(); return true; } _createOrRefreshPeer(isCaller, peerId, roomType, roomId, rtcSupported) { if (this._peerExists(peerId)) { - this._refreshPeer(peerId, roomType, roomId); - return; + this._refreshPeer(isCaller, peerId, roomType, roomId); + } else { + this.createPeer(isCaller, peerId, roomType, roomId, rtcSupported); } + } + createPeer(isCaller, peerId, roomType, roomId, rtcSupported) { if (window.isRtcSupported && rtcSupported) { this.peers[peerId] = new RTCPeer(this._server, isCaller, peerId, roomType, roomId, this._wsConfig.rtcConfig); } @@ -1091,7 +1296,7 @@ class PeersManager { } _onPeerLeft(message) { - if (this._peerExists(message.peerId) && this._webRtcSupported(message.peerId)) { + if (this._peerExists(message.peerId) && !this._webRtcSupported(message.peerId)) { console.log('WSPeer left:', message.peerId); } if (message.disconnect === true) { @@ -1136,11 +1341,10 @@ class PeersManager { _onPeerDisconnected(peerId) { const peer = this.peers[peerId]; delete this.peers[peerId]; - if (!peer || !peer._conn) return; - if (peer._channel) peer._channel.onclose = null; - peer._conn.close(); - peer._busy = false; - peer._roomIds = {}; + + if (!peer) return; + + peer._closeChannelAndConnection(); } _onRoomSecretsDeleted(roomSecrets) { @@ -1268,6 +1472,11 @@ class FileChunker { this._readChunk(); } + _restartFromOffset(offset) { + this._offset = offset; + this.nextPartition(); + } + repeatPartition() { this._offset -= this._partitionSize; this.nextPartition(); diff --git a/public/scripts/ui.js b/public/scripts/ui.js index e399d89..2b6b9e6 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -16,7 +16,7 @@ class PeersUI { this.$shareModeCancelBtn = $$('.shr-panel .cancel-btn'); this.$shareModeEditBtn = $$('.shr-panel .edit-btn'); - this.peers = {}; + this.peerUIs = {}; this.shareMode = {}; this.shareMode.active = false; @@ -24,9 +24,9 @@ class PeersUI { this.shareMode.files = []; this.shareMode.text = ""; - Events.on('peer-joined', e => this._onPeerJoined(e.detail)); - Events.on('peer-added', _ => this._evaluateOverflowingPeers()); + Events.on('peer-joined', e => this._onPeerJoined(e.detail.peer, e.detail.roomType, e.detail.roomId)); Events.on('peer-connected', e => this._onPeerConnected(e.detail.peerId, e.detail.connectionHash)); + Events.on('peer-connecting', e => this._onPeerConnecting(e.detail)); Events.on('peer-disconnected', e => this._onPeerDisconnected(e.detail)); Events.on('peers', e => this._onPeers(e.detail)); Events.on('set-progress', e => this._onSetProgress(e.detail)); @@ -47,17 +47,17 @@ class PeersUI { this.$shareModeCancelBtn.addEventListener('click', _ => this._deactivateShareMode()); - Events.on('peer-display-name-changed', e => this._onPeerDisplayNameChanged(e)); + Events.on('peer-display-name-changed', e => this._onPeerDisplayNameChanged(e.detail.peerId, e.detail.displayName)); Events.on('ws-config', e => this._evaluateRtcSupport(e.detail)) } _evaluateRtcSupport(wsConfig) { if (wsConfig.wsFallback) { - this.$wsFallbackWarning.hidden = false; + this.$wsFallbackWarning.removeAttribute("hidden"); } else { - this.$wsFallbackWarning.hidden = true; + this.$wsFallbackWarning.setAttribute("hidden", true); if (!window.isRtcSupported) { alert(Localization.getTranslation("instructions.webrtc-requirement")); } @@ -65,15 +65,17 @@ class PeersUI { } _changePeerDisplayName(peerId, displayName) { - this.peers[peerId].name.displayName = displayName; - const peerIdNode = $(peerId); - if (peerIdNode && displayName) peerIdNode.querySelector('.name').textContent = displayName; - this._redrawPeerRoomTypes(peerId); + const peerUI = this.peerUIs[peerId]; + + if (!peerUI) return; + + peerUI._setDisplayName(displayName); } - _onPeerDisplayNameChanged(e) { - if (!e.detail.displayName) return; - this._changePeerDisplayName(e.detail.peerId, e.detail.displayName); + _onPeerDisplayNameChanged(peerId, displayName) { + if (!peerId || !displayName) return; + + this._changePeerDisplayName(peerId, displayName); } async _onKeyDown(e) { @@ -89,50 +91,48 @@ class PeersUI { } } - _onPeerJoined(msg) { - this._joinPeer(msg.peer, msg.roomType, msg.roomId); + _onPeerJoined(peer, roomType, roomId) { + this._joinPeer(peer, roomType, roomId); } _joinPeer(peer, roomType, roomId) { - const existingPeer = this.peers[peer.id]; - if (existingPeer) { - // peer already exists. Abort but add roomType to GUI - existingPeer._roomIds[roomType] = roomId; - this._redrawPeerRoomTypes(peer.id); + const existingPeerUI = this.peerUIs[peer.id]; + if (existingPeerUI) { + // peerUI already exists. Abort but add roomType to GUI + existingPeerUI._addRoomId(roomType, roomId); return; } - peer._isSameBrowser = () => BrowserTabsConnector.peerIsSameBrowser(peer.id); - peer._roomIds = {}; - - peer._roomIds[roomType] = roomId; - this.peers[peer.id] = peer; - } - - _onPeerConnected(peerId, connectionHash) { - if (!this.peers[peerId] || $(peerId)) return; - - const peer = this.peers[peerId]; - - new PeerUI(peer, connectionHash, { + const peerUI = new PeerUI(peer, roomType, roomId, { active: this.shareMode.active, descriptor: this.shareMode.descriptor, }); + this.peerUIs[peer.id] = peerUI; } - _redrawPeerRoomTypes(peerId) { - const peer = this.peers[peerId]; - const peerNode = $(peerId); + _onPeerConnected(peerId, connectionHash) { + const peerUI = this.peerUIs[peerId]; - if (!peer || !peerNode) return; + if (!peerUI) return; - peerNode.classList.remove('type-ip', 'type-secret', 'type-public-id', 'type-same-browser'); + peerUI._peerConnected(true, connectionHash); - if (peer._isSameBrowser()) { - peerNode.classList.add(`type-same-browser`); - } + this._addPeerUIIfMissing(peerUI); + } - Object.keys(peer._roomIds).forEach(roomType => peerNode.classList.add(`type-${roomType}`)); + _addPeerUIIfMissing(peerUI) { + if (this.$xPeers.contains(peerUI.$el)) return; + + this.$xPeers.appendChild(peerUI.$el); + this._evaluateOverflowingPeers(); + } + + _onPeerConnecting(peerId) { + const peerUI = this.peerUIs[peerId]; + + if (!peerUI) return; + + peerUI._peerConnected(false); } _evaluateOverflowingPeers() { @@ -149,26 +149,31 @@ class PeersUI { } _onPeerDisconnected(peerId) { - const $peer = $(peerId); - if (!$peer) return; - $peer.remove(); + const peerUI = this.peerUIs[peerId]; + + if (!peerUI) return; + + peerUI._removeDom(); + + delete this.peerUIs[peerId]; + this._evaluateOverflowingPeers(); } _onRoomTypeRemoved(peerId, roomType) { - const peer = this.peers[peerId]; + const peerUI = this.peerUIs[peerId]; - if (!peer) return; + if (!peerUI) return; - delete peer._roomIds[roomType]; - - this._redrawPeerRoomTypes(peerId) + peerUI._removeRoomId(roomType); } _onSetProgress(progress) { - const $peer = $(progress.peerId); - if (!$peer) return; - $peer.ui.setProgress(progress.progress, progress.status) + const peerUI = this.peerUIs[progress.peerId]; + + if (!peerUI) return; + + peerUI.setProgress(progress.progress, progress.status); } _onDrop(e) { @@ -392,35 +397,52 @@ class PeersUI { class PeerUI { static _badgeClassNames = ["badge-room-ip", "badge-room-secret", "badge-room-public-id"]; - static _shareMode = { - active: false, - descriptor: "" - }; - constructor(peer, connectionHash, shareMode) { + constructor(peer, roomType, roomId, shareMode = {active: false, descriptor: ""}) { this.$xInstructions = $$('x-instructions'); - this.$xPeers = $$('x-peers'); this._peer = peer; - this._connectionHash = - `${connectionHash.substring(0, 4)} ${connectionHash.substring(4, 8)} ${connectionHash.substring(8, 12)} ${connectionHash.substring(12, 16)}`; + this._connectionHash = ""; + this._connected = false; - // This is needed if the ShareMode is started BEFORE the PeerUI is drawn. - PeerUI._shareMode = shareMode; + this._roomIds = {} + this._roomIds[roomType] = roomId; + this._shareMode = shareMode; + + this._createCallbacks(); this._initDom(); - this.$xPeers.appendChild(this.$el); - Events.fire('peer-added'); - // ShareMode Events.on('share-mode-changed', e => this._onShareModeChanged(e.detail.active, e.detail.descriptor)); } + _initDom() { + this.$el = document.createElement('x-peer'); + this.$el.id = this._peer.id; + this.$el.ui = this; + this.$el.classList.add('center'); + + this.html(); + + this.$label = this.$el.querySelector('label'); + this.$input = this.$el.querySelector('input'); + this.$displayName = this.$el.querySelector('.name'); + + this.updateTypesClassList(); + + this.setStatus("connect"); + + this._evaluateShareMode(); + this._bindListeners(); + } + + _removeDom() { + this.$el.remove(); + } + html() { - let title= PeerUI._shareMode.active - ? Localization.getTranslation("peer-ui.click-to-send-share-mode", null, {descriptor: PeerUI._shareMode.descriptor}) - : Localization.getTranslation("peer-ui.click-to-send"); + let title= Localization.getTranslation("peer-ui.click-to-send"); this.$el.innerHTML = ` `; - - this.$el.querySelector('svg use').setAttribute('xlink:href', this._icon()); - this.$el.querySelector('.name').textContent = this._displayName(); - this.$el.querySelector('.device-name').textContent = this._deviceName(); } updateTypesClassList() { @@ -544,8 +546,6 @@ class PeerUI { _createCallbacks() { this._callbackInput = e => this._onFilesSelected(e); - this._callbackClickSleep = _ => NoSleepUI.enable(); - this._callbackTouchStartSleep = _ => NoSleepUI.enable(); this._callbackDrop = e => this._onDrop(e); this._callbackDragEnd = e => this._onDragEnd(e); this._callbackDragLeave = e => this._onDragEnd(e); @@ -562,9 +562,7 @@ class PeerUI { this.$el.removeEventListener('pointerdown', this._callbackPointerDown); // Add Events Normal Mode - this.$el.querySelector('input').addEventListener('change', this._callbackInput); - this.$el.addEventListener('click', this._callbackClickSleep); - this.$el.addEventListener('touchstart', this._callbackTouchStartSleep); + this.$input.addEventListener('change', this._callbackInput); this.$el.addEventListener('drop', this._callbackDrop); this.$el.addEventListener('dragend', this._callbackDragEnd); this.$el.addEventListener('dragleave', this._callbackDragLeave); @@ -575,8 +573,7 @@ class PeerUI { } else { // Remove Events Normal Mode - this.$el.removeEventListener('click', this._callbackClickSleep); - this.$el.removeEventListener('touchstart', this._callbackTouchStartSleep); + this.$input.removeEventListener('change', this._callbackInput); this.$el.removeEventListener('drop', this._callbackDrop); this.$el.removeEventListener('dragend', this._callbackDragEnd); this.$el.removeEventListener('dragleave', this._callbackDragLeave); @@ -677,6 +674,13 @@ class PeerUI { if (files.length === 0) return; + let totalSize = 0; + for (let i = 0; i < files.length; i++) { + totalSize += files[i].size; + } + // Prevent device from sleeping + NoSleepUI.enable(); + Events.fire('files-selected', { files: files, to: this._peer.id @@ -724,7 +728,7 @@ class PeerUI { this.$el.removeAttribute('status'); this.$el.querySelector('.status').innerHTML = ''; this._currentStatus = null; - NoSleepUI.disableIfPeersIdle(); + NoSleepUI.disableIfIdle(); return; } @@ -1346,9 +1350,11 @@ class ReceiveRequestDialog extends ReceiveDialog { this.$acceptRequestBtn.addEventListener('click', _ => this._respondToFileTransferRequest(true)); this.$declineRequestBtn.addEventListener('click', _ => this._respondToFileTransferRequest(false)); + this._filesTransferRequestQueue = []; + this._currentRequest = null; + Events.on('files-transfer-request', e => this._onRequestFileTransfer(e.detail.request, e.detail.peerId)) Events.on('keydown', e => this._onKeyDown(e)); - this._filesTransferRequestQueue = []; } _onKeyDown(e) { @@ -1366,9 +1372,13 @@ class ReceiveRequestDialog extends ReceiveDialog { } _dequeueRequests() { - if (!this._filesTransferRequestQueue.length) return; + if (!this._filesTransferRequestQueue.length) { + this._currentRequest = null; + return; + } let {request, peerId} = this._filesTransferRequestQueue.shift(); - this._showRequestDialog(request, peerId) + this._currentRequest = request; + this._showRequestDialog(request, peerId); } _addThumbnailToPreviewBox(thumbnailData) { @@ -1409,7 +1419,8 @@ class ReceiveRequestDialog extends ReceiveDialog { }) if (accepted) { Events.fire('set-progress', {peerId: this.correspondingPeerId, progress: 0, status: 'wait'}); - // Todo: only on big files? + + // Prevent device from sleeping NoSleepUI.enable(); } this.hide(); @@ -2856,20 +2867,20 @@ class WebFileHandlersUI { class NoSleepUI { constructor() { NoSleepUI._nosleep = new NoSleep(); + NoSleepUI._active = false; } static enable() { - if (!NoSleepUI._interval) { - NoSleepUI._nosleep.enable(); - // Disable after 10s if all peers are idle - NoSleepUI._interval = setInterval(() => NoSleepUI.disableIfPeersIdle(), 10000); - } + if (NoSleepUI._active) return; + + NoSleepUI._nosleep.enable(); + NoSleepUI._active = true; } - static disableIfPeersIdle() { - if ($$('x-peer[status]') === null) { - clearInterval(NoSleepUI._interval); - NoSleepUI._nosleep.disable(); - } + static disableIfIdle() { + if ($$('x-peer[status]')) return; + + NoSleepUI._nosleep.disable(); + NoSleepUI._active = false; } } From 5ee8bb871e7c75324f243ffc2a97282f4101f539 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Wed, 7 Feb 2024 04:11:56 +0100 Subject: [PATCH 20/53] Move file creation to serviceworker to prevent loading everything into RAM --- public/scripts/network.js | 88 +++++++++++++++++++++++++++++- public/scripts/sw-file-digester.js | 48 ++++++++++++++++ public/scripts/ui.js | 2 + 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 public/scripts/sw-file-digester.js diff --git a/public/scripts/network.js b/public/scripts/network.js index 5ae3f8d..430a33a 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -1742,7 +1742,7 @@ class FileDigester { this._mime = meta.mime; this._totalSize = totalSize; this._totalBytesReceived = totalBytesReceived; - this._onFileCompleteCallback = fileCompleteCallback; + this._fileCompleteCallback = fileCompleteCallback; this._receiveConfimationCallback = receiveConfirmationCallback; } @@ -1763,13 +1763,95 @@ class FileDigester { if (this._bytesReceived < this._size) return; // we are done + if (!window.Worker && !window.isSecureContext) { + this.processFileViaMemory(); + return; + } + + this.processFileViaWorker(); + } + + processFileViaMemory() { + Logger.warn('Big file transfers might exceed the RAM of the receiver. Use a secure context (https) to prevent this.'); + + // Loads complete file into RAM which might lead to a page crash (Memory limit iOS Safari: ~380 MB) const file = new File(this._buffer, this._name, { type: this._mime, lastModified: new Date().getTime() }) + this._fileCompleteCallback(file); + } - this._buffer = []; - this._onFileCompleteCallback(file); + processFileViaWorker() { + // Use service worker to prevent loading the complete file into RAM + const fileWorker = new Worker("scripts/sw-file-digester.js"); + + let i = 0; + let offset = 0; + + const _this = this; + + function sendPart(buffer, offset) { + fileWorker.postMessage({ + type: "part", + name: _this._name, + buffer: buffer, + offset: offset + }); + } + + function getFile() { + fileWorker.postMessage({ + type: "get-file", + name: _this._name, + }); + } + + function onPart(part) { + // remove old chunk from buffer + _this._buffer[i] = null; + + if (i < _this._buffer.length - 1) { + // process next chunk + offset += part.byteLength; + i++; + sendPart(_this._buffer[i], offset); + return; + } + + // File processing complete -> retrieve completed file + getFile(); + } + + function onFile(file) { + _this._buffer = []; + fileWorker.terminate(); + _this._fileCompleteCallback(file); + } + + function onError(error) { + // an error occurred. Use memory method instead. + Logger.error(error); + Logger.warn('Failed to process file via service-worker. Do not use Firefox private mode to prevent this.') + fileWorker.terminate(); + _this.processFileViaMemory(); + } + + sendPart(this._buffer[i], offset); + + fileWorker.onmessage = (e) => { + switch (e.data.type) { + case "part": + onPart(e.data.part); + break; + case "file": + onFile(e.data.file); + break; + case "error": + onError(e.data.error); + break; + } + } } } diff --git a/public/scripts/sw-file-digester.js b/public/scripts/sw-file-digester.js new file mode 100644 index 0000000..0a2bb3b --- /dev/null +++ b/public/scripts/sw-file-digester.js @@ -0,0 +1,48 @@ +self.addEventListener('message', async e => { + try { + switch (e.data.type) { + case "part": + await this.onPart(e.data.name, e.data.buffer, e.data.offset); + break; + case "get-file": + await this.onGetFile(e.data.name); + break; + } + } + catch (e) { + self.postMessage({type: "error", error: e}); + } +}) + +async function getFileHandle(fileName) { + const root = await navigator.storage.getDirectory(); + return await root.getFileHandle(fileName, {create: true}); +} + +async function getAccessHandle(fileName) { + const fileHandle = await getFileHandle(fileName); + + // Create FileSystemSyncAccessHandle on the file. + return await fileHandle.createSyncAccessHandle(); +} + +async function onPart(fileName, buffer, offset) { + const accessHandle = await getAccessHandle(fileName); + + // Write the message to the end of the file. + let encodedMessage = new DataView(buffer); + accessHandle.write(encodedMessage, { at: offset }); + accessHandle.close(); + + self.postMessage({type: "part", part: encodedMessage}); + encodedMessage = null; +} + +async function onGetFile(fileName) { + const fileHandle = await getFileHandle(fileName); + let file = await fileHandle.getFile(); + + self.postMessage({type: "file", file: file}); + file = null; + // Todo: delete file from storage +} \ No newline at end of file diff --git a/public/scripts/ui.js b/public/scripts/ui.js index 0884bb6..14a3a9f 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -1143,6 +1143,8 @@ class ReceiveFileDialog extends ReceiveDialog { navigator.share({files: files}) .catch(err => { Logger.error(err); + // Todo: tidy up, setDownloadButton instead and show warning to user + // Differentiate: "File too big to be shared. It can be downloaded instead." and "Error while sharing. It can be downloaded instead." }); // Prevent clicking the button multiple times From 40a12b550109f87594e6502fb5bd9dfcde263f98 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Wed, 7 Feb 2024 23:58:15 +0100 Subject: [PATCH 21/53] Fix progress animation --- public/lang/en.json | 5 +- public/scripts/network.js | 10 ++-- public/scripts/ui.js | 117 +++++++++++++++++++++++++++++--------- 3 files changed, 98 insertions(+), 34 deletions(-) diff --git a/public/lang/en.json b/public/lang/en.json index 0aa810a..cc70d8f 100644 --- a/public/lang/en.json +++ b/public/lang/en.json @@ -179,8 +179,9 @@ "preparing": "Preparing…", "waiting": "Waiting…", "processing": "Processing…", - "transferring": "Transferring…", + "transferring": "Sending…", "receiving": "Receiving…", - "complete": "Transfer complete" + "transfer-complete": "Sent", + "receive-complete": "Received" } } diff --git a/public/scripts/network.js b/public/scripts/network.js index 430a33a..532ec7e 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -653,7 +653,7 @@ class Peer { } _abortTransfer() { - Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: null}); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); this._state = 'idle'; this._busy = false; this._chunker = null; @@ -728,7 +728,6 @@ class Peer { } _allFilesTransferComplete() { - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'process'}); this._state = 'idle'; Events.fire('files-received', { peerId: this._peerId, @@ -762,7 +761,7 @@ class Peer { if (this._acceptedRequest.header.length) return; // We are done receiving - Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'transfer'}); + Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'receive'}); this._allFilesTransferComplete(); } @@ -787,14 +786,14 @@ class Peer { // No more files in queue. Transfer is complete this._state = 'idle'; this._busy = false; - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'complete'}); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'transfer-complete'}); Events.fire('notify-user', Localization.getTranslation("notifications.file-transfer-completed")); Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app } _onFileTransferRequestResponded(message) { if (!message.accepted || this._state !== 'wait') { - Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: null}); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); this._state = 'idle'; this._filesRequested = null; return; @@ -1853,5 +1852,4 @@ class FileDigester { } } } - } diff --git a/public/scripts/ui.js b/public/scripts/ui.js index 14a3a9f..961e030 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -190,7 +190,7 @@ class PeersUI { if (!peerUI) return; - peerUI.setProgress(progress.progress, progress.status); + peerUI.setProgressOrQueue(progress.progress, progress.status); } _onDrop(e) { @@ -426,6 +426,8 @@ class PeerUI { this._currentStatus = null this._oldStatus = null; + this._progressQueue = []; + this._roomIds = {} this._roomIds[roomType] = roomId; @@ -688,35 +690,93 @@ class PeerUI { $input.files = null; // reset input } - setProgress(progress, status) { - clearTimeout(this.resetProgressTimeout); + setProgressOrQueue(progress, status) { + if (this._progressQueue.length > 0) { + // add to queue + this._progressQueue.push({progress: progress, status: status}); + for (let i = 0; i < this._progressQueue.length; i++) { + if (this._progressQueue[i].progress <= progress) { + // if progress is higher than progress in queue -> overwrite in queue and cut queue at this position + this._progressQueue[i].progress = progress; + this._progressQueue[i].status = status; + this._progressQueue = this._progressQueue.slice(0, i + 1); + break; + } + } + return; + } + + this.setProgress(progress, status); + } + + setNextProgress() { + if (this._progressQueue.length > 0) { + setTimeout(() => { + let next = this._progressQueue.shift() + this.setProgress(next.progress, next.status); + }, 250); // 200 ms animation + buffer + } + } + + setProgress(progress, status) { this.setStatus(status); - const progressSpillsOverHalf = this._currentProgress < 0.5 && 0.5 <= progress; + const progressSpillsOverHalf = this._currentProgress < 0.5 && 0.5 < progress; // 0.5 slips through + const progressSpillsOverFull = progress <= 0.5 && 0.5 <= this._currentProgress && this._currentProgress < 1; - if (progress === 0 || progressSpillsOverHalf) { - this.$progress.classList.remove('animate'); + if (progressSpillsOverHalf) { + this._progressQueue.unshift({progress: progress, status: status}); + this.setProgress(0.5, status); + return; + } else if (progressSpillsOverFull) { + this._progressQueue.unshift({progress: progress, status: status}); + this.setProgress(1, status); + return; } - else { + + if (progress === 0) { + this._currentProgress = 0; + this.$progress.classList.remove('animate'); + this.$progress.classList.remove('over50'); + this.$progress.style.setProperty('--progress', `rotate(${360 * progress}deg)`); + this.setNextProgress(); + return; + } + + if (progress < this._currentProgress && status !== this._currentStatus) { + // reset progress + this._progressQueue.unshift({progress: progress, status: status}); + this.setProgress(0, status); + return; + } + + if (progress === 0) { + this.$progress.classList.remove('animate'); + this.$progress.classList.remove('over50'); + this.$progress.classList.add('animate'); + } else if (this._currentProgress === 0.5) { + this.$progress.classList.remove('animate'); + this.$progress.classList.add('over50'); this.$progress.classList.add('animate'); } - if (0.5 <= progress && progress < 1) { - this.$progress.classList.add('over50'); - } - else { - this.$progress.classList.remove('over50'); + if (this._currentProgress < progress) { + this.$progress.classList.add('animate'); + } else { + this.$progress.classList.remove('animate'); } - const degrees = `rotate(${360 * progress}deg)`; - this.$progress.style.setProperty('--progress', degrees); + this.$progress.style.setProperty('--progress', `rotate(${360 * progress}deg)`); this._currentProgress = progress if (progress === 1) { - this.resetProgressTimeout = setTimeout(() => this.setProgress(0, null), 200); + // reset progress + this._progressQueue.unshift({progress: 0, status: status}); } + + this.setNextProgress(); } setStatus(status) { @@ -732,8 +792,6 @@ class PeerUI { return; } - this.$el.classList.add('blink'); - let statusName = { "connect": Localization.getTranslation("peer-ui.connecting"), "prepare": Localization.getTranslation("peer-ui.preparing"), @@ -741,20 +799,24 @@ class PeerUI { "receive": Localization.getTranslation("peer-ui.receiving"), "process": Localization.getTranslation("peer-ui.processing"), "wait": Localization.getTranslation("peer-ui.waiting"), - "complete": Localization.getTranslation("peer-ui.complete") + "transfer-complete": Localization.getTranslation("peer-ui.transfer-complete"), + "receive-complete": Localization.getTranslation("peer-ui.receive-complete") }[status]; - if (status === "complete") { + this.$el.setAttribute('status', status); + this.$el.querySelector('.status').innerText = statusName; + this._currentStatus = status; + + if (status === "transfer-complete" || status === "receive-complete") { this.$el.classList.remove('blink'); this.statusTimeout = setTimeout(() => { this.setProgress(0, null); }, 10000); } - - this.$el.setAttribute('status', status); - this.$el.querySelector('.status').innerText = statusName; - this._currentStatus = status; + else { + this.$el.classList.add('blink'); + } } _onDrop(e) { @@ -1069,7 +1131,7 @@ class ReceiveFileDialog extends ReceiveDialog { await this._setViaDownload(peerId, files, totalSize, descriptor); } - Events.fire('set-progress', {peerId: peerId, progress: 1, status: null}); + Events.fire('set-progress', {peerId: peerId, progress: 1, status: "receive-complete"}); } _getDescriptor(files, imagesOnly) { @@ -1167,14 +1229,16 @@ class ReceiveFileDialog extends ReceiveDialog { const tooBigToZip = window.iOS && totalSize > 256000000; this.sendAsZip = false; if (files.length > 1 && !tooBigToZip) { - zipFileName = this._createZipFilename() + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'process'}); + zipFileUrl = await this._createZipFile(files, zipProgress => { Events.fire('set-progress', { peerId: peerId, progress: zipProgress / totalSize, status: 'process' }) - }) + }); + zipFileName = this._createZipFilename() this.sendAsZip = !!zipFileUrl; } @@ -1308,6 +1372,7 @@ class ReceiveFileDialog extends ReceiveDialog { // download automatically if zipped or if only one file is received this.$downloadBtn.click(); + // if automatic download fails -> show dialog setTimeout(() => { if (!this.downloadSuccessful) { From 2d2cfec5f00861cb2123cb2e482afa19a41f4f68 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 8 Feb 2024 00:46:00 +0100 Subject: [PATCH 22/53] Add missing checks for transfer states --- public/scripts/network.js | 46 +++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 532ec7e..540c7b5 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -664,7 +664,10 @@ class Peer { } _onChunkReceived(chunk) { - if(this._state !== 'receive' || !this._digester || !(chunk.byteLength || chunk.size)) return; + if(this._state !== 'receive' || !this._digester || !(chunk.byteLength || chunk.size)) { + this._sendCurrentState(); + return; + } this._digester.unchunk(chunk); @@ -690,13 +693,19 @@ class Peer { } _onProgress(progress) { - if (this._state !== 'transfer') return; + if (this._state !== 'transfer') { + this._sendCurrentState(); + return; + } Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'transfer'}); } _onReceiveConfirmation(bytesReceived) { - if (!this._chunker || this._state !== 'transfer') return; + if (!this._chunker || this._state !== 'transfer') { + this._sendCurrentState(); + return; + } this._chunker._onReceiveConfirmation(bytesReceived); } @@ -704,7 +713,7 @@ class Peer { if (!this._acceptedRequest) { return false; } - + // Check if file fits to header const acceptedHeader = this._acceptedRequest.header.shift(); @@ -740,7 +749,12 @@ class Peer { this._busy = false; } - async _onFileReceived(file) { + async _fileReceived(file) { + if (this._state !== "receive") { + this._sendCurrentState(); + return; + } + if (!this._fitsHeader(file)) { this._abortTransfer(); Events.fire('notify-user', Localization.getTranslation("notifications.files-incorrect")); @@ -766,6 +780,11 @@ class Peer { } _onFileTransferComplete(message) { + if (this._state !== "transfer") { + this._sendCurrentState(); + return; + } + this._chunker = null; if (!message.success) { @@ -792,12 +811,18 @@ class Peer { } _onFileTransferRequestResponded(message) { - if (!message.accepted || this._state !== 'wait') { + if (this._state !== 'wait') { + this._sendCurrentState(); + return; + } + + if (!message.accepted) { Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); this._state = 'idle'; this._filesRequested = null; return; } + Events.fire('file-transfer-accepted'); Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'transfer'}); this._state = 'transfer'; @@ -805,7 +830,10 @@ class Peer { } _onMessageTransferCompleted() { - if (this._state !== 'text-sent') return; + if (this._state !== 'text-sent') { + this._sendCurrentState(); + return; + } this._state = 'idle'; Events.fire('notify-user', Localization.getTranslation("notifications.message-transfer-completed")); } @@ -1227,7 +1255,7 @@ class RTCPeer extends Peer { this._digester = new FileDigester({size: header.size, name: header.name, mime: header.mime}, this._acceptedRequest.totalSize, this._totalBytesReceived, - fileBlob => this._onFileReceived(fileBlob) + fileBlob => this._fileReceived(fileBlob) ); } @@ -1328,7 +1356,7 @@ class WSPeer extends Peer { this._digester = new FileDigester({size: header.size, name: header.name, mime: header.mime}, this._acceptedRequest.totalSize, this._totalBytesReceived, - fileBlob => this._onFileReceived(fileBlob), + fileBlob => this._fileReceived(fileBlob), bytesReceived => this._sendReceiveConfirmation(bytesReceived) ); } From d8908e01ead5b114dcc28cde70785d387d96e0b7 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 8 Feb 2024 00:46:51 +0100 Subject: [PATCH 23/53] Add alert for iOS when receiving big files using a private tab --- public/scripts/network.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 540c7b5..482de81 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -1799,9 +1799,12 @@ class FileDigester { } processFileViaMemory() { + // Loads complete file into RAM which might lead to a page crash (Memory limit iOS Safari: ~380 MB) + if (window.iOS && this._totalSize > 250000000) { + alert('File is bigger than 250 MB and might crash the page on iOS. To be able to use a more efficient method use https and avoid private tabs as they have restricted functionality.') + } Logger.warn('Big file transfers might exceed the RAM of the receiver. Use a secure context (https) to prevent this.'); - // Loads complete file into RAM which might lead to a page crash (Memory limit iOS Safari: ~380 MB) const file = new File(this._buffer, this._name, { type: this._mime, lastModified: new Date().getTime() From 19d33e11d865f1a09a5378b4d5e2eb68e5bd5078 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 8 Feb 2024 01:36:20 +0100 Subject: [PATCH 24/53] Implement fallback to download if navigator.share() fails. Refactor ReceiveFileDialog --- public/lang/en.json | 4 +- public/scripts/ui.js | 148 +++++++++++++++++++++++++------------------ 2 files changed, 88 insertions(+), 64 deletions(-) diff --git a/public/lang/en.json b/public/lang/en.json index cc70d8f..694dc18 100644 --- a/public/lang/en.json +++ b/public/lang/en.json @@ -161,7 +161,9 @@ "message-transfer-completed": "Message transfer completed", "unfinished-transfers-warning": "There are unfinished transfers. Are you sure you want to close PairDrop?", "rate-limit-join-key": "Rate limit reached. Wait 10 seconds and try again.", - "selected-peer-left": "Selected peer left" + "selected-peer-left": "Selected peer left", + "error-sharing-size": "Files too big to be shared. They can be downloaded instead.", + "error-sharing-default": "Error while sharing. It can be downloaded instead." }, "document-titles": { "file-received": "File Received", diff --git a/public/scripts/ui.js b/public/scripts/ui.js index 961e030..a58a98f 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -1073,65 +1073,74 @@ class ReceiveFileDialog extends ReceiveDialog { this.$shareBtn = this.$el.querySelector('#share-btn'); Events.on('files-received', e => this._onFilesReceived(e.detail.peerId, e.detail.files, e.detail.imagesOnly, e.detail.totalSize)); - this._filesQueue = []; + this._filesDataQueue = []; } async _onFilesReceived(peerId, files, imagesOnly, totalSize) { + const descriptor = this._getDescriptor(files, imagesOnly); const displayName = $(peerId).ui._displayName(); const connectionHash = $(peerId).ui._connectionHash; const badgeClassName = $(peerId).ui._badgeClassName(); - this._filesQueue.push({ + this._filesDataQueue.push({ peerId: peerId, - displayName: displayName, - connectionHash: connectionHash, files: files, imagesOnly: imagesOnly, totalSize: totalSize, + descriptor: descriptor, + displayName: displayName, + connectionHash: connectionHash, badgeClassName: badgeClassName }); audioPlayer.playBlop(); - await this._nextFiles(); - } - - async _nextFiles() { - if (this._busy || !this._filesQueue.length) return; - this._busy = true; - const {peerId, displayName, connectionHash, files, imagesOnly, totalSize, badgeClassName} = this._filesQueue.shift(); - await this._displayFiles(peerId, displayName, connectionHash, files, imagesOnly, totalSize, badgeClassName); + await this._processFiles(); } canShareFilesViaMenu(files) { return window.isMobile && !!navigator.share && navigator.canShare({files}); } - async _displayFiles(peerId, displayName, connectionHash, files, imagesOnly, totalSize, badgeClassName) { - const descriptor = this._getDescriptor(files, imagesOnly); - const documentTitleTranslation = files.length === 1 + async _processFiles() { + if (this._busy || !this._filesDataQueue.length) return; + + this._busy = true; + + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'process'}); + + this._data = this._filesDataQueue.shift(); + + const documentTitleTranslation = this._data.files.length === 1 ? `${ Localization.getTranslation("document-titles.file-received") } - PairDrop` - : `${ Localization.getTranslation("document-titles.file-received-plural", null, {count: files.length}) } - PairDrop`; + : `${ Localization.getTranslation("document-titles.file-received-plural", null, {count: this._data.files.length}) } - PairDrop`; // If possible, share via menu - else download files - const shareViaMenu = this.canShareFilesViaMenu(files); + const shareViaMenu = this.canShareFilesViaMenu(this._data.files); - this._parseFileData(displayName, connectionHash, files, imagesOnly, totalSize, badgeClassName); - this._setTitle(descriptor); + this._parseFileData( + this._data.displayName, + this._data.connectionHash, + this._data.files, + this._data.imagesOnly, + this._data.totalSize, + this._data.badgeClassName + ); + this._setTitle(this._data.descriptor); - await this._addFileToPreviewBox(files[0]); + await this._addFileToPreviewBox(this._data.files[0]); document.title = documentTitleTranslation; changeFavicon("images/favicon-96x96-notification.png"); if (shareViaMenu) { - await this._setViaShareMenu(files); + await this._setupShareMenu(); } else { - await this._setViaDownload(peerId, files, totalSize, descriptor); + await this._setupDownload(); } - Events.fire('set-progress', {peerId: peerId, progress: 1, status: "receive-complete"}); + Events.fire('set-progress', {peerId: this._data.peerId, progress: 0, status: "receive-complete"}); } _getDescriptor(files, imagesOnly) { @@ -1152,6 +1161,7 @@ class ReceiveFileDialog extends ReceiveDialog { _setTitle(descriptor) { this.$receiveTitle.innerText = Localization.getTranslation("dialogs.receive-title", null, {descriptor: descriptor}); } + createPreviewElement(file) { return new Promise((resolve, reject) => { try { @@ -1200,13 +1210,22 @@ class ReceiveFileDialog extends ReceiveDialog { }, duration); } - async _setShareButton(files) { + async _setShareButton() { this.$shareBtn.onclick = _ => { - navigator.share({files: files}) - .catch(err => { + navigator.share({files: this._data.files}) + .catch(async err => { Logger.error(err); - // Todo: tidy up, setDownloadButton instead and show warning to user - // Differentiate: "File too big to be shared. It can be downloaded instead." and "Error while sharing. It can be downloaded instead." + + if (err.name === 'AbortError' && err.message === 'Abort due to error while reading files.') { + Events.fire('notify-user', Localization.getTranslation("notifications.error-sharing-size")); + } + else { + Events.fire('notify-user', Localization.getTranslation("notifications.error-sharing-default")); + } + + // Fallback to download + this._tidyUpButtons(); + await this._setupDownload() }); // Prevent clicking the button multiple times @@ -1216,42 +1235,28 @@ class ReceiveFileDialog extends ReceiveDialog { this.$shareBtn.removeAttribute('hidden'); } - async _setDownloadButton(peerId, files, totalSize, descriptor) { - let downloadTranslation = Localization.getTranslation("dialogs.download") - let downloadSuccessfulTranslation = Localization.getTranslation("notifications.download-successful", null, {descriptor: descriptor}); - - this.$downloadBtn.innerText = downloadTranslation; - this.$downloadBtn.removeAttribute('disabled'); - this.$downloadBtn.removeAttribute('hidden'); - + async _processDataAsZip() { let zipFileUrl, zipFileName; + let sendAsZip = false; - const tooBigToZip = window.iOS && totalSize > 256000000; - this.sendAsZip = false; - if (files.length > 1 && !tooBigToZip) { - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'process'}); - - zipFileUrl = await this._createZipFile(files, zipProgress => { + const tooBigToZip = window.iOS && this._data.totalSize > 256000000; + if (this._data.files.length > 1 && !tooBigToZip) { + zipFileUrl = await this._createZipFile(this._data.files, zipProgress => { Events.fire('set-progress', { - peerId: peerId, - progress: zipProgress / totalSize, + peerId: this._data.peerId, + progress: zipProgress / this._data.totalSize, status: 'process' }) }); zipFileName = this._createZipFilename() - this.sendAsZip = !!zipFileUrl; - } - - // If single file or zipping failed -> download files individually -> else download zip - if (this.sendAsZip) { - this._setDownloadButtonToZip(zipFileUrl, zipFileName, downloadSuccessfulTranslation); - } else { - this._setDownloadButtonToFiles(files, downloadSuccessfulTranslation, downloadTranslation); + sendAsZip = !!zipFileUrl; } + return {sendAsZip, zipFileUrl, zipFileName}; } - _setDownloadButtonToZip(zipFileUrl, zipFileName, downloadSuccessfulTranslation) { + _setDownloadButtonToZip(zipFileUrl, zipFileName) { + const downloadSuccessfulTranslation = Localization.getTranslation("notifications.download-successful", null, {descriptor: this._data.descriptor}); this.downloadSuccessful = false; this.$downloadBtn.onclick = _ => { this._downloadFileFromUrl(zipFileUrl, zipFileName) @@ -1263,12 +1268,17 @@ class ReceiveFileDialog extends ReceiveDialog { }; } - _setDownloadButtonToFiles(files, downloadSuccessfulTranslation, downloadTranslation) { + _setDownloadButtonToFiles(files) { + const downloadTranslation = Localization.getTranslation("dialogs.download"); + const downloadSuccessfulTranslation = Localization.getTranslation("notifications.download-successful", null, {descriptor: this._data.descriptor}); + + this.$downloadBtn.innerText = files.length === 1 + ? downloadTranslation + : `${downloadTranslation} 1/${files.length}`; + this.downloadSuccessful = false; let i = 0; - this.$downloadBtn.innerText = `${downloadTranslation} ${i + 1}/${files.length}`; - this.$downloadBtn.onclick = _ => { this._disableButton(this.$shareBtn, 2000); @@ -1351,21 +1361,33 @@ class ReceiveFileDialog extends ReceiveDialog { return `PairDrop_files_${year}${month}${date}_${hours}${minutes}.zip`; } - async _setViaShareMenu(files) { - await this._setShareButton(files); + async _setupShareMenu() { + await this._setShareButton(); // always show dialog this.show(); + // open share menu automatically setTimeout(() => { this.$shareBtn.click(); }, 500); } - async _setViaDownload(peerId, files, totalSize, descriptor) { - await this._setDownloadButton(peerId, files, totalSize, descriptor); + async _setupDownload() { + this.$downloadBtn.innerText = Localization.getTranslation("dialogs.download"); + this.$downloadBtn.removeAttribute('disabled'); + this.$downloadBtn.removeAttribute('hidden'); - if (!this.sendAsZip && files.length !== 1) { + let {sendAsZip, zipFileUrl, zipFileName} = await this._processDataAsZip(); + + // If single file or zipping failed -> download files individually -> else download zip + if (sendAsZip) { + this._setDownloadButtonToZip(zipFileUrl, zipFileName); + } else { + this._setDownloadButtonToFiles(this._data.files); + } + + if (!sendAsZip) { this.show(); return; } @@ -1373,7 +1395,7 @@ class ReceiveFileDialog extends ReceiveDialog { // download automatically if zipped or if only one file is received this.$downloadBtn.click(); - // if automatic download fails -> show dialog + // if automatic download fails -> show dialog after 1 s setTimeout(() => { if (!this.downloadSuccessful) { this.show(); @@ -1402,7 +1424,7 @@ class ReceiveFileDialog extends ReceiveDialog { this._busy = false; - await this._nextFiles(); + await this._processFiles(); }, 300); } } From 902b5c6b8f7cf7b4c34b044d97e260528cc65afd Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 8 Feb 2024 04:03:02 +0100 Subject: [PATCH 25/53] Refactor file transfer --- public/scripts/network.js | 140 ++++++++++++++++---------------------- 1 file changed, 58 insertions(+), 82 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 482de81..f0986af 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -472,8 +472,9 @@ class Peer { let totalSize = 0; let imagesOnly = true this._state = 'prepare'; + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'prepare'}); + for (let i = 0; i < files.length; i++) { - Events.fire('set-progress', {peerId: this._peerId, progress: 0.8*i/files.length, status: 'prepare'}) header.push({ name: files[i].name, mime: files[i].type, @@ -483,8 +484,6 @@ class Peer { if (files[i].type.split('/')[0] !== 'image') imagesOnly = false; } - Events.fire('set-progress', {peerId: this._peerId, progress: 0.8, status: 'prepare'}) - let dataUrl = ''; if (files[0].type.split('/')[0] === 'image') { try { @@ -498,7 +497,7 @@ class Peer { this._filesRequested = files; - this._sendMessage({type: 'request', + this._sendMessage({type: 'transfer-request', header: header, totalSize: totalSize, imagesOnly: imagesOnly, @@ -525,7 +524,7 @@ class Peer { _sendHeader(file) { this._sendMessage({ - type: 'header', + type: 'transfer-header', size: file.size, name: file.name, mime: file.type @@ -554,7 +553,7 @@ class Peer { } _sendProgress(progress) { - this._sendMessage({ type: 'progress', progress: progress }); + this._sendMessage({ type: 'receive-progress', progress: progress }); } _onData(data) { @@ -563,14 +562,20 @@ class Peer { _onMessage(message) { switch (message.type) { - case 'request': - this._onFilesTransferRequest(message); + case 'state': + this._onReceiveState(message.state); break; - case 'header': - this._onHeader(message); + case 'transfer-request': + this._onTransferRequest(message); break; - case 'progress': - this._onProgress(message.progress); + case 'transfer-response': + this._onTransferResponse(message); + break; + case 'transfer-header': + this._onTransferHeader(message); + break; + case 'receive-progress': + this._onReceiveProgress(message.progress); break; case 'receive-confirmation': this._onReceiveConfirmation(message.bytesReceived); @@ -578,33 +583,27 @@ class Peer { case 'resend-request': this._onResendRequest(message.offset); break; - case 'files-transfer-response': - this._onFileTransferRequestResponded(message); - break; case 'file-transfer-complete': this._onFileTransferComplete(message); break; - case 'message-transfer-complete': - this._onMessageTransferCompleted(); - break; case 'text': this._onTextReceived(message); break; + case 'text-sent': + this._onTextSent(); + break; case 'display-name-changed': this._onDisplayNameChanged(message); break; - case 'state': - this._onReceiveState(message.state); - break; default: Logger.warn('RTC: Unknown message type:', message.type); } } - _onFilesTransferRequest(request) { + _onTransferRequest(request) { if (this._pendingRequest) { // Only accept one request at a time per peer - this._sendMessage({type: 'files-transfer-response', accepted: false}); + this._sendMessage({type: 'transfer-response', accepted: false}); return; } @@ -624,29 +623,35 @@ class Peer { } _respondToFileTransferRequest(accepted) { - this._sendMessage({type: 'files-transfer-response', accepted: accepted}); + this._sendMessage({type: 'transfer-response', accepted: accepted}); if (accepted) { - this._acceptedRequest = this._pendingRequest; - this._totalBytesReceived = 0; + this._state = 'receive'; this._busy = true; + this._acceptedRequest = this._pendingRequest; + this._lastProgress = 0; + this._totalBytesReceived = 0; this._filesReceived = []; } this._pendingRequest = null; } - _onHeader(header) { - if (!this._acceptedRequest || !this._acceptedRequest.header.length) { - this._sendTransferAbortion(); + _onTransferHeader(header) { + if (this._state !== "receive") { + this._sendCurrentState(); return; } - - this._state = 'receive'; - this._lastProgress = 0; this._timeStart = Date.now(); + this._addFileDigester(header); } - _addFileDigester(header) {} + _addFileDigester(header) { + this._digester = new FileDigester({size: header.size, name: header.name, mime: header.mime}, + this._acceptedRequest.totalSize, + fileBlob => this._fileReceived(fileBlob), + bytesReceived => this._sendReceiveConfirmation(bytesReceived) + ); + } _sendReceiveConfirmation(bytesReceived) { this._sendMessage({type: 'receive-confirmation', bytesReceived: bytesReceived}); @@ -661,6 +666,7 @@ class Peer { this._acceptedRequest = null; this._digester = null; this._filesReceived = []; + this._totalBytesReceived = 0; } _onChunkReceived(chunk) { @@ -671,7 +677,9 @@ class Peer { this._digester.unchunk(chunk); - const progress = this._digester.progress; + let progress = (this._totalBytesReceived + this._digester._bytesReceived) / this._acceptedRequest.totalSize; + + if (isNaN(progress)) progress = 1 if (progress > 1) { this._abortTransfer(); @@ -679,10 +687,6 @@ class Peer { return; } - if (progress === 1) { - this._digester = null; - } - Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'receive'}); // occasionally notify sender about our progress @@ -692,7 +696,7 @@ class Peer { } } - _onProgress(progress) { + _onReceiveProgress(progress) { if (this._state !== 'transfer') { this._sendCurrentState(); return; @@ -722,12 +726,17 @@ class Peer { return sameSize && sameName; } - _logTransferSpeed(size, duration, speed) { - Logger.log(`File received.\n\nSize: ${size} MB\tDuration: ${duration} s\tSpeed: ${speed} MB/s`); - } - - _singleFileTransferComplete(file, duration, size, speed) { + _singleFileTransferComplete(file) { + this._digester = null; this._totalBytesReceived += file.size; + + const duration = (Date.now() - this._timeStart) / 1000; // s + const size = Math.round(10 * file.size / 1e6) / 10; // MB + const speed = Math.round(100 * size / duration) / 100; // MB/s + + // Log speed from request to receive + Logger.log(`File received.\n\nSize: ${size} MB\tDuration: ${duration} s\tSpeed: ${speed} MB/s`); + this._sendMessage({type: 'file-transfer-complete', success: true, duration: duration, size: size, speed: speed}); // include for compatibility with 'Snapdrop & PairDrop for Android' app @@ -750,11 +759,6 @@ class Peer { } async _fileReceived(file) { - if (this._state !== "receive") { - this._sendCurrentState(); - return; - } - if (!this._fitsHeader(file)) { this._abortTransfer(); Events.fire('notify-user', Localization.getTranslation("notifications.files-incorrect")); @@ -762,15 +766,8 @@ class Peer { return; } - const duration = (Date.now() - this._timeStart) / 1000; - const size = Math.round(10 * file.size / 1000000) / 10; - const speed = Math.round(100 * file.size / 1000000 / duration) / 100; - - // Log speed - this._logTransferSpeed(duration, size, speed); - // File transfer complete - this._singleFileTransferComplete(file, duration, size, speed); + this._singleFileTransferComplete(file); if (this._acceptedRequest.header.length) return; @@ -810,7 +807,7 @@ class Peer { Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app } - _onFileTransferRequestResponded(message) { + _onTransferResponse(message) { if (this._state !== 'wait') { this._sendCurrentState(); return; @@ -829,7 +826,7 @@ class Peer { this.sendFiles(); } - _onMessageTransferCompleted() { + _onTextSent() { if (this._state !== 'text-sent') { this._sendCurrentState(); return; @@ -849,7 +846,7 @@ class Peer { try { const escaped = decodeURIComponent(escape(atob(message.text))); Events.fire('text-received', { text: escaped, peerId: this._peerId }); - this._sendMessage({ type: 'message-transfer-complete' }); + this._sendMessage({ type: 'text-sent' }); } catch (e) { Logger.error(e); @@ -1251,14 +1248,6 @@ class RTCPeer extends Peer { super._onMessage(message); } - _addFileDigester(header) { - this._digester = new FileDigester({size: header.size, name: header.name, mime: header.mime}, - this._acceptedRequest.totalSize, - this._totalBytesReceived, - fileBlob => this._fileReceived(fileBlob) - ); - } - getConnectionHash() { const localDescriptionLines = this._conn.localDescription.sdp.split("\r\n"); const remoteDescriptionLines = this._conn.remoteDescription.sdp.split("\r\n"); @@ -1352,15 +1341,6 @@ class WSPeer extends Peer { super._onMessage(message); } - _addFileDigester(header) { - this._digester = new FileDigester({size: header.size, name: header.name, mime: header.mime}, - this._acceptedRequest.totalSize, - this._totalBytesReceived, - fileBlob => this._fileReceived(fileBlob), - bytesReceived => this._sendReceiveConfirmation(bytesReceived) - ); - } - _onWsRelay(message) { try { message = JSON.parse(message).message; @@ -1759,7 +1739,7 @@ class FileChunkerWS extends FileChunker { class FileDigester { - constructor(meta, totalSize, totalBytesReceived, fileCompleteCallback, receiveConfirmationCallback = null) { + constructor(meta, totalSize, fileCompleteCallback, receiveConfirmationCallback = null) { this._buffer = []; this._bytesReceived = 0; this._bytesReceivedSinceLastTime = 0; @@ -1768,7 +1748,6 @@ class FileDigester { this._name = meta.name; this._mime = meta.mime; this._totalSize = totalSize; - this._totalBytesReceived = totalBytesReceived; this._fileCompleteCallback = fileCompleteCallback; this._receiveConfimationCallback = receiveConfirmationCallback; } @@ -1784,9 +1763,6 @@ class FileDigester { this._bytesReceivedSinceLastTime = 0; } - this.progress = (this._totalBytesReceived + this._bytesReceived) / this._totalSize; - if (isNaN(this.progress)) this.progress = 1 - if (this._bytesReceived < this._size) return; // we are done From 7c6062e1e07c505d9d09860283211d40e52451a3 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Fri, 9 Feb 2024 02:09:53 +0100 Subject: [PATCH 26/53] Solve "transfer-complete" and "receive-complete" status detection via css instead of adding a new class --- public/scripts/ui.js | 7 +------ public/styles/styles-deferred.css | 10 +++++----- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/public/scripts/ui.js b/public/scripts/ui.js index a58a98f..617dcb2 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -807,16 +807,11 @@ class PeerUI { this.$el.querySelector('.status').innerText = statusName; this._currentStatus = status; - if (status === "transfer-complete" || status === "receive-complete") { - this.$el.classList.remove('blink'); - + if (status.indexOf("-complete") || status === "receive-complete") { this.statusTimeout = setTimeout(() => { this.setProgress(0, null); }, 10000); } - else { - this.$el.classList.add('blink'); - } } _onDrop(e) { diff --git a/public/styles/styles-deferred.css b/public/styles/styles-deferred.css index 12be300..f7f4020 100644 --- a/public/styles/styles-deferred.css +++ b/public/styles/styles-deferred.css @@ -188,12 +188,12 @@ x-peer:not(.type-public-id) .highlight-room-public-id { display: none; } -x-peer:not([status].blink):hover, -x-peer:not([status].blink):focus { +x-peer:is(:not([status]), [status$=-complete]):hover, +x-peer:is(:not([status]), [status$=-complete]):focus { transform: scale(1.05); } -x-peer[status].blink x-icon { +x-peer[status]:not([status$=-complete]) x-icon { box-shadow: none; } @@ -249,7 +249,7 @@ x-peer[status] .device-name { display: none; } -x-peer[status].blink { +x-peer[status]:not([status$=-complete]) { pointer-events: none; } @@ -257,7 +257,7 @@ x-peer { animation: pop 600ms ease-out 1; } -x-peer[status]:not(.blink) .status { +x-peer[status$=-complete] .status { color: var(--primary-color); } From 65936a4d7dd9c8dc7d2680b6d02533237c438b78 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Fri, 9 Feb 2024 03:34:07 +0100 Subject: [PATCH 27/53] Truncate file used by the sw-file-digester.js after processing --- public/scripts/network.js | 23 ++++++++++++++++++++--- public/scripts/sw-file-digester.js | 24 +++++++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index f0986af..5b73cb4 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -1813,6 +1813,13 @@ class FileDigester { }); } + function deleteFile() { + fileWorker.postMessage({ + type: "delete-file", + name: _this._name + }) + } + function onPart(part) { // remove old chunk from buffer _this._buffer[i] = null; @@ -1831,16 +1838,23 @@ class FileDigester { function onFile(file) { _this._buffer = []; - fileWorker.terminate(); _this._fileCompleteCallback(file); + deleteFile(); + } + + function onFileDeleted() { + // File Digestion complete -> Tidy up + fileWorker.terminate(); } function onError(error) { - // an error occurred. Use memory method instead. + // an error occurred. Logger.error(error); Logger.warn('Failed to process file via service-worker. Do not use Firefox private mode to prevent this.') - fileWorker.terminate(); + + // Use memory method instead and tidy up. _this.processFileViaMemory(); + fileWorker.terminate(); } sendPart(this._buffer[i], offset); @@ -1853,6 +1867,9 @@ class FileDigester { case "file": onFile(e.data.file); break; + case "file-deleted": + onFileDeleted(); + break; case "error": onError(e.data.error); break; diff --git a/public/scripts/sw-file-digester.js b/public/scripts/sw-file-digester.js index 0a2bb3b..eaf36ba 100644 --- a/public/scripts/sw-file-digester.js +++ b/public/scripts/sw-file-digester.js @@ -7,6 +7,9 @@ self.addEventListener('message', async e => { case "get-file": await this.onGetFile(e.data.name); break; + case "delete-file": + await this.onDeleteFile(e.data.name); + break; } } catch (e) { @@ -32,7 +35,9 @@ async function onPart(fileName, buffer, offset) { // Write the message to the end of the file. let encodedMessage = new DataView(buffer); accessHandle.write(encodedMessage, { at: offset }); - accessHandle.close(); + + // Always close FileSystemSyncAccessHandle if done. + accessHandle.close(); accessHandle.close(); self.postMessage({type: "part", part: encodedMessage}); encodedMessage = null; @@ -43,6 +48,19 @@ async function onGetFile(fileName) { let file = await fileHandle.getFile(); self.postMessage({type: "file", file: file}); - file = null; - // Todo: delete file from storage +} + +async function onDeleteFile(fileName) { + const accessHandle = await getAccessHandle(fileName); + + // Truncate the file to 0 bytes + accessHandle.truncate(0); + + // Persist changes to disk. + accessHandle.flush(); + + // Always close FileSystemSyncAccessHandle if done. + accessHandle.close(); + + self.postMessage({type: "file-deleted"}); } \ No newline at end of file From 1df8fe258ecfadbed48673c1620cf8117f1837eb Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Tue, 13 Feb 2024 18:23:27 +0100 Subject: [PATCH 28/53] Tidy up zipper functions --- public/scripts/ui.js | 57 ++++++++++++++++----------------------- public/scripts/util.js | 60 ++++++++++++++++++++++++------------------ 2 files changed, 56 insertions(+), 61 deletions(-) diff --git a/public/scripts/ui.js b/public/scripts/ui.js index 617dcb2..c02537c 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -1231,23 +1231,31 @@ class ReceiveFileDialog extends ReceiveDialog { } async _processDataAsZip() { - let zipFileUrl, zipFileName; + let zipObjectUrl = ""; + let zipName = ""; let sendAsZip = false; const tooBigToZip = window.iOS && this._data.totalSize > 256000000; - if (this._data.files.length > 1 && !tooBigToZip) { - zipFileUrl = await this._createZipFile(this._data.files, zipProgress => { - Events.fire('set-progress', { - peerId: this._data.peerId, - progress: zipProgress / this._data.totalSize, - status: 'process' - }) - }); - zipFileName = this._createZipFilename() - sendAsZip = !!zipFileUrl; + if (this._data.files.length > 1 && !tooBigToZip) { + Events.fire('set-progress', { + peerId: this._data.peerId, + progress: 0, + status: 'process' + }); + + zipObjectUrl = await zipper.getObjectUrlOfZipFile(this._data.files,zipProgress => { + Events.fire('set-progress', { + peerId: this._data.peerId, + progress: zipProgress / this._data.totalSize, + status: 'process' + }); + }); + zipName = this._createZipFilename(); + + sendAsZip = !!zipObjectUrl; } - return {sendAsZip, zipFileUrl, zipFileName}; + return {sendAsZip, zipObjectUrl, zipName}; } _setDownloadButtonToZip(zipFileUrl, zipFileName) { @@ -1318,27 +1326,6 @@ class ReceiveFileDialog extends ReceiveDialog { } } - async _createZipFile(files, onProgressCallback) { - try { - let bytesCompleted = 0; - - zipper.createNewZipWriter(); - - for (let i = 0; i < files.length; i++) { - await zipper.addFile(files[i], { - onprogress: (progress) => onProgressCallback(bytesCompleted + progress) - }); - bytesCompleted += files[i].size; - } - - return await zipper.getBlobURL(); - } - catch (e) { - Logger.error(e); - return false; - } - } - _createZipFilename() { let now = new Date(Date.now()); let year = now.getFullYear().toString(); @@ -1373,11 +1360,11 @@ class ReceiveFileDialog extends ReceiveDialog { this.$downloadBtn.removeAttribute('disabled'); this.$downloadBtn.removeAttribute('hidden'); - let {sendAsZip, zipFileUrl, zipFileName} = await this._processDataAsZip(); + let {sendAsZip, zipObjectUrl, zipName} = await this._processDataAsZip(); // If single file or zipping failed -> download files individually -> else download zip if (sendAsZip) { - this._setDownloadButtonToZip(zipFileUrl, zipFileName); + this._setDownloadButtonToZip(zipObjectUrl, zipName); } else { this._setDownloadButtonToFiles(this._data.files); } diff --git a/public/scripts/util.js b/public/scripts/util.js index 7a1a549..6f666bf 100644 --- a/public/scripts/util.js +++ b/public/scripts/util.js @@ -82,39 +82,47 @@ const audioPlayer = (() => { const zipper = (() => { - let zipWriter; return { - createNewZipWriter() { - zipWriter = new zip.ZipWriter(new zip.BlobWriter("application/zip"), { bufferedWrite: true, level: 0 }); - }, - addFile(file, options) { - return zipWriter.add(file.name, new zip.BlobReader(file), options); - }, - async getBlobURL() { - if (zipWriter) { - const blobURL = URL.createObjectURL(await zipWriter.close()); - zipWriter = null; - return blobURL; + async getObjectUrlOfZipFile(files, onZipProgressCallback){ + try { + const zipWriter = new zip.ZipWriter(new zip.BlobWriter("application/zip")); + + let bytesProcessed = 0; + for (let i = 0; i < files.length; i++) { + await zipWriter.add( + files[i].name, + new zip.BlobReader(files[i]), + { + onprogress: (progress) => onZipProgressCallback(bytesProcessed + progress) + } + ); + bytesProcessed += files[i].size; + } + + return URL.createObjectURL(await zipWriter.close()); } - else { - throw new Error("Zip file closed"); - } - }, - async getZipFile(filename = "archive.zip") { - if (zipWriter) { - const file = new File([await zipWriter.close()], filename, {type: "application/zip"}); - zipWriter = null; - return file; - } - else { - throw new Error("Zip file closed"); + catch (e) { + Logger.error(e); + return false; } }, async getEntries(file, options) { - return await (new zip.ZipReader(new zip.BlobReader(file))).getEntries(options); + try { + return await (new zip.ZipReader(new zip.BlobReader(file))).getEntries(options); + } + catch (e) { + Logger.error(e); + return false; + } }, async getData(entry, options) { - return await entry.getData(new zip.BlobWriter(), options); + try { + return await entry.getData(new zip.BlobWriter(), options); + } + catch (e) { + Logger.error(e); + return false; + } }, }; From da558ddceb30d21793390c0befb64ca72df96842 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Tue, 13 Feb 2024 20:14:22 +0100 Subject: [PATCH 29/53] Move beforeunload event to Peer class to include it to the WSPeer; Add reset method to Peer class to prevent returning the "unfinished-transfers" warning when closing the page after a peer has left during transfer --- public/scripts/network.js | 63 ++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 5b73cb4..2154ed1 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -333,13 +333,44 @@ class Peer { // evaluate auto accept this._evaluateAutoAccept(); + + Events.on('beforeunload', e => this._onBeforeUnload(e)); + Events.on('pagehide', _ => this._onPageHide()); } - _onServerSignalMessage(message) {} + _reset() { + this._state = 'idle'; + this._busy = false; + this._chunker = null; + this._pendingRequest = null; + this._acceptedRequest = null; + this._digester = null; + this._filesReceived = []; + this._totalBytesReceived = 0; + } _refresh() {} - _onDisconnected() {} + _disconnect() { + Events.fire('peer-disconnected', this._peerId); + } + + _onDisconnected() { + this._reset(); + } + + _onBeforeUnload(e) { + if (this._busy) { + e.preventDefault(); + return Localization.getTranslation("notifications.unfinished-transfers-warning"); + } + } + + _onPageHide() { + this._disconnect(); + } + + _onServerSignalMessage(message) {} _setIsCaller(isCaller) { this._isCaller = isCaller; @@ -659,14 +690,7 @@ class Peer { _abortTransfer() { Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); - this._state = 'idle'; - this._busy = false; - this._chunker = null; - this._pendingRequest = null; - this._acceptedRequest = null; - this._digester = null; - this._filesReceived = []; - this._totalBytesReceived = 0; + this._reset(); } _onChunkReceived(chunk) { @@ -886,9 +910,6 @@ class RTCPeer extends Peer { this.pendingInboundMessages = []; this.pendingOutboundMessages = []; - Events.on('beforeunload', e => this._onBeforeUnload(e)); - Events.on('pagehide', _ => this._onPageHide()); - this._connect(); } @@ -1137,9 +1158,6 @@ class RTCPeer extends Peer { } } - _disconnect() { - Events.fire('peer-disconnected', this._peerId); - } _refresh() { Events.fire('peer-connecting', this._peerId); @@ -1149,6 +1167,7 @@ class RTCPeer extends Peer { } _onDisconnected() { + super._onDisconnected(); this._closeChannelAndConnection(); } @@ -1184,17 +1203,6 @@ class RTCPeer extends Peer { this.remoteIceCandidatesReceived = false; } - _onBeforeUnload(e) { - if (this._busy) { - e.preventDefault(); - return Localization.getTranslation("notifications.unfinished-transfers-warning"); - } - } - - _onPageHide() { - this._disconnect(); - } - _sendMessage(message) { if (!this._stable() || this.pendingOutboundMessages.length > 0) { // queue messages if not connected OR if connected AND queue is not empty @@ -1368,6 +1376,7 @@ class WSPeer extends Peer { } _onDisconnected() { + super._onDisconnected(); this.signalSuccessful = false; } From 7c471910eff3eaab0148759a07e892a32d1ce24f Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Tue, 13 Feb 2024 21:01:49 +0100 Subject: [PATCH 30/53] Tidy up Peer classes --- public/lang/en.json | 3 +- public/scripts/network.js | 556 +++++++++++++++++++++----------------- 2 files changed, 308 insertions(+), 251 deletions(-) diff --git a/public/lang/en.json b/public/lang/en.json index 694dc18..f91af81 100644 --- a/public/lang/en.json +++ b/public/lang/en.json @@ -163,7 +163,8 @@ "rate-limit-join-key": "Rate limit reached. Wait 10 seconds and try again.", "selected-peer-left": "Selected peer left", "error-sharing-size": "Files too big to be shared. They can be downloaded instead.", - "error-sharing-default": "Error while sharing. It can be downloaded instead." + "error-sharing-default": "Error while sharing. It can be downloaded instead.", + "ram-exceed-ios": "File is bigger than 250 MB and will crash the page on iOS. Use https to prevent this." }, "document-titles": { "file-received": "File Received", diff --git a/public/scripts/network.js b/public/scripts/network.js index 2154ed1..a6d36de 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -318,6 +318,13 @@ class ServerConnection { class Peer { + static STATE_IDLE = 'idle'; + static STATE_PREPARE = 'prepare'; + static STATE_TRANSFER_REQUEST_SENT = 'transfer-request-sent'; + static STATE_RECEIVE_PROCEEDING = 'receive-proceeding'; + static STATE_TRANSFER_PROCEEDING = 'transfer-proceeding'; + static STATE_TEXT_SENT = 'text-sent'; + constructor(serverConnection, isCaller, peerId, roomType, roomId) { this._server = serverConnection; this._isCaller = isCaller; @@ -329,7 +336,7 @@ class Peer { this._filesQueue = []; this._busy = false; - this._state = 'idle'; // 'idle', 'prepare', 'wait', 'receive', 'transfer', 'text-sent' + this._state = Peer.STATE_IDLE; // evaluate auto accept this._evaluateAutoAccept(); @@ -339,14 +346,19 @@ class Peer { } _reset() { - this._state = 'idle'; + this._state = Peer.STATE_IDLE; this._busy = false; + + // tidy up sender + this._filesRequested = null; this._chunker = null; + + // tidy up receiver this._pendingRequest = null; this._acceptedRequest = null; + this._totalBytesReceived = 0; this._digester = null; this._filesReceived = []; - this._totalBytesReceived = 0; } _refresh() {} @@ -376,14 +388,6 @@ class Peer { this._isCaller = isCaller; } - _sendMessage(message) {} - - _sendData(data) {} - - _sendDisplayName(displayName) { - this._sendMessage({type: 'display-name-changed', displayName: displayName}); - } - _isSameBrowser() { return BrowserTabsConnector.peerIsSameBrowser(this._peerId); } @@ -473,37 +477,131 @@ class Peer { } _onPeerConnected() { - this._sendCurrentState(); + this._sendState(); } - _sendCurrentState() { + _sendMessage(message) {} + + _sendData(data) {} + + _onMessage(message) { + switch (message.type) { + case 'display-name-changed': + this._onDisplayNameChanged(message); + break; + case 'state': + this._onState(message.state); + break; + case 'transfer-request': + this._onTransferRequest(message); + break; + case 'transfer-request-response': + this._onTransferRequestResponse(message); + break; + case 'transfer-header': + this._onTransferHeader(message); + break; + case 'receive-progress': + this._onReceiveProgress(message.progress); + break; + case 'receive-confirmation': + this._onReceiveConfirmation(message.bytesReceived); + break; + case 'resend-request': + this._onResendRequest(message.offset); + break; + case 'file-receive-complete': + this._onFileReceiveComplete(message); + break; + case 'text': + this._onText(message); + break; + case 'text-receive-complete': + this._onTextReceiveComplete(); + break; + default: + Logger.warn('RTC: Unknown message type:', message.type); + } + } + + _sendDisplayName(displayName) { + this._sendMessage({type: 'display-name-changed', displayName: displayName}); + } + + _onDisplayNameChanged(message) { + const displayNameHasChanged = message.displayName !== this._displayName; + + if (!message.displayName || !displayNameHasChanged) return; + + this._displayName = message.displayName; + + const roomSecret = this._getPairSecret(); + + if (roomSecret) { + PersistentStorage + .updateRoomSecretDisplayName(roomSecret, message.displayName) + .then(roomSecretEntry => { + Logger.debug(`Successfully updated DisplayName for roomSecretEntry ${roomSecretEntry.key}`); + }) + } + + Events.fire('peer-display-name-changed', {peerId: this._peerId, displayName: message.displayName}); + Events.fire('notify-peer-display-name-changed', this._peerId); + } + + _sendState() { this._sendMessage({type: 'state', state: this._state}) } - _onReceiveState(peerState) { - if (this._state === "receive") { - if (peerState !== "transfer" || !this._digester) { - this._abortTransfer(); - return; - } - // Reconnection during receiving of file. Send request for restart - const offset = this._digester._bytesReceived; - this._sendResendRequest(offset); - return + _onState(peerState) { + if (this._state === Peer.STATE_RECEIVE_PROCEEDING) { + this._onStateReceiver(peerState); } - - if (this._state === "transfer" && peerState !== "receive") { - this._abortTransfer(); - return; + else if (this._state === Peer.STATE_TRANSFER_PROCEEDING) { + this._onStateSender(peerState); } } - async requestFileTransfer(files) { + _onStateSender(peerState) { + // this peer is sender + if (peerState !== Peer.STATE_RECEIVE_PROCEEDING) { + this._abortTransfer(); + } + } + + _onStateReceiver(peerState) { + // this peer is receiver + switch (peerState) { + case Peer.STATE_TRANSFER_REQUEST_SENT: + // Reconnection during file transfer request. Send acceptance again. + this._sendTransferRequestResponse(true); + break; + case Peer.STATE_TRANSFER_PROCEEDING: + // Reconnection during receiving of file. Send request for resending + if (!this._digester) { + this._abortTransfer(); + } + const offset = this._digester._bytesReceived; + this._sendResendRequest(offset); + break; + default: + this._abortTransfer(); + } + } + + _abortTransfer() { + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); + this._reset(); + } + + // File Sender Only + async _sendFileTransferRequest(files) { + this._state = Peer.STATE_PREPARE; + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'prepare'}); + let header = []; let totalSize = 0; - let imagesOnly = true - this._state = 'prepare'; - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'prepare'}); + let imagesOnly = true; for (let i = 0; i < files.length; i++) { header.push({ @@ -515,7 +613,7 @@ class Peer { if (files[i].type.split('/')[0] !== 'image') imagesOnly = false; } - let dataUrl = ''; + let dataUrl = ""; if (files[0].type.split('/')[0] === 'image') { try { dataUrl = await getThumbnailAsDataUrl(files[0], 400, null, 0.9); @@ -534,11 +632,33 @@ class Peer { imagesOnly: imagesOnly, thumbnailDataUrl: dataUrl }); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'wait'}) - this._state = 'wait'; + this._state = Peer.STATE_TRANSFER_REQUEST_SENT; + } + + _onTransferRequestResponse(message) { + if (this._state !== Peer.STATE_TRANSFER_REQUEST_SENT) { + this._sendState(); + return; + } + + if (!message.accepted) { + if (message.reason === 'ram-exceed-ios') { + Events.fire('notify-user', Localization.getTranslation('notifications.ram-exceed-ios')); + } + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); + this._reset(); + return; + } + + Events.fire('file-transfer-accepted'); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'transfer'}); + this._state = Peer.STATE_TRANSFER_PROCEEDING; + this._sendFiles(); } - sendFiles() { + _sendFiles() { for (let i = 0; i < this._filesRequested.length; i++) { this._filesQueue.push(this._filesRequested[i]); } @@ -562,20 +682,10 @@ class Peer { }); } - // Is overwritten in expanding classes _sendFile(file) {} - _sendResendRequest(offset) { - this._sendMessage({ type: 'resend-request', offset: offset }); - } - - _sendTransferAbortion() { - this._sendMessage({type: 'file-transfer-complete', success: false}); - } - - _onResendRequest(offset) { - if (this._state !== 'transfer' || !this._chunker) { + if (this._state !== Peer.STATE_TRANSFER_PROCEEDING || !this._chunker) { this._sendTransferAbortion(); return; } @@ -583,66 +693,77 @@ class Peer { this._chunker._resendFromOffset(offset); } - _sendProgress(progress) { - this._sendMessage({ type: 'receive-progress', progress: progress }); - } - - _onData(data) { - this._onChunkReceived(data); - } - - _onMessage(message) { - switch (message.type) { - case 'state': - this._onReceiveState(message.state); - break; - case 'transfer-request': - this._onTransferRequest(message); - break; - case 'transfer-response': - this._onTransferResponse(message); - break; - case 'transfer-header': - this._onTransferHeader(message); - break; - case 'receive-progress': - this._onReceiveProgress(message.progress); - break; - case 'receive-confirmation': - this._onReceiveConfirmation(message.bytesReceived); - break; - case 'resend-request': - this._onResendRequest(message.offset); - break; - case 'file-transfer-complete': - this._onFileTransferComplete(message); - break; - case 'text': - this._onTextReceived(message); - break; - case 'text-sent': - this._onTextSent(); - break; - case 'display-name-changed': - this._onDisplayNameChanged(message); - break; - default: - Logger.warn('RTC: Unknown message type:', message.type); + _onReceiveProgress(progress) { + if (this._state !== Peer.STATE_TRANSFER_PROCEEDING) { + this._sendState(); + return; } + + Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'transfer'}); } + _onReceiveConfirmation(bytesReceived) { + if (!this._chunker || this._state !== Peer.STATE_TRANSFER_PROCEEDING) { + this._sendState(); + return; + } + this._chunker._onReceiveConfirmation(bytesReceived); + } + + _onFileReceiveComplete(message) { + if (this._state !== Peer.STATE_TRANSFER_PROCEEDING) { + this._sendState(); + return; + } + + this._chunker = null; + + if (!message.success) { + Logger.warn('File could not be sent'); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); + this._reset(); + return; + } + + Logger.log(`File sent.\n\nSize: ${message.size} MB\tDuration: ${message.duration} s\tSpeed: ${message.speed} MB/s`); + + if (this._filesQueue.length) { + this._dequeueFile(); + return; + } + + // No more files in queue. Transfer is complete + this._reset(); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'transfer-complete'}); + Events.fire('notify-user', Localization.getTranslation("notifications.file-transfer-completed")); + Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app + } + + // File Receiver Only _onTransferRequest(request) { if (this._pendingRequest) { // Only accept one request at a time per peer - this._sendMessage({type: 'transfer-response', accepted: false}); + this._sendTransferRequestResponse(false); return; } this._pendingRequest = request; + if (!window.Worker || !window.isSecureContext) { + // Each file must be loaded into RAM completely which might lead to a page crash (Memory limit iOS Safari: ~380 MB) + Logger.warn('Big file transfers might exceed the RAM of the receiver. Use a secure context (https) to prevent this.'); + } + + if (window.iOS && this._filesTooBigForIos(request.header)) { + // Page will crash. Decline request + Events.fire('notify-user', Localization.getTranslation('notifications.ram-exceed-ios')); + this._sendTransferRequestResponse(false, 'ram-exceed-ios'); + return; + } + if (this._autoAccept) { // auto accept if set via Edit Paired Devices Dialog - this._respondToFileTransferRequest(true); + this._sendTransferRequestResponse(true); return; } @@ -653,22 +774,40 @@ class Peer { }); } - _respondToFileTransferRequest(accepted) { - this._sendMessage({type: 'transfer-response', accepted: accepted}); + _filesTooBigForIos(files) { + if (window.Worker && window.isSecureContext) { + return false; + } + for (let i = 0; i < files.length; i++) { + if (files[i].size > 250000000) { + return true; + } + } + return false; + } + + _sendTransferRequestResponse(accepted, reason = null) { + let message = {type: 'transfer-request-response', accepted: accepted}; + + if (reason) { + message.reason = reason; + } + + this._sendMessage(message); + if (accepted) { - this._state = 'receive'; + this._state = Peer.STATE_RECEIVE_PROCEEDING; this._busy = true; this._acceptedRequest = this._pendingRequest; this._lastProgress = 0; this._totalBytesReceived = 0; this._filesReceived = []; } - this._pendingRequest = null; } _onTransferHeader(header) { - if (this._state !== "receive") { - this._sendCurrentState(); + if (this._state !== Peer.STATE_RECEIVE_PROCEEDING) { + this._sendState(); return; } this._timeStart = Date.now(); @@ -678,7 +817,6 @@ class Peer { _addFileDigester(header) { this._digester = new FileDigester({size: header.size, name: header.name, mime: header.mime}, - this._acceptedRequest.totalSize, fileBlob => this._fileReceived(fileBlob), bytesReceived => this._sendReceiveConfirmation(bytesReceived) ); @@ -688,14 +826,21 @@ class Peer { this._sendMessage({type: 'receive-confirmation', bytesReceived: bytesReceived}); } - _abortTransfer() { - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); - this._reset(); + _sendResendRequest(offset) { + this._sendMessage({ type: 'resend-request', offset: offset }); + } + + _sendTransferAbortion() { + this._sendMessage({type: 'file-receive-complete', success: false}); + } + + _onData(data) { + this._onChunkReceived(data); } _onChunkReceived(chunk) { - if(this._state !== 'receive' || !this._digester || !(chunk.byteLength || chunk.size)) { - this._sendCurrentState(); + if(this._state !== Peer.STATE_RECEIVE_PROCEEDING || !this._digester || !(chunk.byteLength || chunk.size)) { + this._sendState(); return; } @@ -720,21 +865,26 @@ class Peer { } } - _onReceiveProgress(progress) { - if (this._state !== 'transfer') { - this._sendCurrentState(); - return; - } - - Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'transfer'}); + _sendProgress(progress) { + this._sendMessage({ type: 'receive-progress', progress: progress }); } - _onReceiveConfirmation(bytesReceived) { - if (!this._chunker || this._state !== 'transfer') { - this._sendCurrentState(); + async _fileReceived(file) { + if (!this._fitsHeader(file)) { + this._abortTransfer(); + Events.fire('notify-user', Localization.getTranslation("notifications.files-incorrect")); + Logger.error("Received files differ from requested files. Abort!"); return; } - this._chunker._onReceiveConfirmation(bytesReceived); + + // File transfer complete + this._singleFileReceiveComplete(file); + + if (this._acceptedRequest.header.length) return; + + // We are done receiving + Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'receive'}); + this._allFilesReceiveComplete(); } _fitsHeader(file) { @@ -750,7 +900,7 @@ class Peer { return sameSize && sameName; } - _singleFileTransferComplete(file) { + _singleFileReceiveComplete(file) { this._digester = null; this._totalBytesReceived += file.size; @@ -761,142 +911,52 @@ class Peer { // Log speed from request to receive Logger.log(`File received.\n\nSize: ${size} MB\tDuration: ${duration} s\tSpeed: ${speed} MB/s`); - this._sendMessage({type: 'file-transfer-complete', success: true, duration: duration, size: size, speed: speed}); - // include for compatibility with 'Snapdrop & PairDrop for Android' app Events.fire('file-received', file); this._filesReceived.push(file); + + this._sendMessage({type: 'file-receive-complete', success: true, duration: duration, size: size, speed: speed}); } - _allFilesTransferComplete() { - this._state = 'idle'; + _allFilesReceiveComplete() { Events.fire('files-received', { peerId: this._peerId, files: this._filesReceived, imagesOnly: this._acceptedRequest.imagesOnly, totalSize: this._acceptedRequest.totalSize }); - this._filesReceived = []; - this._acceptedRequest = null; - this._busy = false; + this._reset(); } - async _fileReceived(file) { - if (!this._fitsHeader(file)) { - this._abortTransfer(); - Events.fire('notify-user', Localization.getTranslation("notifications.files-incorrect")); - Logger.error("Received files differ from requested files. Abort!"); - return; - } - - // File transfer complete - this._singleFileTransferComplete(file); - - if (this._acceptedRequest.header.length) return; - - // We are done receiving - Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'receive'}); - this._allFilesTransferComplete(); - } - - _onFileTransferComplete(message) { - if (this._state !== "transfer") { - this._sendCurrentState(); - return; - } - - this._chunker = null; - - if (!message.success) { - Logger.warn('File could not be sent'); - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); - - this._state = 'idle'; - return; - } - - Logger.log(`File sent.\n\nSize: ${message.size} MB\tDuration: ${message.duration} s\tSpeed: ${message.speed} MB/s`); - - if (this._filesQueue.length) { - this._dequeueFile(); - return; - } - - // No more files in queue. Transfer is complete - this._state = 'idle'; - this._busy = false; - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'transfer-complete'}); - Events.fire('notify-user', Localization.getTranslation("notifications.file-transfer-completed")); - Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app - } - - _onTransferResponse(message) { - if (this._state !== 'wait') { - this._sendCurrentState(); - return; - } - - if (!message.accepted) { - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); - this._state = 'idle'; - this._filesRequested = null; - return; - } - - Events.fire('file-transfer-accepted'); - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'transfer'}); - this._state = 'transfer'; - this.sendFiles(); - } - - _onTextSent() { - if (this._state !== 'text-sent') { - this._sendCurrentState(); - return; - } - this._state = 'idle'; - Events.fire('notify-user', Localization.getTranslation("notifications.message-transfer-completed")); - } - - sendText(text) { - this._state = 'text-sent'; + // Message Sender Only + _sendText(text) { + this._state = Peer.STATE_TEXT_SENT; const unescaped = btoa(unescape(encodeURIComponent(text))); this._sendMessage({ type: 'text', text: unescaped }); } - _onTextReceived(message) { + _onTextReceiveComplete() { + if (this._state !== Peer.STATE_TEXT_SENT) { + this._sendState(); + return; + } + this._reset(); + Events.fire('notify-user', Localization.getTranslation("notifications.message-transfer-completed")); + } + + // Message Receiver Only + _onText(message) { if (!message.text) return; try { const escaped = decodeURIComponent(escape(atob(message.text))); Events.fire('text-received', { text: escaped, peerId: this._peerId }); - this._sendMessage({ type: 'text-sent' }); + this._sendMessage({ type: 'text-receive-complete' }); } catch (e) { Logger.error(e); } } - - _onDisplayNameChanged(message) { - const displayNameHasChanged = message.displayName !== this._displayName; - - if (!message.displayName || !displayNameHasChanged) return; - - this._displayName = message.displayName; - - const roomSecret = this._getPairSecret(); - - if (roomSecret) { - PersistentStorage - .updateRoomSecretDisplayName(roomSecret, message.displayName) - .then(roomSecretEntry => { - Logger.debug(`Successfully updated DisplayName for roomSecretEntry ${roomSecretEntry.key}`); - }) - } - - Events.fire('peer-display-name-changed', {peerId: this._peerId, displayName: message.displayName}); - Events.fire('notify-peer-display-name-changed', this._peerId); - } } class RTCPeer extends Peer { @@ -907,7 +967,7 @@ class RTCPeer extends Peer { this.rtcSupported = true; this.rtcConfig = rtcConfig; - this.pendingInboundMessages = []; + this.pendingInboundServerSignalMessages = []; this.pendingOutboundMessages = []; this._connect(); @@ -963,7 +1023,7 @@ class RTCPeer extends Peer { this._openMessageChannel(); this._openDataChannel(); - this._evaluatePendingInboundMessages() + this._evaluatePendingInboundServerMessages() .then((count) => { if (count) { Logger.debug("Pending inbound messages evaluated."); @@ -1082,7 +1142,6 @@ class RTCPeer extends Peer { Logger.error(e.error); } - async _handleLocalDescription(localDescription) { await this._conn.setLocalDescription(localDescription); @@ -1128,12 +1187,15 @@ class RTCPeer extends Peer { await this._conn.addIceCandidate(candidate); } - async _evaluatePendingInboundMessages() { + async _evaluatePendingInboundServerMessages() { let inboundMessagesEvaluatedCount = 0; - while (this.pendingInboundMessages.length > 0) { - const message = this.pendingInboundMessages.shift(); + while (this.pendingInboundServerSignalMessages.length > 0) { + const message = this.pendingInboundServerSignalMessages.shift(); + Logger.debug("Evaluate pending inbound message:", message); + await this._onServerSignalMessage(message); + inboundMessagesEvaluatedCount++; } return inboundMessagesEvaluatedCount; @@ -1141,7 +1203,7 @@ class RTCPeer extends Peer { async _onServerSignalMessage(message) { if (this._conn === null) { - this.pendingInboundMessages.push(message); + this.pendingInboundServerSignalMessages.push(message); return; } @@ -1242,7 +1304,7 @@ class RTCPeer extends Peer { ); this._chunker._readChunk(); this._sendHeader(file); - this._state = 'transfer'; + this._state = Peer.STATE_TRANSFER_PROCEEDING; } _onMessage(message) { @@ -1452,11 +1514,11 @@ class PeersManager { if (this._peerExists(peerId)) { this._refreshPeer(isCaller, peerId, roomType, roomId); } else { - this.createPeer(isCaller, peerId, roomType, roomId, rtcSupported); + this._createPeer(isCaller, peerId, roomType, roomId, rtcSupported); } } - createPeer(isCaller, peerId, roomType, roomId, rtcSupported) { + _createPeer(isCaller, peerId, roomType, roomId, rtcSupported) { if (window.isRtcSupported && rtcSupported) { this.peers[peerId] = new RTCPeer(this._server, isCaller, peerId, roomType, roomId, this._wsConfig.rtcConfig); } @@ -1490,16 +1552,16 @@ class PeersManager { } _onRespondToFileTransferRequest(detail) { - this.peers[detail.to]._respondToFileTransferRequest(detail.accepted); + this.peers[detail.to]._sendTransferRequestResponse(detail.accepted); } async _onFilesSelected(message) { let files = await mime.addMissingMimeTypesToFiles(message.files); - await this.peers[message.to].requestFileTransfer(files); + await this.peers[message.to]._sendFileTransferRequest(files); } _onSendText(message) { - this.peers[message.to].sendText(message.text); + this.peers[message.to]._sendText(message.text); } _onPeerLeft(message) { @@ -1748,7 +1810,7 @@ class FileChunkerWS extends FileChunker { class FileDigester { - constructor(meta, totalSize, fileCompleteCallback, receiveConfirmationCallback = null) { + constructor(meta, fileCompleteCallback, sendReceiveConfirmationCallback) { this._buffer = []; this._bytesReceived = 0; this._bytesReceivedSinceLastTime = 0; @@ -1756,9 +1818,8 @@ class FileDigester { this._size = meta.size; this._name = meta.name; this._mime = meta.mime; - this._totalSize = totalSize; this._fileCompleteCallback = fileCompleteCallback; - this._receiveConfimationCallback = receiveConfirmationCallback; + this._sendReceiveConfimationCallback = sendReceiveConfirmationCallback; } unchunk(chunk) { @@ -1766,15 +1827,15 @@ class FileDigester { this._bytesReceived += chunk.byteLength || chunk.size; this._bytesReceivedSinceLastTime += chunk.byteLength || chunk.size; - // If more than half of maxBytesWithoutConfirmation received -> request more - if (this._receiveConfimationCallback && 2 * this._bytesReceivedSinceLastTime > this._maxBytesWithoutConfirmation) { - this._receiveConfimationCallback(this._bytesReceived); + // If more than half of maxBytesWithoutConfirmation received -> send confirmation + if (2 * this._bytesReceivedSinceLastTime > this._maxBytesWithoutConfirmation) { + this._sendReceiveConfimationCallback(this._bytesReceived); this._bytesReceivedSinceLastTime = 0; } if (this._bytesReceived < this._size) return; - // we are done + // We are done receiving. Preferably use a file worker to process the file to prevent exceeding of available RAM if (!window.Worker && !window.isSecureContext) { this.processFileViaMemory(); return; @@ -1785,11 +1846,6 @@ class FileDigester { processFileViaMemory() { // Loads complete file into RAM which might lead to a page crash (Memory limit iOS Safari: ~380 MB) - if (window.iOS && this._totalSize > 250000000) { - alert('File is bigger than 250 MB and might crash the page on iOS. To be able to use a more efficient method use https and avoid private tabs as they have restricted functionality.') - } - Logger.warn('Big file transfers might exceed the RAM of the receiver. Use a secure context (https) to prevent this.'); - const file = new File(this._buffer, this._name, { type: this._mime, lastModified: new Date().getTime() From a98499ea5a26d0dd05c7794dbf1b59b285096d32 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 15 Feb 2024 00:38:30 +0100 Subject: [PATCH 31/53] Move header comparison to _onTransferHeader function as there is no benefit in doing it after file is received --- public/scripts/network.js | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index a6d36de..03fe35f 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -810,6 +810,14 @@ class Peer { this._sendState(); return; } + + if (!this._fitsAcceptedHeader(header)) { + this._abortTransfer(); + Events.fire('notify-user', Localization.getTranslation("notifications.files-incorrect")); + Logger.error("Received files differ from requested files. Abort!"); + return; + } + this._timeStart = Date.now(); this._addFileDigester(header); @@ -870,34 +878,36 @@ class Peer { } async _fileReceived(file) { - if (!this._fitsHeader(file)) { - this._abortTransfer(); - Events.fire('notify-user', Localization.getTranslation("notifications.files-incorrect")); - Logger.error("Received files differ from requested files. Abort!"); - return; - } - // File transfer complete this._singleFileReceiveComplete(file); - if (this._acceptedRequest.header.length) return; + // If less files received than header accepted -> wait for next file + if (this._filesReceived.length < this._acceptedRequest.header.length) return; // We are done receiving Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'receive'}); this._allFilesReceiveComplete(); } - _fitsHeader(file) { + _fitsAcceptedHeader(header) { if (!this._acceptedRequest) { return false; } - // Check if file fits to header - const acceptedHeader = this._acceptedRequest.header.shift(); + const positionFile = this._filesReceived.length; - const sameSize = file.size === acceptedHeader.size; - const sameName = file.name === acceptedHeader.name - return sameSize && sameName; + if (positionFile > this._acceptedRequest.header.length - 1) { + return false; + } + + // Check if file header fits + const acceptedHeader = this._acceptedRequest.header[positionFile]; + + const sameSize = header.size === acceptedHeader.size; + const sameType = header.mime === acceptedHeader.mime; + const sameName = header.name === acceptedHeader.name; + + return sameSize && sameType && sameName; } _singleFileReceiveComplete(file) { From 42bd71a3dc9204df68cc7c0fe983937497328391 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 15 Feb 2024 01:33:06 +0100 Subject: [PATCH 32/53] Add error status and check if too many bytes are received --- public/lang/en.json | 3 ++- public/scripts/network.js | 30 ++++++++++++++++++------------ public/scripts/ui.js | 14 +++++++++----- public/styles/styles-deferred.css | 10 +++++++--- public/styles/styles-main.css | 1 + 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/public/lang/en.json b/public/lang/en.json index f91af81..06ec120 100644 --- a/public/lang/en.json +++ b/public/lang/en.json @@ -185,6 +185,7 @@ "transferring": "Sending…", "receiving": "Receiving…", "transfer-complete": "Sent", - "receive-complete": "Received" + "receive-complete": "Received", + "error": "Error" } } diff --git a/public/scripts/network.js b/public/scripts/network.js index 03fe35f..9de6502 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -590,7 +590,7 @@ class Peer { } _abortTransfer() { - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'error'}); this._reset(); } @@ -847,23 +847,24 @@ class Peer { } _onChunkReceived(chunk) { - if(this._state !== Peer.STATE_RECEIVE_PROCEEDING || !this._digester || !(chunk.byteLength || chunk.size)) { + if (this._state !== Peer.STATE_RECEIVE_PROCEEDING || !this._digester || !chunk.byteLength) { this._sendState(); return; } - this._digester.unchunk(chunk); - - let progress = (this._totalBytesReceived + this._digester._bytesReceived) / this._acceptedRequest.totalSize; - - if (isNaN(progress)) progress = 1 - - if (progress > 1) { + try { + this._digester.unchunk(chunk); + } + catch (e) { this._abortTransfer(); - Logger.error("Too many bytes received. Abort!"); + Logger.error(e); return; } + let progress = this._digester + ? (this._totalBytesReceived + this._digester._bytesReceived) / this._acceptedRequest.totalSize + : 1; + Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'receive'}); // occasionally notify sender about our progress @@ -1834,8 +1835,12 @@ class FileDigester { unchunk(chunk) { this._buffer.push(chunk); - this._bytesReceived += chunk.byteLength || chunk.size; - this._bytesReceivedSinceLastTime += chunk.byteLength || chunk.size; + this._bytesReceived += chunk.byteLength; + this._bytesReceivedSinceLastTime += chunk.byteLength; + + if (this._bytesReceived > this._size) { + throw new Error("Too many bytes received. Abort!"); + } // If more than half of maxBytesWithoutConfirmation received -> send confirmation if (2 * this._bytesReceivedSinceLastTime > this._maxBytesWithoutConfirmation) { @@ -1843,6 +1848,7 @@ class FileDigester { this._bytesReceivedSinceLastTime = 0; } + // File not completely received -> Wait for next chunk. if (this._bytesReceived < this._size) return; // We are done receiving. Preferably use a file worker to process the file to prevent exceeding of available RAM diff --git a/public/scripts/ui.js b/public/scripts/ui.js index c02537c..b5a755c 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -729,7 +729,8 @@ class PeerUI { this._progressQueue.unshift({progress: progress, status: status}); this.setProgress(0.5, status); return; - } else if (progressSpillsOverFull) { + } + else if (progressSpillsOverFull) { this._progressQueue.unshift({progress: progress, status: status}); this.setProgress(1, status); return; @@ -755,7 +756,8 @@ class PeerUI { this.$progress.classList.remove('animate'); this.$progress.classList.remove('over50'); this.$progress.classList.add('animate'); - } else if (this._currentProgress === 0.5) { + } + else if (this._currentProgress === 0.5) { this.$progress.classList.remove('animate'); this.$progress.classList.add('over50'); this.$progress.classList.add('animate'); @@ -763,7 +765,8 @@ class PeerUI { if (this._currentProgress < progress) { this.$progress.classList.add('animate'); - } else { + } + else { this.$progress.classList.remove('animate'); } @@ -800,14 +803,15 @@ class PeerUI { "process": Localization.getTranslation("peer-ui.processing"), "wait": Localization.getTranslation("peer-ui.waiting"), "transfer-complete": Localization.getTranslation("peer-ui.transfer-complete"), - "receive-complete": Localization.getTranslation("peer-ui.receive-complete") + "receive-complete": Localization.getTranslation("peer-ui.receive-complete"), + "error": Localization.getTranslation("peer-ui.error") }[status]; this.$el.setAttribute('status', status); this.$el.querySelector('.status').innerText = statusName; this._currentStatus = status; - if (status.indexOf("-complete") || status === "receive-complete") { + if (["transfer-complete", "receive-complete", "error"].includes(status)) { this.statusTimeout = setTimeout(() => { this.setProgress(0, null); }, 10000); diff --git a/public/styles/styles-deferred.css b/public/styles/styles-deferred.css index f7f4020..367b7a9 100644 --- a/public/styles/styles-deferred.css +++ b/public/styles/styles-deferred.css @@ -188,8 +188,8 @@ x-peer:not(.type-public-id) .highlight-room-public-id { display: none; } -x-peer:is(:not([status]), [status$=-complete]):hover, -x-peer:is(:not([status]), [status$=-complete]):focus { +x-peer:is(:not([status]), [status$=-complete], [status=error]):hover, +x-peer:is(:not([status]), [status$=-complete], [status=error]):focus { transform: scale(1.05); } @@ -249,7 +249,7 @@ x-peer[status] .device-name { display: none; } -x-peer[status]:not([status$=-complete]) { +x-peer[status]:not([status$=-complete]):not([status=error]) { pointer-events: none; } @@ -261,6 +261,10 @@ x-peer[status$=-complete] .status { color: var(--primary-color); } +x-peer[status=error] .status { + color: var(--error-color); +} + @keyframes pop { 0% { transform: scale(0.7); diff --git a/public/styles/styles-main.css b/public/styles/styles-main.css index b274bd6..6652014 100644 --- a/public/styles/styles-main.css +++ b/public/styles/styles-main.css @@ -921,6 +921,7 @@ x-peers:empty~x-instructions { body { /* Constant colors */ --primary-color: #4285f4; + --error-color: #ff6b6b; --paired-device-color: #00a69c; --public-room-color: #ed9d01; --accent-color: var(--primary-color); From c0e5b66d41d58955bb76d38195d1c100e21cd3d6 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Wed, 14 Feb 2024 17:27:01 +0100 Subject: [PATCH 33/53] Fix share menu error detection on iOS --- public/scripts/ui.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/public/scripts/ui.js b/public/scripts/ui.js index b5a755c..b03db22 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -1215,6 +1215,14 @@ class ReceiveFileDialog extends ReceiveDialog { .catch(async err => { Logger.error(err); + if (err.name === 'AbortError' && err.message === 'Abort due to cancellation of share.') { + return; + } + else if (err.name === 'NotAllowedError' && err.message === 'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.') { + return; + } + + // Fallback to download if (err.name === 'AbortError' && err.message === 'Abort due to error while reading files.') { Events.fire('notify-user', Localization.getTranslation("notifications.error-sharing-size")); } @@ -1222,7 +1230,6 @@ class ReceiveFileDialog extends ReceiveDialog { Events.fire('notify-user', Localization.getTranslation("notifications.error-sharing-default")); } - // Fallback to download this._tidyUpButtons(); await this._setupDownload() }); From aacf24c31f31af97374ee6eca54ba59a3310b6e5 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Wed, 14 Feb 2024 17:40:54 +0100 Subject: [PATCH 34/53] Fix reconnecting by always accepting new ice candidates --- public/scripts/network.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 9de6502..6798a00 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -1173,12 +1173,9 @@ class RTCPeer extends Peer { } _handleLocalCandidate(candidate) { - if (this.localIceCandidatesSent) return; - Logger.debug("RTC: Local candidate created", candidate); if (candidate === null) { - this.localIceCandidatesSent = true; return; } @@ -1186,12 +1183,9 @@ class RTCPeer extends Peer { } async _handleRemoteCandidate(candidate) { - if (this.remoteIceCandidatesReceived) return; - Logger.debug("RTC: Received remote candidate", candidate); if (candidate === null) { - this.remoteIceCandidatesReceived = true; return; } @@ -1272,8 +1266,6 @@ class RTCPeer extends Peer { this._conn.close(); this._conn = null; } - this.localIceCandidatesSent = false; - this.remoteIceCandidatesReceived = false; } _sendMessage(message) { From 90f10910aae7c820c7db24a625ac97d670595486 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 15 Feb 2024 02:36:37 +0100 Subject: [PATCH 35/53] Fix _fileReceived getting called twice --- public/scripts/network.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 6798a00..01e38bf 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -878,7 +878,7 @@ class Peer { this._sendMessage({ type: 'receive-progress', progress: progress }); } - async _fileReceived(file) { + _fileReceived(file) { // File transfer complete this._singleFileReceiveComplete(file); @@ -912,7 +912,10 @@ class Peer { } _singleFileReceiveComplete(file) { + this._digester._fileCompleteCallback = null; + this._digester._sendReceiveConfimationCallback = null; this._digester = null; + this._totalBytesReceived += file.size; const duration = (Date.now() - this._timeStart) / 1000; // s @@ -1894,9 +1897,6 @@ class FileDigester { } function onPart(part) { - // remove old chunk from buffer - _this._buffer[i] = null; - if (i < _this._buffer.length - 1) { // process next chunk offset += part.byteLength; @@ -1925,9 +1925,9 @@ class FileDigester { Logger.error(error); Logger.warn('Failed to process file via service-worker. Do not use Firefox private mode to prevent this.') - // Use memory method instead and tidy up. - _this.processFileViaMemory(); + // Use memory method instead and terminate service worker. fileWorker.terminate(); + _this.processFileViaMemory(); } sendPart(this._buffer[i], offset); From f4a947527d23e09b785404a6c1bddca5009720f8 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 15 Feb 2024 18:05:48 +0100 Subject: [PATCH 36/53] Move service worker digestion into separate class and add static function to check if it is supported by the browser. Change ram-exceed-ios waring accordingly. --- public/lang/en.json | 2 +- public/scripts/network.js | 262 ++++++++++++++++++----------- public/scripts/sw-file-digester.js | 19 ++- 3 files changed, 178 insertions(+), 105 deletions(-) diff --git a/public/lang/en.json b/public/lang/en.json index 06ec120..4a1bd7b 100644 --- a/public/lang/en.json +++ b/public/lang/en.json @@ -164,7 +164,7 @@ "selected-peer-left": "Selected peer left", "error-sharing-size": "Files too big to be shared. They can be downloaded instead.", "error-sharing-default": "Error while sharing. It can be downloaded instead.", - "ram-exceed-ios": "File is bigger than 250 MB and will crash the page on iOS. Use https to prevent this." + "ram-exceed-ios": "One of the files is bigger than 250 MB and will crash the page on iOS. Use https and do not use private tabs on the iOS device to prevent this." }, "document-titles": { "file-received": "File Received", diff --git a/public/scripts/network.js b/public/scripts/network.js index 01e38bf..456fbe4 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -484,7 +484,7 @@ class Peer { _sendData(data) {} - _onMessage(message) { + async _onMessage(message) { switch (message.type) { case 'display-name-changed': this._onDisplayNameChanged(message); @@ -493,7 +493,7 @@ class Peer { this._onState(message.state); break; case 'transfer-request': - this._onTransferRequest(message); + await this._onTransferRequest(message); break; case 'transfer-request-response': this._onTransferRequestResponse(message); @@ -740,44 +740,44 @@ class Peer { } // File Receiver Only - _onTransferRequest(request) { + async _onTransferRequest(request) { + // Only accept one request at a time per peer if (this._pendingRequest) { - // Only accept one request at a time per peer this._sendTransferRequestResponse(false); return; } + // Check if each file must be loaded into RAM completely. This might lead to a page crash (Memory limit iOS Safari: ~380 MB) + if (!(await FileDigesterWorker.isSupported())) { + Logger.warn('Big file transfers might exceed the RAM of the receiver. Use a secure context (https) and do not use private tabs to prevent this.'); + + // Check if page will crash on iOS + if (window.iOS && await this._filesTooBigForSwOnIOS(request.header)) { + Events.fire('notify-user', Localization.getTranslation('notifications.ram-exceed-ios')); + + // Would exceed RAM -> decline request + this._sendTransferRequestResponse(false, 'ram-exceed-ios'); + return; + } + } + this._pendingRequest = request; - if (!window.Worker || !window.isSecureContext) { - // Each file must be loaded into RAM completely which might lead to a page crash (Memory limit iOS Safari: ~380 MB) - Logger.warn('Big file transfers might exceed the RAM of the receiver. Use a secure context (https) to prevent this.'); - } - - if (window.iOS && this._filesTooBigForIos(request.header)) { - // Page will crash. Decline request - Events.fire('notify-user', Localization.getTranslation('notifications.ram-exceed-ios')); - this._sendTransferRequestResponse(false, 'ram-exceed-ios'); - return; - } - + // Automatically accept request if auto-accept is set to true via the Edit Paired Devices Dialog if (this._autoAccept) { - // auto accept if set via Edit Paired Devices Dialog this._sendTransferRequestResponse(true); return; } - // default behavior: show user transfer request + // Default behavior: show transfer request to user Events.fire('files-transfer-request', { request: request, peerId: this._peerId }); } - _filesTooBigForIos(files) { - if (window.Worker && window.isSecureContext) { - return false; - } + async _filesTooBigForSwOnIOS(files) { + // Files over 250 MB crash safari if not handled via a service worker for (let i = 0; i < files.length; i++) { if (files[i].size > 250000000) { return true; @@ -1313,7 +1313,7 @@ class RTCPeer extends Peer { this._state = Peer.STATE_TRANSFER_PROCEEDING; } - _onMessage(message) { + async _onMessage(message) { Logger.debug('RTC Receive:', JSON.parse(message)); try { message = JSON.parse(message); @@ -1321,7 +1321,7 @@ class RTCPeer extends Peer { Logger.warn("RTCPeer: Received JSON is malformed"); return; } - super._onMessage(message); + await super._onMessage(message); } getConnectionHash() { @@ -1412,9 +1412,9 @@ class WSPeer extends Peer { this._sendSignal(true); } - _onMessage(message) { + async _onMessage(message) { Logger.debug('WS Receive:', message); - super._onMessage(message); + await super._onMessage(message); } _onWsRelay(message) { @@ -1847,12 +1847,14 @@ class FileDigester { if (this._bytesReceived < this._size) return; // We are done receiving. Preferably use a file worker to process the file to prevent exceeding of available RAM - if (!window.Worker && !window.isSecureContext) { - this.processFileViaMemory(); - return; - } - - this.processFileViaWorker(); + FileDigesterWorker.isSupported() + .then(supported => { + if (!supported) { + this.processFileViaMemory(); + return; + } + this.processFileViaWorker(); + }); } processFileViaMemory() { @@ -1865,88 +1867,146 @@ class FileDigester { } processFileViaWorker() { - // Use service worker to prevent loading the complete file into RAM - const fileWorker = new Worker("scripts/sw-file-digester.js"); - - let i = 0; - let offset = 0; - - const _this = this; - - function sendPart(buffer, offset) { - fileWorker.postMessage({ - type: "part", - name: _this._name, - buffer: buffer, - offset: offset - }); - } - - function getFile() { - fileWorker.postMessage({ - type: "get-file", - name: _this._name, - }); - } - - function deleteFile() { - fileWorker.postMessage({ - type: "delete-file", - name: _this._name + const fileDigesterWorker = new FileDigesterWorker(); + fileDigesterWorker.digestFileBuffer(this._buffer, this._name) + .then(file => { + this._fileCompleteCallback(file); }) - } + .catch(reason => { + Logger.warn(reason); + this.processFileViaWorker(); + }) + } +} - function onPart(part) { - if (i < _this._buffer.length - 1) { - // process next chunk - offset += part.byteLength; - i++; - sendPart(_this._buffer[i], offset); - return; - } +class FileDigesterWorker { - // File processing complete -> retrieve completed file - getFile(); - } + constructor() { + // Use service worker to prevent loading the complete file into RAM + this.fileWorker = new Worker("scripts/sw-file-digester.js"); - function onFile(file) { - _this._buffer = []; - _this._fileCompleteCallback(file); - deleteFile(); - } - - function onFileDeleted() { - // File Digestion complete -> Tidy up - fileWorker.terminate(); - } - - function onError(error) { - // an error occurred. - Logger.error(error); - Logger.warn('Failed to process file via service-worker. Do not use Firefox private mode to prevent this.') - - // Use memory method instead and terminate service worker. - fileWorker.terminate(); - _this.processFileViaMemory(); - } - - sendPart(this._buffer[i], offset); - - fileWorker.onmessage = (e) => { + this.fileWorker.onmessage = (e) => { switch (e.data.type) { + case "support": + this.onSupport(e.data.supported); + break; case "part": - onPart(e.data.part); + this.onPart(e.data.part); break; case "file": - onFile(e.data.file); + this.onFile(e.data.file); break; case "file-deleted": - onFileDeleted(); + this.onFileDeleted(); break; case "error": - onError(e.data.error); + this.onError(e.data.error); break; } } } -} + + static isSupported() { + // Check if web worker is supported and supports specific functions + return new Promise(async resolve => { + if (!window.Worker || !window.isSecureContext) { + resolve(false); + return; + } + + const fileDigesterWorker = new FileDigesterWorker(); + + resolve(await fileDigesterWorker.checkSupport()); + + fileDigesterWorker.fileWorker.terminate(); + }) + } + + checkSupport() { + return new Promise(resolve => { + this.resolveSupport = resolve; + this.fileWorker.postMessage({ + type: "check-support" + }); + }) + } + + onSupport(supported) { + if (!this.resolveSupport) return; + + this.resolveSupport(supported); + this.resolveSupport = null; + } + + digestFileBuffer(buffer, fileName) { + return new Promise((resolve, reject) => { + this.resolveFile = resolve; + this.rejectFile = reject; + + this.i = 0; + this.offset = 0; + + this.buffer = buffer; + this.fileName = fileName; + + this.sendPart(this.buffer[0], 0); + }) + } + + + sendPart(buffer, offset) { + this.fileWorker.postMessage({ + type: "part", + name: this.fileName, + buffer: buffer, + offset: offset + }); + } + + getFile() { + this.fileWorker.postMessage({ + type: "get-file", + name: this.fileName, + }); + } + + deleteFile() { + this.fileWorker.postMessage({ + type: "delete-file", + name: this.fileName + }) + } + + onPart(part) { + if (this.i < this.buffer.length - 1) { + // process next chunk + this.offset += part.byteLength; + this.i++; + this.sendPart(this.buffer[this.i], this.offset); + return; + } + + // File processing complete -> retrieve completed file + this.getFile(); + } + + onFile(file) { + this.buffer = []; + this.resolveFile(file); + this.deleteFile(); + } + + onFileDeleted() { + // File Digestion complete -> Tidy up + this.fileWorker.terminate(); + } + + onError(error) { + // an error occurred. + Logger.error(error); + + // Use memory method instead and terminate service worker. + this.fileWorker.terminate(); + this.rejectFile("Failed to process file via service-worker. Do not use Firefox private mode to prevent this."); + } +} \ No newline at end of file diff --git a/public/scripts/sw-file-digester.js b/public/scripts/sw-file-digester.js index eaf36ba..9678b3f 100644 --- a/public/scripts/sw-file-digester.js +++ b/public/scripts/sw-file-digester.js @@ -1,14 +1,17 @@ self.addEventListener('message', async e => { try { switch (e.data.type) { + case "check-support": + await checkSupport(); + break; case "part": - await this.onPart(e.data.name, e.data.buffer, e.data.offset); + await onPart(e.data.name, e.data.buffer, e.data.offset); break; case "get-file": - await this.onGetFile(e.data.name); + await onGetFile(e.data.name); break; case "delete-file": - await this.onDeleteFile(e.data.name); + await onDeleteFile(e.data.name); break; } } @@ -17,6 +20,16 @@ self.addEventListener('message', async e => { } }) +async function checkSupport() { + try { + await getAccessHandle("test.txt"); + self.postMessage({type: "support", supported: true}); + } + catch (e) { + self.postMessage({type: "support", supported: false}); + } +} + async function getFileHandle(fileName) { const root = await navigator.storage.getDirectory(); return await root.getFileHandle(fileName, {create: true}); From 74bd7dd406b4adb9f13a4baf43307a0ab57d6639 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Fri, 16 Feb 2024 15:22:19 +0100 Subject: [PATCH 37/53] Check if RAM would be exceeded before using navigator.share() --- public/scripts/ui.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/public/scripts/ui.js b/public/scripts/ui.js index b03db22..2d6180f 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -1115,7 +1115,13 @@ class ReceiveFileDialog extends ReceiveDialog { : `${ Localization.getTranslation("document-titles.file-received-plural", null, {count: this._data.files.length}) } - PairDrop`; // If possible, share via menu - else download files - const shareViaMenu = this.canShareFilesViaMenu(this._data.files); + let shareViaMenu = this.canShareFilesViaMenu(this._data.files); + + if (shareViaMenu && this._filesTooBigForRam()) { + // Files too big to share on iOS -> Download instead + shareViaMenu = false; + Events.fire('notify-user', Localization.getTranslation("notifications.error-sharing-size")); + } this._parseFileData( this._data.displayName, @@ -1142,6 +1148,11 @@ class ReceiveFileDialog extends ReceiveDialog { Events.fire('set-progress', {peerId: this._data.peerId, progress: 0, status: "receive-complete"}); } + _filesTooBigForRam() { + // Pages crash on iOS if RAM exceeds 250 MB + return window.iOS && this._data.totalSize > 250000000; + } + _getDescriptor(files, imagesOnly) { let descriptor; if (files.length === 1) { @@ -1246,9 +1257,7 @@ class ReceiveFileDialog extends ReceiveDialog { let zipName = ""; let sendAsZip = false; - const tooBigToZip = window.iOS && this._data.totalSize > 256000000; - - if (this._data.files.length > 1 && !tooBigToZip) { + if (this._data.files.length > 1 && !this._filesTooBigForRam()) { Events.fire('set-progress', { peerId: this._data.peerId, progress: 0, @@ -1373,7 +1382,7 @@ class ReceiveFileDialog extends ReceiveDialog { let {sendAsZip, zipObjectUrl, zipName} = await this._processDataAsZip(); - // If single file or zipping failed -> download files individually -> else download zip + // If single file or zipping failed or file size exceeds memory -> download files individually -> else download zip if (sendAsZip) { this._setDownloadButtonToZip(zipObjectUrl, zipName); } else { From 0d17ada58bca51e1f0a7a939e8517f9cb2895f81 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Fri, 16 Feb 2024 15:25:31 +0100 Subject: [PATCH 38/53] NoSleep: Move evaluation if any peer is still busy to the PeerManager --- public/scripts/network.js | 14 ++++++++++++++ public/scripts/ui.js | 5 +---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 456fbe4..0046231 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -359,6 +359,9 @@ class Peer { this._totalBytesReceived = 0; this._digester = null; this._filesReceived = []; + + // disable NoSleep if idle + Events.fire('evaluate-no-sleep'); } _refresh() {} @@ -1484,6 +1487,8 @@ class PeersManager { Events.on('ws-disconnected', _ => this._onWsDisconnected()); Events.on('ws-relay', e => this._onWsRelay(e.detail.peerId, e.detail.message)); Events.on('ws-config', e => this._onWsConfig(e.detail)); + + Events.on('evaluate-no-sleep', _ => this._onEvaluateNoSleep()); } _onWsConfig(wsConfig) { @@ -1495,6 +1500,15 @@ class PeersManager { this.peers[peerId]._onServerSignalMessage(message); } + _onEvaluateNoSleep() { + // Evaluate if NoSleep should be disabled + for (let i = 0; i < this.peers.length; i++) { + if (this.peers[i]._busy) return; + } + + NoSleepUI.disable(); + } + _refreshPeer(isCaller, peerId, roomType, roomId) { const peer = this.peers[peerId]; const roomTypesDiffer = Object.keys(peer._roomIds)[0] !== roomType; diff --git a/public/scripts/ui.js b/public/scripts/ui.js index 2d6180f..0ef241c 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -791,7 +791,6 @@ class PeerUI { this.$el.removeAttribute('status'); this.$el.querySelector('.status').innerHTML = ''; this._currentStatus = null; - NoSleepUI.disableIfIdle(); return; } @@ -2968,9 +2967,7 @@ class NoSleepUI { NoSleepUI._active = true; } - static disableIfIdle() { - if ($$('x-peer[status]')) return; - + static disable() { NoSleepUI._nosleep.disable(); NoSleepUI._active = false; } From 3c8848d406ddf1da8706303aa7786377b281ad0b Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Fri, 16 Feb 2024 17:56:55 +0100 Subject: [PATCH 39/53] Add STATE_TRANSFER_REQUEST_RECEIVED and close transfer request dialog if requesting peer reloads --- public/scripts/network.js | 48 ++++++++++++++++++++++++++++++--------- public/scripts/ui.js | 18 +++++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 0046231..2fa5760 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -321,6 +321,7 @@ class Peer { static STATE_IDLE = 'idle'; static STATE_PREPARE = 'prepare'; static STATE_TRANSFER_REQUEST_SENT = 'transfer-request-sent'; + static STATE_TRANSFER_REQUEST_RECEIVED = 'transfer-request-received'; static STATE_RECEIVE_PROCEEDING = 'receive-proceeding'; static STATE_TRANSFER_PROCEEDING = 'transfer-proceeding'; static STATE_TEXT_SENT = 'text-sent'; @@ -493,7 +494,7 @@ class Peer { this._onDisplayNameChanged(message); break; case 'state': - this._onState(message.state); + await this._onState(message.state); break; case 'transfer-request': await this._onTransferRequest(message); @@ -556,23 +557,29 @@ class Peer { this._sendMessage({type: 'state', state: this._state}) } - _onState(peerState) { - if (this._state === Peer.STATE_RECEIVE_PROCEEDING) { - this._onStateReceiver(peerState); + async _onState(peerState) { + if (this._state === Peer.STATE_TRANSFER_PROCEEDING) { + this._onStateIfSender(peerState); } - else if (this._state === Peer.STATE_TRANSFER_PROCEEDING) { - this._onStateSender(peerState); + else if (this._state === Peer.STATE_RECEIVE_PROCEEDING) { + this._onStateIfReceiver(peerState); + } + else if (this._state === Peer.STATE_TRANSFER_REQUEST_SENT) { + await this._onStateIfTransferRequestSent(peerState); + } + else if (this._state === Peer.STATE_TRANSFER_REQUEST_RECEIVED) { + this._onStateIfTransferRequestReceived(peerState); } } - _onStateSender(peerState) { + _onStateIfSender(peerState) { // this peer is sender if (peerState !== Peer.STATE_RECEIVE_PROCEEDING) { this._abortTransfer(); } } - _onStateReceiver(peerState) { + _onStateIfReceiver(peerState) { // this peer is receiver switch (peerState) { case Peer.STATE_TRANSFER_REQUEST_SENT: @@ -592,6 +599,25 @@ class Peer { } } + async _onStateIfTransferRequestSent(peerState) { + // This peer has sent a transfer request + // If other peer is still idle -> send request again + if (peerState === Peer.STATE_IDLE) { + await this._sendFileTransferRequest(this._filesRequested); + } + } + + _onStateIfTransferRequestReceived(peerState) { + // This peer has received a transfer request + // If other peer is not in "STATE_TRANSFER_REQUEST_SENT" anymore -> reset and hide request from user + if (peerState !== Peer.STATE_TRANSFER_REQUEST_SENT) { + this._reset(); + Events.fire('files-transfer-request-abort', { + peerId: this._peerId + }) + } + } + _abortTransfer() { Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'error'}); this._reset(); @@ -625,7 +651,8 @@ class Peer { } } - Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'prepare'}) + this._state = Peer.STATE_TRANSFER_REQUEST_SENT; + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'wait'}); this._filesRequested = files; @@ -636,8 +663,6 @@ class Peer { thumbnailDataUrl: dataUrl }); - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'wait'}) - this._state = Peer.STATE_TRANSFER_REQUEST_SENT; } _onTransferRequestResponse(message) { @@ -764,6 +789,7 @@ class Peer { } } + this._state = Peer.STATE_TRANSFER_REQUEST_RECEIVED; this._pendingRequest = request; // Automatically accept request if auto-accept is set to true via the Edit Paired Devices Dialog diff --git a/public/scripts/ui.js b/public/scripts/ui.js index 0ef241c..10b8f46 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -1444,6 +1444,7 @@ class ReceiveRequestDialog extends ReceiveDialog { this._currentRequest = null; Events.on('files-transfer-request', e => this._onRequestFileTransfer(e.detail.request, e.detail.peerId)) + Events.on('files-transfer-request-abort', e => this._onRequestFileTransferAbort(e.detail.peerId)); Events.on('keydown', e => this._onKeyDown(e)); } @@ -1461,6 +1462,22 @@ class ReceiveRequestDialog extends ReceiveDialog { this._dequeueRequests(); } + _onRequestFileTransferAbort(peerId) { + // Remove file transfer request from this peer from queue + for (let i = 0; i < this._filesTransferRequestQueue.length; i++) { + if (this._filesTransferRequestQueue[i].peerId === peerId) { + this._filesTransferRequestQueue.splice(i, 1); + break; + } + } + + // Hide dialog if the currently open transfer request is from this peer + if (this.isShown() && this.correspondingPeerId === peerId) { + this.hide(); + Events.fire('notify-user', Localization.getTranslation("notifications.selected-peer-left")); + } + } + _dequeueRequests() { if (!this._filesTransferRequestQueue.length) { this._currentRequest = null; @@ -2724,6 +2741,7 @@ class Notifications { Events.on('text-received', e => this._messageNotification(e.detail.text, e.detail.peerId)); Events.on('files-received', e => this._downloadNotification(e.detail.files, e.detail.imagesOnly)); Events.on('files-transfer-request', e => this._requestNotification(e.detail.request, e.detail.peerId)); + // Todo on 'files-transfer-request-abort' remove notification } async _requestPermission() { From 00f1a201772a04d3b1f65753ed23138cb92ec7b0 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Fri, 16 Feb 2024 18:08:30 +0100 Subject: [PATCH 40/53] Round progress to 4th digit to prevent weird progress bar behavior on reconnect --- public/scripts/network.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 2fa5760..af4900c 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -890,8 +890,9 @@ class Peer { return; } + // While transferring -> round progress to 4th digit. After transferring, set it to 1. let progress = this._digester - ? (this._totalBytesReceived + this._digester._bytesReceived) / this._acceptedRequest.totalSize + ? Math.floor(1e4 * (this._totalBytesReceived + this._digester._bytesReceived) / this._acceptedRequest.totalSize) / 1e4 : 1; Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'receive'}); From e29ea44025dd062dbfbc9b76550972797a3888bd Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Sat, 17 Feb 2024 12:41:45 +0100 Subject: [PATCH 41/53] Add transfer notes: Speed + Time left --- public/scripts/network.js | 135 ++++++++++++++++++++++++++++++++++---- public/scripts/ui.js | 68 ++++++++++--------- 2 files changed, 159 insertions(+), 44 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index af4900c..6eada56 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -350,6 +350,14 @@ class Peer { this._state = Peer.STATE_IDLE; this._busy = false; + clearInterval(this._transferStatusInterval); + + this._transferStatusInterval = null; + this._bytesTotal = 0; + this._bytesReceivedFiles = 0; + this._timeStart = null; + this._byteLogs = []; + // tidy up sender this._filesRequested = null; this._chunker = null; @@ -357,7 +365,6 @@ class Peer { // tidy up receiver this._pendingRequest = null; this._acceptedRequest = null; - this._totalBytesReceived = 0; this._digester = null; this._filesReceived = []; @@ -623,6 +630,79 @@ class Peer { this._reset(); } + _addLog(bytesReceivedCurrentFile) { + const now = Date.now(); + + // Add log + this._byteLogs.push({ + time: now, + bytesReceived: this._bytesReceivedFiles + bytesReceivedCurrentFile + }); + + // Always include at least 5 entries (2.5 MB) to increase precision + if (this._byteLogs.length < 5) return; + + // Move running average to calculate with a window of 20s + while (now - this._byteLogs[0].time > 20000) { + this._byteLogs.shift(); + } + } + + _setTransferStatus(status) { + const secondsSinceStart = Math.round((Date.now() - this._timeStartTransferComplete) / 1000); + + // Wait for 10s to only show info on longer transfers and to increase precision + if (secondsSinceStart < 10) return; + + // mode: 0 -> speed, 1 -> time left, 2 -> receive/transfer + const mode = Math.round((secondsSinceStart - 10) / 5) % 3; + + if (mode === 0) { + status = this._getSpeedString(); + } + else if (mode === 1) { + status = this._getTimeString(); + } + + this._transferStatusString = status; + } + + _calculateSpeedKbPerSecond() { + const timeDifferenceSeconds = (this._byteLogs[this._byteLogs.length - 1].time - this._byteLogs[0].time) / 1000; + const bytesDifferenceKB = (this._byteLogs[this._byteLogs.length - 1].bytesReceived - this._byteLogs[0].bytesReceived) / 1000; + return bytesDifferenceKB / timeDifferenceSeconds; + } + + _calculateBytesLeft() { + return this._bytesTotal - this._byteLogs[this._byteLogs.length - 1].bytesReceived; + } + + _calculateSecondsLeft() { + return Math.ceil(this._calculateBytesLeft() / this._calculateSpeedKbPerSecond() / 1000); + } + + _getSpeedString() { + const speedKBs = this._calculateSpeedKbPerSecond(); + if (speedKBs >= 1000) { + let speedMBs = Math.round(speedKBs / 100) / 10; + return `${speedMBs} MB/s`; // e.g. "2.2 MB/s" + } + + return `${speedKBs} kB/s`; // e.g. "522 kB/s" + } + + _getTimeString() { + const seconds = this._calculateSecondsLeft(); + if (seconds > 60) { + let minutes = Math.floor(seconds / 60); + let secondsLeft = Math.floor(seconds % 60); + return `${minutes} min ${secondsLeft}s`; // e.g. // "1min 20s" + } + else { + return `${seconds}s`; // e.g. "35s" + } + } + // File Sender Only async _sendFileTransferRequest(files) { this._state = Peer.STATE_PREPARE; @@ -655,6 +735,7 @@ class Peer { Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'wait'}); this._filesRequested = files; + this._bytesTotal = totalSize; this._sendMessage({type: 'transfer-request', header: header, @@ -691,7 +772,18 @@ class Peer { this._filesQueue.push(this._filesRequested[i]); } this._filesRequested = null + if (this._busy) return; + + this._byteLogs = []; + this._bytesReceivedFiles = 0; + this._timeStartTransferComplete = Date.now(); + + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'transfer'}); + + this._transferStatusString = 'transfer'; + this._transferStatusInterval = setInterval(() => this._setTransferStatus('transfer'), 1000); + this._dequeueFile(); } @@ -727,7 +819,7 @@ class Peer { return; } - Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'transfer'}); + Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: this._transferStatusString}); } _onReceiveConfirmation(bytesReceived) { @@ -736,6 +828,8 @@ class Peer { return; } this._chunker._onReceiveConfirmation(bytesReceived); + + this._addLog(bytesReceived); } _onFileReceiveComplete(message) { @@ -744,6 +838,8 @@ class Peer { return; } + this._bytesReceivedFiles += this._chunker._file.size; + this._chunker = null; if (!message.success) { @@ -762,7 +858,7 @@ class Peer { // No more files in queue. Transfer is complete this._reset(); - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'transfer-complete'}); + Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'transfer-complete'}); Events.fire('notify-user', Localization.getTranslation("notifications.file-transfer-completed")); Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app } @@ -822,16 +918,25 @@ class Peer { message.reason = reason; } - this._sendMessage(message); - if (accepted) { this._state = Peer.STATE_RECEIVE_PROCEEDING; this._busy = true; + this._byteLogs = []; + this._filesReceived = []; this._acceptedRequest = this._pendingRequest; this._lastProgress = 0; - this._totalBytesReceived = 0; - this._filesReceived = []; + + this._bytesTotal = this._acceptedRequest.totalSize; + this._bytesReceivedFiles = 0; + + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'receive'}); + + this._timeStartTransferComplete = Date.now(); + this._transferStatusString = 'receive'; + this._transferStatusInterval = setInterval(() => this._setTransferStatus('receive'), 1000); } + + this._sendMessage(message); } _onTransferHeader(header) { @@ -847,7 +952,7 @@ class Peer { return; } - this._timeStart = Date.now(); + this._timeStartTransferFile = Date.now(); this._addFileDigester(header); } @@ -859,8 +964,10 @@ class Peer { ); } - _sendReceiveConfirmation(bytesReceived) { - this._sendMessage({type: 'receive-confirmation', bytesReceived: bytesReceived}); + _sendReceiveConfirmation(bytesReceivedCurrentFile) { + this._sendMessage({type: 'receive-confirmation', bytesReceived: bytesReceivedCurrentFile}); + + this._addLog(bytesReceivedCurrentFile); } _sendResendRequest(offset) { @@ -892,10 +999,10 @@ class Peer { // While transferring -> round progress to 4th digit. After transferring, set it to 1. let progress = this._digester - ? Math.floor(1e4 * (this._totalBytesReceived + this._digester._bytesReceived) / this._acceptedRequest.totalSize) / 1e4 + ? Math.floor(1e4 * (this._bytesReceivedFiles + this._digester._bytesReceived) / this._acceptedRequest.totalSize) / 1e4 : 1; - Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'receive'}); + Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: this._transferStatusString}); // occasionally notify sender about our progress if (progress - this._lastProgress >= 0.005 || progress === 1) { @@ -946,9 +1053,9 @@ class Peer { this._digester._sendReceiveConfimationCallback = null; this._digester = null; - this._totalBytesReceived += file.size; + this._bytesReceivedFiles += file.size; - const duration = (Date.now() - this._timeStart) / 1000; // s + const duration = (Date.now() - this._timeStartTransferFile) / 1000; // s const size = Math.round(10 * file.size / 1e6) / 10; // MB const speed = Math.round(100 * size / duration) / 100; // MB/s diff --git a/public/scripts/ui.js b/public/scripts/ui.js index 10b8f46..d31dda8 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -29,7 +29,7 @@ class PeersUI { Events.on('peer-connecting', e => this._onPeerConnecting(e.detail)); Events.on('peer-disconnected', e => this._onPeerDisconnected(e.detail)); Events.on('peers', e => this._onPeers(e.detail)); - Events.on('set-progress', e => this._onSetProgress(e.detail)); + Events.on('set-progress', e => this._onSetProgress(e.detail.peerId, e.detail.progress, e.detail.status)); Events.on('drop', e => this._onDrop(e)); Events.on('keydown', e => this._onKeyDown(e)); @@ -185,12 +185,12 @@ class PeersUI { }) } - _onSetProgress(progress) { - const peerUI = this.peerUIs[progress.peerId]; + _onSetProgress(peerId, progress, status) { + const peerUI = this.peerUIs[peerId]; if (!peerUI) return; - peerUI.setProgressOrQueue(progress.progress, progress.status); + peerUI.setProgressOrQueue(progress, status); } _onDrop(e) { @@ -692,18 +692,19 @@ class PeerUI { setProgressOrQueue(progress, status) { if (this._progressQueue.length > 0) { - // add to queue - this._progressQueue.push({progress: progress, status: status}); - - for (let i = 0; i < this._progressQueue.length; i++) { - if (this._progressQueue[i].progress <= progress) { - // if progress is higher than progress in queue -> overwrite in queue and cut queue at this position - this._progressQueue[i].progress = progress; - this._progressQueue[i].status = status; - this._progressQueue = this._progressQueue.slice(0, i + 1); - break; + if (progress) { + // if progress is higher than progress in queue -> overwrite in queue and cut queue at this position + for (let i = 0; i < this._progressQueue.length; i++) { + if (this._progressQueue[i].progress <= progress) { + this._progressQueue[i].progress = progress; + this._progressQueue[i].status = status; + this._progressQueue.splice(i + 1); + return; + } } } + // add to queue + this._progressQueue.push({progress: progress, status: status}); return; } @@ -711,17 +712,19 @@ class PeerUI { } setNextProgress() { - if (this._progressQueue.length > 0) { - setTimeout(() => { - let next = this._progressQueue.shift() - this.setProgress(next.progress, next.status); - }, 250); // 200 ms animation + buffer - } + if (!this._progressQueue.length) return; + + setTimeout(() => { + let next = this._progressQueue.shift() + this.setProgress(next.progress, next.status); + }, 250); // 200 ms animation + buffer } setProgress(progress, status) { this.setStatus(status); + if (progress === null) return; + const progressSpillsOverHalf = this._currentProgress < 0.5 && 0.5 < progress; // 0.5 slips through const progressSpillsOverFull = progress <= 0.5 && 0.5 <= this._currentProgress && this._currentProgress < 1; @@ -763,7 +766,7 @@ class PeerUI { this.$progress.classList.add('animate'); } - if (this._currentProgress < progress) { + if (progress > this._currentProgress) { this.$progress.classList.add('animate'); } else { @@ -772,29 +775,30 @@ class PeerUI { this.$progress.style.setProperty('--progress', `rotate(${360 * progress}deg)`); - this._currentProgress = progress - if (progress === 1) { // reset progress this._progressQueue.unshift({progress: 0, status: status}); } + this._currentProgress = progress; + this.setNextProgress(); } setStatus(status) { if (status === this._currentStatus) return; + this._currentStatus = status; + clearTimeout(this.statusTimeout); if (!status) { this.$el.removeAttribute('status'); - this.$el.querySelector('.status').innerHTML = ''; - this._currentStatus = null; + this.$el.querySelector('.status').innerText = ''; return; } - let statusName = { + let statusText = { "connect": Localization.getTranslation("peer-ui.connecting"), "prepare": Localization.getTranslation("peer-ui.preparing"), "transfer": Localization.getTranslation("peer-ui.transferring"), @@ -806,11 +810,15 @@ class PeerUI { "error": Localization.getTranslation("peer-ui.error") }[status]; - this.$el.setAttribute('status', status); - this.$el.querySelector('.status').innerText = statusName; - this._currentStatus = status; + if (statusText) { + this.$el.setAttribute('status', status); + this.$el.querySelector('.status').innerText = statusText; + } + else { + this.$el.querySelector('.status').innerText = status; + } - if (["transfer-complete", "receive-complete", "error"].includes(status)) { + if (status.endsWith("-complete") || status === "error") { this.statusTimeout = setTimeout(() => { this.setProgress(0, null); }, 10000); From 8592499d220f76baf5c63e9f351378c5166945a1 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Sat, 17 Feb 2024 14:17:43 +0100 Subject: [PATCH 42/53] Replace status: null with status: idle; Set status to processing immediately after receiving is done --- public/scripts/network.js | 5 +++-- public/scripts/ui.js | 23 ++++++++++------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 6eada56..9afba2f 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -756,7 +756,7 @@ class Peer { if (message.reason === 'ram-exceed-ios') { Events.fire('notify-user', Localization.getTranslation('notifications.ram-exceed-ios')); } - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'idle'}); this._reset(); return; } @@ -844,7 +844,7 @@ class Peer { if (!message.success) { Logger.warn('File could not be sent'); - Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: null}); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'idle'}); this._reset(); return; } @@ -1024,6 +1024,7 @@ class Peer { // We are done receiving Events.fire('set-progress', {peerId: this._peerId, progress: 1, status: 'receive'}); + Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'process'}); this._allFilesReceiveComplete(); } diff --git a/public/scripts/ui.js b/public/scripts/ui.js index d31dda8..0cc971e 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -423,8 +423,8 @@ class PeerUI { this._connected = false; this._currentProgress = 0; - this._currentStatus = null - this._oldStatus = null; + this._currentStatus = 'idle'; + this._oldStatus = 'idle'; this._progressQueue = []; @@ -461,8 +461,6 @@ class PeerUI { this.updateTypesClassList(); - this.setStatus("connect"); - this._evaluateShareMode(); this._bindListeners(); } @@ -602,7 +600,7 @@ class PeerUI { if (connected) { this._connected = true; - // on reconnect + // on reconnect: reset status to saved status this.setStatus(this._oldStatus); this._oldStatus = null; @@ -611,11 +609,11 @@ class PeerUI { else { this._connected = false; + // when connecting: / connection is lost: save old status if (!this._oldStatus && this._currentStatus !== "connect") { - // save old status when reconnecting this._oldStatus = this._currentStatus; + this.setStatus("connect"); } - this.setStatus("connect"); this._connectionHash = ""; } @@ -755,12 +753,12 @@ class PeerUI { return; } - if (progress === 0) { + if (progress < 0.5) { this.$progress.classList.remove('animate'); this.$progress.classList.remove('over50'); this.$progress.classList.add('animate'); } - else if (this._currentProgress === 0.5) { + else if (progress > 0.5 && this._currentProgress === 0.5) { this.$progress.classList.remove('animate'); this.$progress.classList.add('over50'); this.$progress.classList.add('animate'); @@ -773,14 +771,13 @@ class PeerUI { this.$progress.classList.remove('animate'); } - this.$progress.style.setProperty('--progress', `rotate(${360 * progress}deg)`); - if (progress === 1) { // reset progress this._progressQueue.unshift({progress: 0, status: status}); } this._currentProgress = progress; + this.$progress.style.setProperty('--progress', `rotate(${360 * progress}deg)`); this.setNextProgress(); } @@ -792,7 +789,7 @@ class PeerUI { clearTimeout(this.statusTimeout); - if (!status) { + if (status === 'idle') { this.$el.removeAttribute('status'); this.$el.querySelector('.status').innerText = ''; return; @@ -820,7 +817,7 @@ class PeerUI { if (status.endsWith("-complete") || status === "error") { this.statusTimeout = setTimeout(() => { - this.setProgress(0, null); + this.setStatus("idle"); }, 10000); } } From d70f9d762e3f7a8df82b3f5daee162a1414a8666 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Sat, 17 Feb 2024 15:39:33 +0100 Subject: [PATCH 43/53] Remove redundant 'receive-progress' and move setting of progress to receive confirmation methods --- public/scripts/network.js | 55 +++++++++++++-------------------------- 1 file changed, 18 insertions(+), 37 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 9afba2f..d935c8c 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -355,7 +355,8 @@ class Peer { this._transferStatusInterval = null; this._bytesTotal = 0; this._bytesReceivedFiles = 0; - this._timeStart = null; + this._timeStartTransferComplete = null; + this._timeStartTransferFile = null; this._byteLogs = []; // tidy up sender @@ -512,9 +513,6 @@ class Peer { case 'transfer-header': this._onTransferHeader(message); break; - case 'receive-progress': - this._onReceiveProgress(message.progress); - break; case 'receive-confirmation': this._onReceiveConfirmation(message.bytesReceived); break; @@ -630,13 +628,13 @@ class Peer { this._reset(); } - _addLog(bytesReceivedCurrentFile) { + _addLog(bytesReceivedTotal) { const now = Date.now(); // Add log this._byteLogs.push({ time: now, - bytesReceived: this._bytesReceivedFiles + bytesReceivedCurrentFile + bytesReceived: bytesReceivedTotal }); // Always include at least 5 entries (2.5 MB) to increase precision @@ -813,15 +811,6 @@ class Peer { this._chunker._resendFromOffset(offset); } - _onReceiveProgress(progress) { - if (this._state !== Peer.STATE_TRANSFER_PROCEEDING) { - this._sendState(); - return; - } - - Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: this._transferStatusString}); - } - _onReceiveConfirmation(bytesReceived) { if (!this._chunker || this._state !== Peer.STATE_TRANSFER_PROCEEDING) { this._sendState(); @@ -829,7 +818,12 @@ class Peer { } this._chunker._onReceiveConfirmation(bytesReceived); - this._addLog(bytesReceived); + const bytesReceivedTotal = this._bytesReceivedFiles + bytesReceived; + const progress = Math.round(1e4 * bytesReceivedTotal / this._bytesTotal) / 1e4; + + this._addLog(bytesReceivedTotal); + + Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: this._transferStatusString}); } _onFileReceiveComplete(message) { @@ -964,10 +958,15 @@ class Peer { ); } - _sendReceiveConfirmation(bytesReceivedCurrentFile) { - this._sendMessage({type: 'receive-confirmation', bytesReceived: bytesReceivedCurrentFile}); + _sendReceiveConfirmation(bytesReceived) { + this._sendMessage({type: 'receive-confirmation', bytesReceived: bytesReceived}); - this._addLog(bytesReceivedCurrentFile); + const bytesReceivedTotal = this._bytesReceivedFiles + bytesReceived; + const progress = Math.round(1e4 * bytesReceivedTotal / this._bytesTotal) / 1e4; + + this._addLog(bytesReceivedTotal); + + Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: this._transferStatusString}); } _sendResendRequest(offset) { @@ -994,25 +993,7 @@ class Peer { catch (e) { this._abortTransfer(); Logger.error(e); - return; } - - // While transferring -> round progress to 4th digit. After transferring, set it to 1. - let progress = this._digester - ? Math.floor(1e4 * (this._bytesReceivedFiles + this._digester._bytesReceived) / this._acceptedRequest.totalSize) / 1e4 - : 1; - - Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: this._transferStatusString}); - - // occasionally notify sender about our progress - if (progress - this._lastProgress >= 0.005 || progress === 1) { - this._lastProgress = progress; - this._sendProgress(progress); - } - } - - _sendProgress(progress) { - this._sendMessage({ type: 'receive-progress', progress: progress }); } _fileReceived(file) { From 3b772d0619090d4223cad88fdc560c14f8d60463 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Sat, 17 Feb 2024 20:02:49 +0100 Subject: [PATCH 44/53] Tidy up code of progress animation and make it linear; Tidy up code of setting statusText for transfer notes --- public/scripts/network.js | 28 ++--- public/scripts/ui.js | 183 ++++++++++++++++-------------- public/styles/styles-deferred.css | 2 +- 3 files changed, 111 insertions(+), 102 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index d935c8c..663b027 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -350,7 +350,7 @@ class Peer { this._state = Peer.STATE_IDLE; this._busy = false; - clearInterval(this._transferStatusInterval); + clearInterval(this._updateStatusTextInterval); this._transferStatusInterval = null; this._bytesTotal = 0; @@ -646,23 +646,24 @@ class Peer { } } - _setTransferStatus(status) { + _updateStatusText() { const secondsSinceStart = Math.round((Date.now() - this._timeStartTransferComplete) / 1000); // Wait for 10s to only show info on longer transfers and to increase precision if (secondsSinceStart < 10) return; - // mode: 0 -> speed, 1 -> time left, 2 -> receive/transfer + // mode: 0 -> speed, 1 -> time left, 2 -> receive/transfer (statusText = null) const mode = Math.round((secondsSinceStart - 10) / 5) % 3; + let statusText = null; if (mode === 0) { - status = this._getSpeedString(); + statusText = this._getSpeedString(); } else if (mode === 1) { - status = this._getTimeString(); + statusText = this._getTimeString(); } - this._transferStatusString = status; + this._statusText = statusText; } _calculateSpeedKbPerSecond() { @@ -676,7 +677,7 @@ class Peer { } _calculateSecondsLeft() { - return Math.ceil(this._calculateBytesLeft() / this._calculateSpeedKbPerSecond() / 1000); + return Math.round(this._calculateBytesLeft() / this._calculateSpeedKbPerSecond() / 1000); } _getSpeedString() { @@ -779,8 +780,8 @@ class Peer { Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'transfer'}); - this._transferStatusString = 'transfer'; - this._transferStatusInterval = setInterval(() => this._setTransferStatus('transfer'), 1000); + this._statusText = null; + this._updateStatusTextInterval = setInterval(() => this._updateStatusText(), 1000); this._dequeueFile(); } @@ -823,7 +824,7 @@ class Peer { this._addLog(bytesReceivedTotal); - Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: this._transferStatusString}); + Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'transfer', statusText: this._statusText}); } _onFileReceiveComplete(message) { @@ -918,7 +919,6 @@ class Peer { this._byteLogs = []; this._filesReceived = []; this._acceptedRequest = this._pendingRequest; - this._lastProgress = 0; this._bytesTotal = this._acceptedRequest.totalSize; this._bytesReceivedFiles = 0; @@ -926,8 +926,8 @@ class Peer { Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'receive'}); this._timeStartTransferComplete = Date.now(); - this._transferStatusString = 'receive'; - this._transferStatusInterval = setInterval(() => this._setTransferStatus('receive'), 1000); + this._statusText = null; + this._updateStatusTextInterval = setInterval(() => this._updateStatusText(), 1000); } this._sendMessage(message); @@ -966,7 +966,7 @@ class Peer { this._addLog(bytesReceivedTotal); - Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: this._transferStatusString}); + Events.fire('set-progress', {peerId: this._peerId, progress: progress, status: 'receive', statusText: this._statusText}); } _sendResendRequest(offset) { diff --git a/public/scripts/ui.js b/public/scripts/ui.js index 0cc971e..fcc481b 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -29,7 +29,7 @@ class PeersUI { Events.on('peer-connecting', e => this._onPeerConnecting(e.detail)); Events.on('peer-disconnected', e => this._onPeerDisconnected(e.detail)); Events.on('peers', e => this._onPeers(e.detail)); - Events.on('set-progress', e => this._onSetProgress(e.detail.peerId, e.detail.progress, e.detail.status)); + Events.on('set-progress', e => this._onSetProgress(e.detail.peerId, e.detail.progress, e.detail.status, e.detail.statusText)); Events.on('drop', e => this._onDrop(e)); Events.on('keydown', e => this._onKeyDown(e)); @@ -185,12 +185,12 @@ class PeersUI { }) } - _onSetProgress(peerId, progress, status) { + _onSetProgress(peerId, progress, status, statusText) { const peerUI = this.peerUIs[peerId]; if (!peerUI) return; - peerUI.setProgressOrQueue(progress, status); + peerUI.queueProgressStatus(progress, status, statusText); } _onDrop(e) { @@ -601,19 +601,19 @@ class PeerUI { this._connected = true; // on reconnect: reset status to saved status - this.setStatus(this._oldStatus); - this._oldStatus = null; + this.queueProgressStatus(null, this._oldStatus); + this._oldStatus = 'idle'; this._connectionHash = connectionHash; } else { this._connected = false; - // when connecting: / connection is lost: save old status - if (!this._oldStatus && this._currentStatus !== "connect") { + // when connecting: / connection is lost during transfer: save old status + if (this._isTransferringStatus(this._currentStatus)) { this._oldStatus = this._currentStatus; - this.setStatus("connect"); } + this.queueProgressStatus(null, "connect"); this._connectionHash = ""; } @@ -688,71 +688,89 @@ class PeerUI { $input.files = null; // reset input } - setProgressOrQueue(progress, status) { - if (this._progressQueue.length > 0) { - if (progress) { - // if progress is higher than progress in queue -> overwrite in queue and cut queue at this position - for (let i = 0; i < this._progressQueue.length; i++) { - if (this._progressQueue[i].progress <= progress) { - this._progressQueue[i].progress = progress; - this._progressQueue[i].status = status; - this._progressQueue.splice(i + 1); - return; - } - } + queueProgressStatus(progress = null, status = null, statusText = null) { + clearTimeout(this._progressIdleTimeout); + + // if progress is higher than progress in queue -> overwrite in queue and cut queue at this position + for (let i = 0; i < this._progressQueue.length; i++) { + if (this._progressQueue[i].progress <= progress && this._progressQueue[i].status === status) { + this._progressQueue[i] = {progress, status, statusText}; + this._progressQueue.splice(i + 1); + return; } - // add to queue - this._progressQueue.push({progress: progress, status: status}); + } + this._progressQueue.push({progress, status, statusText}); + + // only dequeue if not already dequeuing + if (this._progressAnimatingTimeout) return; + + this.dequeueProgressStatus(); + } + + setNextProgressStatus() { + if (!this._progressQueue.length) { + // Queue is empty + this._progressAnimatingTimeout = null; return; } - this.setProgress(progress, status); + // Queue is not empty -> set next progress + this.dequeueProgressStatus(); } - setNextProgress() { - if (!this._progressQueue.length) return; + dequeueProgressStatus() { + clearTimeout(this._progressAnimatingTimeout); - setTimeout(() => { - let next = this._progressQueue.shift() - this.setProgress(next.progress, next.status); - }, 250); // 200 ms animation + buffer - } + let {progress, status, statusText} = this._progressQueue.shift(); - setProgress(progress, status) { - this.setStatus(status); + // On complete status: set progress to 0 after 250ms and status to idle after 10s + if (this._isCompletedStatus(status)) { + this._progressQueue.unshift({progress: 0}); + this._progressIdleTimeout = setTimeout(() => this.setStatus("idle"), 10000); + } - if (progress === null) return; + // After animation has finished -> set next progress in queue + this._progressAnimatingTimeout = setTimeout(() => this.setNextProgressStatus(), 250); // 200 ms animation + buffer + + + // Only change status if explicitly set + if (status) { + this.setStatus(status, statusText); + } + + // Only change progress if explicitly set and differs from current + if (progress === null || progress === this._currentProgress) return; const progressSpillsOverHalf = this._currentProgress < 0.5 && 0.5 < progress; // 0.5 slips through const progressSpillsOverFull = progress <= 0.5 && 0.5 <= this._currentProgress && this._currentProgress < 1; + // If spills over half: go to 0.5 first + // If spills over full: go to 1 first if (progressSpillsOverHalf) { - this._progressQueue.unshift({progress: progress, status: status}); - this.setProgress(0.5, status); + this._progressQueue.unshift({progress: 0.5}, {progress: progress}); + this.dequeueProgressStatus(); return; } else if (progressSpillsOverFull) { - this._progressQueue.unshift({progress: progress, status: status}); - this.setProgress(1, status); + this._progressQueue.unshift({progress: 1}, {progress: progress}); + this.dequeueProgressStatus(); return; } - if (progress === 0) { - this._currentProgress = 0; - this.$progress.classList.remove('animate'); - this.$progress.classList.remove('over50'); - this.$progress.style.setProperty('--progress', `rotate(${360 * progress}deg)`); - this.setNextProgress(); - return; - } - - if (progress < this._currentProgress && status !== this._currentStatus) { - // reset progress - this._progressQueue.unshift({progress: progress, status: status}); - this.setProgress(0, status); + // Clear progress after setting it to 1 + if (progress === 1) { + this._progressQueue.unshift({progress: 0}); + } + + // Set progress to 1 before setting to 0 if not error + if (progress === 0 && this._currentProgress !== 1 && status !== 'error') { + this._progressQueue.unshift({progress: 1}); + this.dequeueProgressStatus(); return; } + // under 0.5 -> remove second circle + // over 0.5 -> add second circle if (progress < 0.5) { this.$progress.classList.remove('animate'); this.$progress.classList.remove('over50'); @@ -764,62 +782,53 @@ class PeerUI { this.$progress.classList.add('animate'); } - if (progress > this._currentProgress) { - this.$progress.classList.add('animate'); - } - else { + // Do not animate when setting progress to lower value + if (progress < this._currentProgress && this._currentProgress === 1) { this.$progress.classList.remove('animate'); } - if (progress === 1) { - // reset progress - this._progressQueue.unshift({progress: 0, status: status}); + // If document is in background do not animate to prevent flickering on focus + if (!document.hasFocus()) { + this.$progress.classList.remove('animate'); } this._currentProgress = progress; this.$progress.style.setProperty('--progress', `rotate(${360 * progress}deg)`); - - this.setNextProgress(); } - setStatus(status) { - if (status === this._currentStatus) return; - + setStatus(status, statusText = null) { this._currentStatus = status; - clearTimeout(this.statusTimeout); - if (status === 'idle') { this.$el.removeAttribute('status'); this.$el.querySelector('.status').innerText = ''; return; } - let statusText = { - "connect": Localization.getTranslation("peer-ui.connecting"), - "prepare": Localization.getTranslation("peer-ui.preparing"), - "transfer": Localization.getTranslation("peer-ui.transferring"), - "receive": Localization.getTranslation("peer-ui.receiving"), - "process": Localization.getTranslation("peer-ui.processing"), - "wait": Localization.getTranslation("peer-ui.waiting"), - "transfer-complete": Localization.getTranslation("peer-ui.transfer-complete"), - "receive-complete": Localization.getTranslation("peer-ui.receive-complete"), - "error": Localization.getTranslation("peer-ui.error") - }[status]; - - if (statusText) { - this.$el.setAttribute('status', status); - this.$el.querySelector('.status').innerText = statusText; - } - else { - this.$el.querySelector('.status').innerText = status; + if (!statusText) { + statusText = { + "connect": Localization.getTranslation("peer-ui.connecting"), + "prepare": Localization.getTranslation("peer-ui.preparing"), + "transfer": Localization.getTranslation("peer-ui.transferring"), + "receive": Localization.getTranslation("peer-ui.receiving"), + "process": Localization.getTranslation("peer-ui.processing"), + "wait": Localization.getTranslation("peer-ui.waiting"), + "transfer-complete": Localization.getTranslation("peer-ui.transfer-complete"), + "receive-complete": Localization.getTranslation("peer-ui.receive-complete"), + "error": Localization.getTranslation("peer-ui.error") + }[status]; } - if (status.endsWith("-complete") || status === "error") { - this.statusTimeout = setTimeout(() => { - this.setStatus("idle"); - }, 10000); - } + this.$el.setAttribute('status', status); + this.$el.querySelector('.status').innerText = statusText; + } + + _isCompletedStatus(status) { + return status && (status.endsWith("-complete") || status === "error") + } + + _isTransferringStatus(status) { + return status !== "connect" && !this._isCompletedStatus(status); } _onDrop(e) { diff --git a/public/styles/styles-deferred.css b/public/styles/styles-deferred.css index 367b7a9..663808b 100644 --- a/public/styles/styles-deferred.css +++ b/public/styles/styles-deferred.css @@ -769,7 +769,7 @@ x-dialog .dialog-subheader { } .animate .circle { - transition: transform 200ms; + transition: transform 200ms linear; } .over50 { From 07e46e472ec0932d3bebc7c5e7409cf05004a016 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 22 Feb 2024 15:30:09 +0100 Subject: [PATCH 45/53] Prevent flickering of text on load by adding defer="true" to deferred style sheets --- public/scripts/main.js | 1 + 1 file changed, 1 insertion(+) diff --git a/public/scripts/main.js b/public/scripts/main.js index 9403567..9def333 100644 --- a/public/scripts/main.js +++ b/public/scripts/main.js @@ -137,6 +137,7 @@ class PairDrop { let stylesheet = document.createElement('link'); stylesheet.rel = 'preload'; stylesheet.as = 'style'; + stylesheet.defer = true; stylesheet.href = url; stylesheet.onload = _ => { stylesheet.onload = null; From 8a56a271bcc81d1b969791dad9ce496360a35ef8 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Fri, 23 Feb 2024 13:02:40 +0100 Subject: [PATCH 46/53] Make PWA run standalone (fixes #264) --- public/manifest.json | 2 +- public/scripts/main.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/manifest.json b/public/manifest.json index 63a9bb9..4a3239b 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -28,7 +28,7 @@ "background_color": "#efefef", "start_url": "/", "scope": "/", - "display": "minimal-ui", + "display": "standalone", "theme_color": "#3367d6", "screenshots" : [ { diff --git a/public/scripts/main.js b/public/scripts/main.js index 9def333..750d7f9 100644 --- a/public/scripts/main.js +++ b/public/scripts/main.js @@ -101,7 +101,7 @@ class PairDrop { } onPwaInstallable(e) { - if (!window.matchMedia('(display-mode: minimal-ui)').matches) { + if (!window.matchMedia('(display-mode: standalone)').matches) { // only display install btn when not installed this.$headerInstallBtn.removeAttribute('hidden'); this.$headerInstallBtn.addEventListener('click', () => { From 9b3571feacc76d4981adbe162f94b64510248752 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 16 May 2024 19:37:32 +0200 Subject: [PATCH 47/53] Refactor BrowserTabsConnector and PeersManager --- public/scripts/browser-tabs-connector.js | 21 +++++-- public/scripts/network.js | 72 ++++++++++++++++++------ public/scripts/ui-main.js | 12 ++-- 3 files changed, 75 insertions(+), 30 deletions(-) diff --git a/public/scripts/browser-tabs-connector.js b/public/scripts/browser-tabs-connector.js index acc0005..0329319 100644 --- a/public/scripts/browser-tabs-connector.js +++ b/public/scripts/browser-tabs-connector.js @@ -2,18 +2,27 @@ class BrowserTabsConnector { constructor() { this.bc = new BroadcastChannel('pairdrop'); this.bc.addEventListener('message', e => this._onMessage(e)); - Events.on('broadcast-send', e => this._broadcastSend(e.detail)); + Events.on('broadcast-send', e => this._broadcastSend(e.detail.type, e.detail.data)); + Events.on('broadcast-self-display-name-changed', e => this._onBroadcastSelfDisplayNameChanged(e.detail.displayName)); } - _broadcastSend(message) { - this.bc.postMessage(message); + _broadcastSend(type, data) { + this.bc.postMessage({ type, data }); + } + + _onBroadcastSelfDisplayNameChanged(displayName) { + this._broadcastSend('self-display-name-changed', { displayName: displayName }); } _onMessage(e) { - Logger.debug('Broadcast:', e.data) - switch (e.data.type) { + const type = e.data.type; + const data = e.data.data; + + Logger.debug('Broadcast:', type, data); + + switch (type) { case 'self-display-name-changed': - Events.fire('self-display-name-changed', e.data.detail); + Events.fire('self-display-name-changed', data.displayName); break; } } diff --git a/public/scripts/network.js b/public/scripts/network.js index af4900c..be2ae21 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -550,7 +550,7 @@ class Peer { } Events.fire('peer-display-name-changed', {peerId: this._peerId, displayName: message.displayName}); - Events.fire('notify-peer-display-name-changed', this._peerId); + Events.fire('notify-display-name-changed', { recipient: this._peerId }); } _sendState() { @@ -1487,13 +1487,16 @@ class WSPeer extends Peer { class PeersManager { constructor(serverConnection) { - this.peers = {}; this._server = serverConnection; + this.peers = {}; + this._device = { + originalDisplayName: '', + displayName: '', + publicRoomId: null + }; + Events.on('signal', e => this._onSignal(e.detail)); Events.on('peers', e => this._onPeers(e.detail)); - Events.on('files-selected', e => this._onFilesSelected(e.detail)); - Events.on('respond-to-files-transfer-request', e => this._onRespondToFileTransferRequest(e.detail)) - Events.on('send-text', e => this._onSendText(e.detail)); Events.on('peer-left', e => this._onPeerLeft(e.detail)); Events.on('peer-joined', e => this._onPeerJoined(e.detail)); Events.on('peer-connected', e => this._onPeerConnected(e.detail.peerId)); @@ -1505,16 +1508,25 @@ class PeersManager { // peer closes connection Events.on('secret-room-deleted', e => this._onSecretRoomDeleted(e.detail)); - Events.on('room-secret-regenerated', e => this._onRoomSecretRegenerated(e.detail)); + + // peer Events.on('display-name', e => this._onDisplayName(e.detail.displayName)); - Events.on('self-display-name-changed', e => this._notifyPeersDisplayNameChanged(e.detail)); - Events.on('notify-peer-display-name-changed', e => this._notifyPeerDisplayNameChanged(e.detail)); + Events.on('self-display-name-changed', e => this._notifyPeersDisplayNameChanged(e.detail.displayName)); + Events.on('notify-display-name-changed', e => this._notifyPeerDisplayNameChanged(e.detail.recipient)); Events.on('auto-accept-updated', e => this._onAutoAcceptUpdated(e.detail.roomSecret, e.detail.autoAccept)); + + // transfer + Events.on('send-text', e => this._onSendText(e.detail)); + Events.on('files-selected', e => this._onFilesSelected(e.detail)); + Events.on('respond-to-files-transfer-request', e => this._onRespondToFileTransferRequest(e.detail)) + + // websocket connection Events.on('ws-disconnected', _ => this._onWsDisconnected()); Events.on('ws-relay', e => this._onWsRelay(e.detail.peerId, e.detail.message)); Events.on('ws-config', e => this._onWsConfig(e.detail)); + // no-sleep Events.on('evaluate-no-sleep', _ => this._onEvaluateNoSleep()); } @@ -1664,25 +1676,34 @@ class PeersManager { } _onRoomSecretsDeleted(roomSecrets) { - for (let i=0; i 1) { peer._removeRoomType(roomType); @@ -1710,7 +1731,10 @@ class PeersManager { } _notifyPeersDisplayNameChanged(newDisplayName) { - this._displayName = newDisplayName ? newDisplayName : this._originalDisplayName; + this._device.displayName = newDisplayName + ? newDisplayName + : this._device.originalDisplayName; + for (const peerId in this.peers) { this._notifyPeerDisplayNameChanged(peerId); } @@ -1719,23 +1743,35 @@ class PeersManager { _notifyPeerDisplayNameChanged(peerId) { const peer = this.peers[peerId]; if (!peer) return; - this.peers[peerId]._sendDisplayName(this._displayName); + this.peers[peerId]._sendDisplayName(this._device.displayName); } _onDisplayName(displayName) { - this._originalDisplayName = displayName; + this._device.originalDisplayName = displayName; // if the displayName has not been changed (yet) set the displayName to the original displayName - if (!this._displayName) this._displayName = displayName; + if (!this._device.displayName) this._device.displayName = displayName; } _onAutoAcceptUpdated(roomSecret, autoAccept) { - const peerId = this._getPeerIdsFromRoomId(roomSecret)[0]; + let peerIds = this._getPeerIdsFromRoomId(roomSecret); + const peerId = this._removePeerIdsSameBrowser(peerIds)[0]; if (!peerId) return; this.peers[peerId]._setAutoAccept(autoAccept); } + _removePeerIdsSameBrowser(peerIds) { + let peerIdsNotSameBrowser = []; + for (let i = 0; i < peerIds.length; i++) { + const peer = this.peers[peerIds[i]]; + if (!peer._isSameBrowser()) { + peerIdsNotSameBrowser.push(peerIds[i]); + } + } + return peerIdsNotSameBrowser; + } + _getPeerIdsFromRoomId(roomId) { if (!roomId) return []; diff --git a/public/scripts/ui-main.js b/public/scripts/ui-main.js index 93cafb1..a43e727 100644 --- a/public/scripts/ui-main.js +++ b/public/scripts/ui-main.js @@ -205,7 +205,7 @@ class FooterUI { this.$displayName.addEventListener('blur', e => this._saveDisplayName(e.target.innerText)); Events.on('display-name', e => this._onDisplayName(e.detail.displayName)); - Events.on('self-display-name-changed', e => this._insertDisplayName(e.detail)); + Events.on('self-display-name-changed', e => this._insertDisplayName(e.detail.displayName)); // Load saved display name on page load Events.on('ws-connected', _ => this._loadSavedDisplayName()); @@ -239,7 +239,7 @@ class FooterUI { if (!displayName) return; Logger.debug("Retrieved edited display name:", displayName) - Events.fire('self-display-name-changed', displayName); + Events.fire('self-display-name-changed', { displayName: displayName }); } _onDisplayName(displayName){ @@ -280,8 +280,8 @@ class FooterUI { Events.fire('notify-user', Localization.getTranslation("notifications.display-name-changed-temporarily")); }) .finally(() => { - Events.fire('self-display-name-changed', newDisplayName); - Events.fire('broadcast-send', {type: 'self-display-name-changed', detail: newDisplayName}); + Events.fire('self-display-name-changed', { displayName: newDisplayName }); + Events.fire('broadcast-self-display-name-changed', { displayName: newDisplayName }); }); } else { @@ -292,8 +292,8 @@ class FooterUI { }) .finally(() => { Events.fire('notify-user', Localization.getTranslation("notifications.display-name-random-again")); - Events.fire('self-display-name-changed', ''); - Events.fire('broadcast-send', {type: 'self-display-name-changed', detail: ''}); + Events.fire('self-display-name-changed', { displayName: '' }); + Events.fire('broadcast-self-display-name-changed', { displayName: '' }); }); } } From be381ea43822f84e2dca6b8d7126ffa781a03e6a Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 16 May 2024 19:44:43 +0200 Subject: [PATCH 48/53] When switching public rooms disconnect from devices in old room (fixes #298) --- public/scripts/network.js | 8 +++++++- public/scripts/ui.js | 31 ++++++++++++++++--------------- server/ws-server.js | 3 +-- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index be2ae21..2e5d036 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -1502,9 +1502,12 @@ class PeersManager { Events.on('peer-connected', e => this._onPeerConnected(e.detail.peerId)); Events.on('peer-disconnected', e => this._onPeerDisconnected(e.detail)); + // ROOMS + Events.on('join-public-room', e => this._onJoinPublicRoom(e.detail.roomId)); + // this device closes connection Events.on('room-secrets-deleted', e => this._onRoomSecretsDeleted(e.detail)); - Events.on('leave-public-room', e => this._onLeavePublicRoom(e.detail)); + Events.on('leave-public-room', _ => this._onLeavePublicRoom()); // peer closes connection Events.on('secret-room-deleted', e => this._onSecretRoomDeleted(e.detail)); @@ -1682,6 +1685,9 @@ class PeersManager { } _onJoinPublicRoom(roomId) { + if (roomId !== this._device.publicRoomId) { + this._disconnectFromPublicRoom(); + } this._device.publicRoomId = roomId; } diff --git a/public/scripts/ui.js b/public/scripts/ui.js index 10b8f46..b70cb1d 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -2046,8 +2046,8 @@ class PublicRoomDialog extends Dialog { Events.on('keydown', e => this._onKeyDown(e)); Events.on('public-room-created', e => this._onPublicRoomCreated(e.detail)); - Events.on('peers', e => this._onPeers(e.detail)); - Events.on('peer-joined', e => this._onPeerJoined(e.detail.peer, e.detail.roomId)); + Events.on('peers', e => this._onPeers(e.detail.peers, e.detail.roomId)); + Events.on('peer-joined', e => this._onPeerJoined(e.detail.roomId)); Events.on('public-room-id-invalid', e => this._onPublicRoomIdInvalid(e.detail)); Events.on('public-room-left', _ => this._onPublicRoomLeft()); this.$el.addEventListener('paste', e => this._onPaste(e)); @@ -2177,29 +2177,30 @@ class PublicRoomDialog extends Dialog { } } - _onPeers(message) { - message.peers.forEach(messagePeer => { - this._evaluateJoinedPeer(messagePeer.id, message.roomId); - }); + _onPeers(peers, roomId) { + // Do not evaluate if creating new room + if (this.roomId && !peers.length) return; + + this._evaluateJoinedPeer(roomId); } - _onPeerJoined(peer, roomId) { - this._evaluateJoinedPeer(peer.id, roomId); + _onPeerJoined(roomId) { + this._evaluateJoinedPeer(roomId); } - _evaluateJoinedPeer(peerId, roomId) { - const isInitiatedRoomId = roomId === this.roomId; - const isJoinedRoomId = roomId === this.roomIdJoin; + _evaluateJoinedPeer(roomId) { + const peerJoinedThisRoom = roomId === this.roomId; + const switchedToOtherRoom = roomId === this.roomIdJoin; - if (!peerId || !roomId || (!isInitiatedRoomId && !isJoinedRoomId)) return; + if (!roomId || (!peerJoinedThisRoom && !switchedToOtherRoom)) return; this.hide(); sessionStorage.setItem('public_room_id', roomId); - if (isJoinedRoomId) { + if (switchedToOtherRoom) { + this.roomIdJoin = null; this.roomId = roomId; - this.roomIdJoin = false; this._setKeyAndQrCode(); } } @@ -2212,7 +2213,7 @@ class PublicRoomDialog extends Dialog { } _leavePublicRoom() { - Events.fire('leave-public-room', this.roomId); + Events.fire('leave-public-room'); } _onPublicRoomLeft() { diff --git a/server/ws-server.js b/server/ws-server.js index 589c424..a55b55d 100644 --- a/server/ws-server.js +++ b/server/ws-server.js @@ -251,7 +251,6 @@ export default class PairDropWsServer { return; } - this._leavePublicRoom(sender); this._joinPublicRoom(sender, message.publicRoomId); } @@ -312,7 +311,7 @@ export default class PairDropWsServer { _joinPublicRoom(peer, publicRoomId) { // prevent joining of 2 public rooms simultaneously - this._leavePublicRoom(peer); + this._leavePublicRoom(peer, true); this._joinRoom(peer, 'public-id', publicRoomId); From 5f6d3303866105759a8f86c2c5716cade919d0c9 Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 16 May 2024 20:47:10 +0200 Subject: [PATCH 49/53] Fix translations in default locale --- public/scripts/localization.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/localization.js b/public/scripts/localization.js index f230add..12a9a4c 100644 --- a/public/scripts/localization.js +++ b/public/scripts/localization.js @@ -78,7 +78,7 @@ class Localization { static async setLocale(newLocale) { if (newLocale === Localization.locale) return false; - Localization.defaultTranslations = await Localization.fetchTranslationsFor(Localization.defaultLocale); + Localization.translationsDefaultLocale = await Localization.fetchTranslationsFor(Localization.localeDefault); const newTranslations = await Localization.fetchTranslationsFor(newLocale); From 51299bcf733be433b240fffa1c9dcf1ff20002df Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Thu, 16 May 2024 20:48:02 +0200 Subject: [PATCH 50/53] Refactor static variable names --- public/scripts/localization.js | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/public/scripts/localization.js b/public/scripts/localization.js index 12a9a4c..5d5ee57 100644 --- a/public/scripts/localization.js +++ b/public/scripts/localization.js @@ -2,28 +2,28 @@ class Localization { constructor() { Localization.$htmlRoot = document.querySelector('html'); - Localization.defaultLocale = "en"; - Localization.supportedLocales = ["ar", "ca", "de", "en", "es", "fr", "id", "it", "ja", "kn", "nb", "nl", "pt-BR", "ro", "ru", "tr", "zh-CN"]; - Localization.supportedLocalesRtl = ["ar"]; + Localization.localeDefault = "en"; + Localization.localesSupported = ["ar", "ca", "de", "en", "es", "fr", "id", "it", "ja", "kn", "nb", "nl", "pt-BR", "ro", "ru", "tr", "zh-CN"]; + Localization.localesRtl = ["ar"]; Localization.translations = {}; Localization.translationsDefaultLocale = {}; - Localization.systemLocale = Localization.getSupportedOrDefaultLocales(navigator.languages); + Localization.localeSystem = Localization.getSupportedOrDefaultLocales(navigator.languages); let storedLanguageCode = localStorage.getItem('language_code'); - Localization.initialLocale = storedLanguageCode && Localization.localeIsSupported(storedLanguageCode) + Localization.localeInitial = storedLanguageCode && Localization.localeIsSupported(storedLanguageCode) ? storedLanguageCode - : Localization.systemLocale; + : Localization.localeSystem; } static localeIsSupported(locale) { - return Localization.supportedLocales.indexOf(locale) > -1; + return Localization.localesSupported.indexOf(locale) > -1; } static localeIsRtl(locale) { - return Localization.supportedLocalesRtl.indexOf(locale) > -1; + return Localization.localesRtl.indexOf(locale) > -1; } static currentLocaleIsRtl() { @@ -31,7 +31,7 @@ class Localization { } static currentLocaleIsDefault() { - return Localization.locale === Localization.defaultLocale + return Localization.locale === Localization.localeDefault } static getSupportedOrDefaultLocales(locales) { @@ -44,15 +44,15 @@ class Localization { // If there is no perfect match for browser locales, try generic locales first before resorting to the default locale return locales.find(Localization.localeIsSupported) || localesGeneric.find(Localization.localeIsSupported) - || Localization.defaultLocale; + || Localization.localeDefault; } async setInitialTranslation() { - await Localization.setTranslation(Localization.initialLocale) + await Localization.setTranslation(Localization.localeInitial) } static async setTranslation(locale) { - if (!locale) locale = Localization.systemLocale; + if (!locale) locale = Localization.localeSystem; await Localization.setLocale(locale) await Localization.translatePage(); @@ -68,7 +68,7 @@ class Localization { Logger.debug("Page successfully translated", - `System language: ${Localization.systemLocale}`, + `System language: ${Localization.localeSystem}`, `Selected language: ${locale}` ); @@ -192,7 +192,7 @@ class Localization { else { // Is not default locale yet // Get translation for default language with same arguments - Logger.debug(`Using default language ${Localization.defaultLocale.toUpperCase()} instead.`); + Logger.debug(`Using default language ${Localization.localeDefault.toUpperCase()} instead.`); translation = this.getTranslation(key, attr, data, true); } } @@ -202,7 +202,7 @@ class Localization { static logTranslationMissingOrBroken(key, attr, data, useDefault) { let usedLocale = useDefault - ? Localization.defaultLocale.toUpperCase() + ? Localization.localeDefault.toUpperCase() : Localization.locale.toUpperCase(); Logger.warn(`Missing or broken translation for language ${usedLocale}.\n`, 'key:', key, 'attr:', attr, 'data:', data); From 76c47c9623a1383a06a9dcbc9967082d25f5d3ca Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Sun, 14 Jul 2024 18:04:03 +0200 Subject: [PATCH 51/53] Rewrite FileDigester to tidy up code, be able to delete files in OPFS onPageHide and on abort of file transfer --- public/scripts/browser-tabs-connector.js | 5 + public/scripts/network.js | 321 ++++++++++++++++------- public/scripts/sw-file-digester.js | 106 ++++++-- public/scripts/ui.js | 18 +- public/scripts/util.js | 23 ++ 5 files changed, 346 insertions(+), 127 deletions(-) diff --git a/public/scripts/browser-tabs-connector.js b/public/scripts/browser-tabs-connector.js index acc0005..7872da3 100644 --- a/public/scripts/browser-tabs-connector.js +++ b/public/scripts/browser-tabs-connector.js @@ -25,6 +25,11 @@ class BrowserTabsConnector { : false; } + static isOnlyTab() { + let peerIdsBrowser = JSON.parse(localStorage.getItem('peer_ids_browser')); + return peerIdsBrowser.length <= 1; + } + static async addPeerIdToLocalStorage() { const peerId = sessionStorage.getItem('peer_id'); if (!peerId) return false; diff --git a/public/scripts/network.js b/public/scripts/network.js index 663b027..056d608 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -352,7 +352,7 @@ class Peer { clearInterval(this._updateStatusTextInterval); - this._transferStatusInterval = null; + this._updateStatusTextInterval = null; this._bytesTotal = 0; this._bytesReceivedFiles = 0; this._timeStartTransferComplete = null; @@ -366,9 +366,13 @@ class Peer { // tidy up receiver this._pendingRequest = null; this._acceptedRequest = null; - this._digester = null; this._filesReceived = []; + if (this._digester) { + this._digester.cleanUp(); + this._digester = null; + } + // disable NoSleep if idle Events.fire('evaluate-no-sleep'); } @@ -625,6 +629,11 @@ class Peer { _abortTransfer() { Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'error'}); + + if (this._digester) { + this._digester.abort(); + } + this._reset(); } @@ -713,7 +722,7 @@ class Peer { for (let i = 0; i < files.length; i++) { header.push({ - name: files[i].name, + displayName: files[i].name, mime: files[i].type, size: files[i].size }); @@ -796,7 +805,7 @@ class Peer { this._sendMessage({ type: 'transfer-header', size: file.size, - name: file.name, + displayName: file.name, mime: file.type }); } @@ -866,8 +875,12 @@ class Peer { return; } + this.fileDigesterWorkerSupported = await SWFileDigester.isSupported(); + + Logger.debug('Digesting files via service workers is', this.fileDigesterWorkerSupported ? 'supported' : 'NOT supported'); + // Check if each file must be loaded into RAM completely. This might lead to a page crash (Memory limit iOS Safari: ~380 MB) - if (!(await FileDigesterWorker.isSupported())) { + if (!this.fileDigesterWorkerSupported) { Logger.warn('Big file transfers might exceed the RAM of the receiver. Use a secure context (https) and do not use private tabs to prevent this.'); // Check if page will crash on iOS @@ -952,10 +965,25 @@ class Peer { } _addFileDigester(header) { - this._digester = new FileDigester({size: header.size, name: header.name, mime: header.mime}, - fileBlob => this._fileReceived(fileBlob), - bytesReceived => this._sendReceiveConfirmation(bytesReceived) - ); + this._digester = this.fileDigesterWorkerSupported + ? new FileDigesterViaWorker( + { + size: header.size, + name: header.displayName, + mime: header.mime + }, + file => this._fileReceived(file), + bytesReceived => this._sendReceiveConfirmation(bytesReceived) + ) + : new FileDigesterViaBuffer( + { + size: header.size, + name: header.displayName, + mime: header.mime + }, + file => this._fileReceived(file), + bytesReceived => this._sendReceiveConfirmation(bytesReceived) + ); } _sendReceiveConfirmation(bytesReceived) { @@ -1025,7 +1053,7 @@ class Peer { const sameSize = header.size === acceptedHeader.size; const sameType = header.mime === acceptedHeader.mime; - const sameName = header.name === acceptedHeader.name; + const sameName = header.displayName === acceptedHeader.displayName; return sameSize && sameType && sameName; } @@ -1045,7 +1073,7 @@ class Peer { Logger.log(`File received.\n\nSize: ${size} MB\tDuration: ${duration} s\tSpeed: ${speed} MB/s`); // include for compatibility with 'Snapdrop & PairDrop for Android' app - Events.fire('file-received', file); + Events.fire('file-received', {name: file.displayName, size: file.size}); this._filesReceived.push(file); @@ -1605,6 +1633,9 @@ class PeersManager { Events.on('ws-config', e => this._onWsConfig(e.detail)); Events.on('evaluate-no-sleep', _ => this._onEvaluateNoSleep()); + + // clean up on page hide + Events.on('pagehide', _ => this._onPageHide()); } _onWsConfig(wsConfig) { @@ -1625,6 +1656,13 @@ class PeersManager { NoSleepUI.disable(); } + _onPageHide() { + // Clear OPFS directory ONLY if this is the last PairDrop Browser tab + if (!BrowserTabsConnector.isOnlyTab()) return; + + SWFileDigester.clearDirectory(); + } + _refreshPeer(isCaller, peerId, roomType, roomId) { const peer = this.peers[peerId]; const roomTypesDiffer = Object.keys(peer._roomIds)[0] !== roomType; @@ -1947,7 +1985,6 @@ class FileChunkerWS extends FileChunker { class FileDigester { constructor(meta, fileCompleteCallback, sendReceiveConfirmationCallback) { - this._buffer = []; this._bytesReceived = 0; this._bytesReceivedSinceLastTime = 0; this._maxBytesWithoutConfirmation = 1048576; // 1 MB @@ -1958,8 +1995,9 @@ class FileDigester { this._sendReceiveConfimationCallback = sendReceiveConfirmationCallback; } - unchunk(chunk) { - this._buffer.push(chunk); + unchunk(chunk) {} + + evaluateChunkSize(chunk) { this._bytesReceived += chunk.byteLength; this._bytesReceivedSinceLastTime += chunk.byteLength; @@ -1972,70 +2010,152 @@ class FileDigester { this._sendReceiveConfimationCallback(this._bytesReceived); this._bytesReceivedSinceLastTime = 0; } + } - // File not completely received -> Wait for next chunk. - if (this._bytesReceived < this._size) return; + isFileReceivedCompletely() { + return this._bytesReceived >= this._size; + } - // We are done receiving. Preferably use a file worker to process the file to prevent exceeding of available RAM - FileDigesterWorker.isSupported() - .then(supported => { - if (!supported) { - this.processFileViaMemory(); - return; - } - this.processFileViaWorker(); - }); + cleanUp() {} + + abort() {} +} + +class FileDigesterViaBuffer extends FileDigester { + constructor(meta, fileCompleteCallback, sendReceiveConfirmationCallback) { + super(meta, fileCompleteCallback, sendReceiveConfirmationCallback); + this._buffer = []; + } + + unchunk(chunk) { + this._buffer.push(chunk); + this.evaluateChunkSize(chunk); + + // If file is not completely received -> Wait for next chunk. + if (!this.isFileReceivedCompletely()) return; + + this.processFileViaMemory(); } processFileViaMemory() { // Loads complete file into RAM which might lead to a page crash (Memory limit iOS Safari: ~380 MB) - const file = new File(this._buffer, this._name, { - type: this._mime, - lastModified: new Date().getTime() - }) + const file = new File( + this._buffer, + this._name, + { + type: this._mime, + lastModified: new Date().getTime() + } + ); + file.displayName = this._name + this._fileCompleteCallback(file); } - processFileViaWorker() { - const fileDigesterWorker = new FileDigesterWorker(); - fileDigesterWorker.digestFileBuffer(this._buffer, this._name) - .then(file => { - this._fileCompleteCallback(file); - }) - .catch(reason => { - Logger.warn(reason); - this.processFileViaWorker(); - }) + cleanUp() { + this._buffer = []; + } + + abort() { + this.cleanUp(); } } -class FileDigesterWorker { +class FileDigesterViaWorker extends FileDigester { + constructor(meta, fileCompleteCallback, sendReceiveConfirmationCallback) { + super(meta, fileCompleteCallback, sendReceiveConfirmationCallback); + this._fileDigesterWorker = new SWFileDigester(); + } - constructor() { + unchunk(chunk) { + this._fileDigesterWorker + .nextChunk(chunk, this._bytesReceived) + .then(_ => { + this.evaluateChunkSize(chunk); + + // If file is not completely received -> Wait for next chunk. + if (!this.isFileReceivedCompletely()) return; + + this.processFileViaWorker(); + }); + } + + processFileViaWorker() { + this._fileDigesterWorker + .getFile() + .then(file => { + // Save id and displayName to file to be able to truncate file later + file.id = file.name; + file.displayName = this._name; + + this._fileCompleteCallback(file); + }) + .catch(e => { + Logger.error("Error in SWFileDigester:", e); + this.cleanUp(); + }); + } + + cleanUp() { + this._fileDigesterWorker.cleanUp(); + } + + abort() { + // delete and clean up (included in deletion) + this._fileDigesterWorker.deleteFile().then((id) => { + Logger.debug("File deleted after abort:", id); + }); + } +} + + +class SWFileDigester { + + static fileWorkers = []; + + constructor(id = null) { // Use service worker to prevent loading the complete file into RAM - this.fileWorker = new Worker("scripts/sw-file-digester.js"); + // Uses origin private file system (OPFS) as storage endpoint + + if (!id) { + // Generate random uuid to save file on disk + // Create only one service worker per file to prevent problems with accessHandles + id = generateUUID(); + SWFileDigester.fileWorkers[id] = new Worker("scripts/sw-file-digester.js"); + } + + this.id = id; + this.fileWorker = SWFileDigester.fileWorkers[id]; this.fileWorker.onmessage = (e) => { switch (e.data.type) { case "support": this.onSupport(e.data.supported); break; - case "part": - this.onPart(e.data.part); + case "chunk-written": + this.onChunkWritten(e.data.offset); break; case "file": this.onFile(e.data.file); break; case "file-deleted": - this.onFileDeleted(); + this.onFileDeleted(e.data.id); break; case "error": this.onError(e.data.error); break; + case "directory-cleared": + this.onDirectoryCleared(); + break; } } } + onError(error) { + // an error occurred. + Logger.error(error); + } + static isSupported() { // Check if web worker is supported and supports specific functions return new Promise(async resolve => { @@ -2044,7 +2164,7 @@ class FileDigesterWorker { return; } - const fileDigesterWorker = new FileDigesterWorker(); + const fileDigesterWorker = new SWFileDigester(); resolve(await fileDigesterWorker.checkSupport()); @@ -2068,75 +2188,88 @@ class FileDigesterWorker { this.resolveSupport = null; } - digestFileBuffer(buffer, fileName) { - return new Promise((resolve, reject) => { - this.resolveFile = resolve; - this.rejectFile = reject; - - this.i = 0; - this.offset = 0; - - this.buffer = buffer; - this.fileName = fileName; - - this.sendPart(this.buffer[0], 0); - }) + nextChunk(chunk, offset) { + return new Promise(resolve => { + this.digestChunk(chunk, offset); + resolve(); + }); } - - sendPart(buffer, offset) { + digestChunk(chunk, offset) { this.fileWorker.postMessage({ - type: "part", - name: this.fileName, - buffer: buffer, + type: "chunk", + id: this.id, + chunk: chunk, offset: offset }); } - getFile() { - this.fileWorker.postMessage({ - type: "get-file", - name: this.fileName, - }); + onChunkWritten(chunkOffset) { + Logger.debug("Chunk written at offset", chunkOffset); } - deleteFile() { - this.fileWorker.postMessage({ - type: "delete-file", - name: this.fileName + getFile() { + return new Promise(resolve => { + this.resolveFile = resolve; + + this.fileWorker.postMessage({ + type: "get-file", + id: this.id, + }); }) } - onPart(part) { - if (this.i < this.buffer.length - 1) { - // process next chunk - this.offset += part.byteLength; - this.i++; - this.sendPart(this.buffer[this.i], this.offset); - return; - } - - // File processing complete -> retrieve completed file - this.getFile(); + async getFileById(id) { + const swFileDigester = new SWFileDigester(id); + return await swFileDigester.getFile(); } onFile(file) { - this.buffer = []; this.resolveFile(file); - this.deleteFile(); } - onFileDeleted() { + deleteFile() { + return new Promise(resolve => { + this.resolveDeletion = resolve; + this.fileWorker.postMessage({ + type: "delete-file", + id: this.id + }); + }); + } + + static async deleteFileById(id) { + const swFileDigester = new SWFileDigester(id); + return await swFileDigester.deleteFile(); + } + + cleanUp() { + // terminate service worker + this.fileWorker.terminate(); + delete SWFileDigester.fileWorkers[this.id]; + } + + onFileDeleted(id) { // File Digestion complete -> Tidy up - this.fileWorker.terminate(); + Logger.debug("File deleted:", id); + this.resolveDeletion(id); + this.cleanUp(); } - onError(error) { - // an error occurred. - Logger.error(error); + static clearDirectory() { + for (let i = 0; i < SWFileDigester.fileWorkers.length; i++) { + SWFileDigester.fileWorkers[i].terminate(); + } + SWFileDigester.fileWorkers = []; - // Use memory method instead and terminate service worker. - this.fileWorker.terminate(); - this.rejectFile("Failed to process file via service-worker. Do not use Firefox private mode to prevent this."); + const swFileDigester = new SWFileDigester(); + swFileDigester.fileWorker.postMessage({ + type: "clear-directory", + }); + } + + onDirectoryCleared() { + Logger.debug("All files on OPFS truncated."); + this.cleanUp(); } } \ No newline at end of file diff --git a/public/scripts/sw-file-digester.js b/public/scripts/sw-file-digester.js index 9678b3f..ba5e544 100644 --- a/public/scripts/sw-file-digester.js +++ b/public/scripts/sw-file-digester.js @@ -1,70 +1,104 @@ +self.accessHandle = undefined; +self.messageQueue = []; +self.busy = false; + + self.addEventListener('message', async e => { + // Put message into queue if busy + if (self.busy) { + self.messageQueue.push(e.data); + return; + } + + await digestMessage(e.data); +}); + +async function digestMessage(message) { + self.busy = true; try { - switch (e.data.type) { + switch (message.type) { case "check-support": await checkSupport(); break; - case "part": - await onPart(e.data.name, e.data.buffer, e.data.offset); + case "chunk": + await onChunk(message.id, message.chunk, message.offset); break; case "get-file": - await onGetFile(e.data.name); + await onGetFile(message.id); break; case "delete-file": - await onDeleteFile(e.data.name); + await onDeleteFile(message.id); + break; + case "clear-directory": + await onClearDirectory(); break; } } catch (e) { self.postMessage({type: "error", error: e}); } -}) + + // message is digested. Digest next message. + await messageDigested(); +} + +async function messageDigested() { + if (!self.messageQueue.length) { + // no chunk in queue -> set flag to false and stop + this.busy = false; + return; + } + + // Digest next message in queue + await this.digestMessage(self.messageQueue.pop()); +} async function checkSupport() { try { - await getAccessHandle("test.txt"); + const accessHandle = await getAccessHandle("test"); self.postMessage({type: "support", supported: true}); + accessHandle.close(); } catch (e) { self.postMessage({type: "support", supported: false}); } } -async function getFileHandle(fileName) { - const root = await navigator.storage.getDirectory(); - return await root.getFileHandle(fileName, {create: true}); +async function getFileHandle(id) { + const dirHandle = await navigator.storage.getDirectory(); + return await dirHandle.getFileHandle(id, {create: true}); } -async function getAccessHandle(fileName) { - const fileHandle = await getFileHandle(fileName); +async function getAccessHandle(id) { + const fileHandle = await getFileHandle(id); - // Create FileSystemSyncAccessHandle on the file. - return await fileHandle.createSyncAccessHandle(); + if (!self.accessHandle) { + // Create FileSystemSyncAccessHandle on the file. + self.accessHandle = await fileHandle.createSyncAccessHandle(); + } + + return self.accessHandle; } -async function onPart(fileName, buffer, offset) { - const accessHandle = await getAccessHandle(fileName); +async function onChunk(id, chunk, offset) { + const accessHandle = await getAccessHandle(id); // Write the message to the end of the file. - let encodedMessage = new DataView(buffer); + let encodedMessage = new DataView(chunk); accessHandle.write(encodedMessage, { at: offset }); - // Always close FileSystemSyncAccessHandle if done. - accessHandle.close(); accessHandle.close(); - - self.postMessage({type: "part", part: encodedMessage}); - encodedMessage = null; + self.postMessage({type: "chunk-written", offset: offset}); } -async function onGetFile(fileName) { - const fileHandle = await getFileHandle(fileName); +async function onGetFile(id) { + const fileHandle = await getFileHandle(id); let file = await fileHandle.getFile(); self.postMessage({type: "file", file: file}); } -async function onDeleteFile(fileName) { - const accessHandle = await getAccessHandle(fileName); +async function onDeleteFile(id) { + const accessHandle = await getAccessHandle(id); // Truncate the file to 0 bytes accessHandle.truncate(0); @@ -75,5 +109,23 @@ async function onDeleteFile(fileName) { // Always close FileSystemSyncAccessHandle if done. accessHandle.close(); - self.postMessage({type: "file-deleted"}); + self.postMessage({type: "file-deleted", id: id}); +} + +async function onClearDirectory() { + const dirHandle = await navigator.storage.getDirectory(); + + // Iterate through directory entries and truncate all entries to 0 + for await (const [id, fileHandle] of dirHandle.entries()) { + const accessHandle = await fileHandle.createSyncAccessHandle(); + + // Truncate the file to 0 bytes + accessHandle.truncate(0); + + // Persist changes to disk. + accessHandle.flush(); + + // Always close FileSystemSyncAccessHandle if done. + accessHandle.close(); + } } \ No newline at end of file diff --git a/public/scripts/ui.js b/public/scripts/ui.js index ff93ca1..a5173f0 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -1060,7 +1060,7 @@ class ReceiveDialog extends Dialog { : Localization.getTranslation("dialogs.file-other-description-file-plural", null, {count: files.length - 1}); } - const fileName = files[0].name; + const fileName = files[0].displayName; const fileNameSplit = fileName.split('.'); const fileExtension = '.' + fileNameSplit[fileNameSplit.length - 1]; const fileStem = fileName.substring(0, fileName.length - fileExtension.length); @@ -1331,7 +1331,7 @@ class ReceiveFileDialog extends ReceiveDialog { Events.fire('notify-user', downloadSuccessfulTranslation); this.downloadSuccessful = true; - this.hide() + this.hide(); }; } @@ -1355,7 +1355,7 @@ class ReceiveFileDialog extends ReceiveDialog { _downloadFiles(files) { let tmpBtn = document.createElement("a"); for (let i = 0; i < files.length; i++) { - tmpBtn.download = files[i].name; + tmpBtn.download = files[i].displayName; tmpBtn.href = URL.createObjectURL(files[i]); tmpBtn.click(); } @@ -1435,6 +1435,7 @@ class ReceiveFileDialog extends ReceiveDialog { hide() { super.hide(); + setTimeout(async () => { this._tidyUpButtons(); this._tidyUpPreviewBox(); @@ -2651,15 +2652,20 @@ class Base64Dialog extends Dialog { this.$pasteBtn.innerText = Localization.getTranslation("dialogs.base64-processing"); } - preparePasting(type) { + preparePasting(type, useFallback = false) { const translateType = type === 'text' ? Localization.getTranslation("dialogs.base64-text") : Localization.getTranslation("dialogs.base64-files"); - if (navigator.clipboard.readText) { + if (navigator.clipboard.readText && !useFallback) { this.$pasteBtn.innerText = Localization.getTranslation("dialogs.base64-tap-to-paste", null, {type: translateType}); this._clickCallback = _ => this.processClipboard(type); - this.$pasteBtn.addEventListener('click', _ => this._clickCallback()); + this.$pasteBtn.addEventListener('click', _ => { + this._clickCallback() + .catch(_ => { + this.preparePasting(type, true); + }) + }); } else { Logger.log("`navigator.clipboard.readText()` is not available on your browser.\nOn Firefox you can set `dom.events.asyncClipboard.readText` to true under `about:config` for convenience.") diff --git a/public/scripts/util.js b/public/scripts/util.js index 6f666bf..de7e2a0 100644 --- a/public/scripts/util.js +++ b/public/scripts/util.js @@ -619,4 +619,27 @@ function isUrlValid(url) { catch (e) { return false; } +} + +// polyfill for crypto.randomUUID() +// Credits: @Briguy37 - https://stackoverflow.com/a/8809472/14678591 +function generateUUID() { + return crypto && crypto.randomUUID() + ? crypto.randomUUID() + : () => { + let + d = new Date().getTime(), + d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0; + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + let r = Math.random() * 16; + if (d > 0) { + r = (d + r) % 16 | 0; + d = Math.floor(d / 16); + } else { + r = (d2 + r) % 16 | 0; + d2 = Math.floor(d2 / 16); + } + return (c == 'x' ? r : (r & 0x7 | 0x8)).toString(16); + }); + }; } \ No newline at end of file From f7ea5191063fb17b55529dada5119cd7f74cd00f Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Sun, 14 Jul 2024 18:05:51 +0200 Subject: [PATCH 52/53] Do not stop trying to reconnect to server if offline (private networks and on same machine might still work) --- public/scripts/network.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 056d608..1559d3e 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -69,7 +69,7 @@ class ServerConnection { _connect() { clearTimeout(this._reconnectTimer); - if (this._isConnected() || this._isConnecting() || this._isOffline()) return; + if (this._isConnected() || this._isConnecting()) return; if (this._isReconnect) { Events.fire('notify-user', { message: Localization.getTranslation("notifications.connecting"), From 1d5f2e802369038dec34d09fc491350dbf8a661d Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Wed, 17 Jul 2024 17:11:21 +0200 Subject: [PATCH 53/53] Round kB/s as well --- public/scripts/network.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/public/scripts/network.js b/public/scripts/network.js index 5b1d9d4..63daa31 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -675,22 +675,23 @@ class Peer { this._statusText = statusText; } - _calculateSpeedKbPerSecond() { + _getSpeedKbPerSecond() { const timeDifferenceSeconds = (this._byteLogs[this._byteLogs.length - 1].time - this._byteLogs[0].time) / 1000; const bytesDifferenceKB = (this._byteLogs[this._byteLogs.length - 1].bytesReceived - this._byteLogs[0].bytesReceived) / 1000; - return bytesDifferenceKB / timeDifferenceSeconds; + return Math.round(bytesDifferenceKB / timeDifferenceSeconds); } - _calculateBytesLeft() { + _getBytesLeft() { return this._bytesTotal - this._byteLogs[this._byteLogs.length - 1].bytesReceived; } - _calculateSecondsLeft() { - return Math.round(this._calculateBytesLeft() / this._calculateSpeedKbPerSecond() / 1000); + _getSecondsLeft() { + return Math.round(this._getBytesLeft() / this._getSpeedKbPerSecond() / 1000); } _getSpeedString() { - const speedKBs = this._calculateSpeedKbPerSecond(); + const speedKBs = this._getSpeedKbPerSecond(); + if (speedKBs >= 1000) { let speedMBs = Math.round(speedKBs / 100) / 10; return `${speedMBs} MB/s`; // e.g. "2.2 MB/s" @@ -700,7 +701,7 @@ class Peer { } _getTimeString() { - const seconds = this._calculateSecondsLeft(); + const seconds = this._getSecondsLeft(); if (seconds > 60) { let minutes = Math.floor(seconds / 60); let secondsLeft = Math.floor(seconds % 60);