From 3c07a4199bf020637510b9cc02c2454e555e0d7d Mon Sep 17 00:00:00 2001 From: schlagmichdoch Date: Tue, 10 Jan 2023 05:07:57 +0100 Subject: [PATCH] implement device pairing via 6-digit code and qr-code --- .gitignore | 3 +- docker-compose.yml | 1 + index.js | 321 +++++++++++++++++----- public/index.html | 70 ++++- public/manifest.json | 5 +- public/scripts/network.js | 162 ++++++++--- public/scripts/qrcode.js | 2 + public/scripts/theme.js | 18 +- public/scripts/ui.js | 565 +++++++++++++++++++++++++++++++++++--- public/styles.css | 123 ++++++++- server/package-lock.json | 23 -- 11 files changed, 1098 insertions(+), 195 deletions(-) create mode 100644 public/scripts/qrcode.js delete mode 100644 server/package-lock.json diff --git a/.gitignore b/.gitignore index f246491..bd15e97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules .DS_Store fqdn.env -/docker/certs \ No newline at end of file +/docker/certs +qrcode-svg/ diff --git a/docker-compose.yml b/docker-compose.yml index 78810a0..4686fee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,4 @@ +#Todo: fix turn server version: "3" services: node: diff --git a/index.js b/index.js index 2e3e11d..58006ba 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,8 @@ var process = require('process') +var crypto = require('crypto') var {spawn} = require('child_process') var net = require('net') + // Handle SIGINT process.on('SIGINT', () => { console.info("SIGINT Received, exiting...") @@ -87,6 +89,7 @@ class SnapdropServer { this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request))); this._rooms = {}; + this._roomSecrets = {}; console.log('Snapdrop is running on port', port); } @@ -94,7 +97,8 @@ class SnapdropServer { _onConnection(peer) { this._joinRoom(peer); peer.socket.on('message', message => this._onMessage(peer, message)); - peer.socket.on('error', console.error); + peer.socket.onerror = e => console.error(e); + peer.socket.onclose = _ => console.log('disconnect'); this._keepAlive(peer); // send displayName @@ -118,74 +122,270 @@ class SnapdropServer { switch (message.type) { case 'disconnect': - this._leaveRoom(sender); + this._onDisconnect(sender); break; case 'pong': sender.lastBeat = Date.now(); break; + case 'room-secrets': + this._onRoomSecrets(sender, message); + break; + case 'room-secret-deleted': + this._onRoomSecretDeleted(sender, message); + break; + case 'room-secrets-cleared': + this._onRoomSecretsCleared(sender, message); + break; + case 'pair-device-initiate': + this._onPairDeviceInitiate(sender); + break; + case 'pair-device-join': + this._onPairDeviceJoin(sender, message); + break; + case 'pair-device-cancel': + this._onPairDeviceCancel(sender); + break; + case 'resend-peers': + this._notifyPeers(sender); + break; + case 'signal': + this._onSignal(sender, message); } + } + + _onSignal(sender, message) { + const room = message.roomType === 'ip' ? sender.ip : message.roomSecret; // relay message to recipient - if (message.to && this._rooms[sender.ip]) { - const recipientId = message.to; // TODO: sanitize - const recipient = this._rooms[sender.ip][recipientId]; + if (message.to && Peer.isValidUuid(message.to) && this._rooms[room]) { + const recipientId = message.to; + const recipient = this._rooms[room][recipientId]; delete message.to; // add sender id message.sender = sender.id; this._send(recipient, message); - return; } } - _joinRoom(peer) { - // if room doesn't exist, create it - if (!this._rooms[peer.ip]) { - this._rooms[peer.ip] = {}; + _onDisconnect(sender) { + this._leaveRoom(sender); + this._leaveAllSecretRooms(sender); + this._removeRoomKey(sender.roomKey); + sender.roomKey = null; + } + + _onRoomSecrets(sender, message) { + const roomSecrets = message.roomSecrets.filter(roomSecret => { + return /^[\x00-\x7F]{64}$/.test(roomSecret); + }) + this._joinSecretRooms(sender, roomSecrets); + } + + _onRoomSecretDeleted(sender, message) { + this._deleteSecretRoom(sender, message.roomSecret) + } + + _onRoomSecretsCleared(sender, message) { + for (let i = 0; i= 47 && r <= 57 || r >= 64 && r <= 90 || r >= 97 && r <= 122; + }); + string += String.fromCharCode.apply(String, arr); + } + return string.substring(0, length) + } + + _onPairDeviceInitiate(sender) { + let roomSecret = this.getRandomString(64); + let roomKey = this._createRoomKey(sender, roomSecret); + sender.roomKey = roomKey; + this._send(sender, { + type: 'pair-device-initiated', + roomSecret: roomSecret, + roomKey: roomKey + }); + this._joinRoom(sender, 'secret', roomSecret); + } + + _onPairDeviceJoin(sender, message) { + if (sender.roomKeyRate >= 10) { + this._send(sender, { type: 'pair-device-join-key-rate-limit' }); + return; + } + sender.roomKeyRate += 1; + setTimeout(_ => sender.roomKeyRate -= 1, 10000); + if (!this._roomSecrets[message.roomKey] || sender.id === this._roomSecrets[message.roomKey].creator.id) { + this._send(sender, { type: 'pair-device-join-key-invalid' }); + return; + } + const roomSecret = this._roomSecrets[message.roomKey].roomSecret; + const creator = this._roomSecrets[message.roomKey].creator; + this._removeRoomKey(message.roomKey); + this._send(sender, { + type: 'pair-device-joined', + roomSecret: roomSecret, + }); + this._send(creator, { + type: 'pair-device-joined', + roomSecret: roomSecret, + }); + this._joinRoom(sender, 'secret', roomSecret); + this._removeRoomKey(sender.roomKey); + } + + _onPairDeviceCancel(sender) { + if (sender.roomKey) { + this._send(sender, { + type: 'pair-device-canceled', + roomKey: sender.roomKey, + }); + this._removeRoomKey(sender.roomKey); + } + } + + _createRoomKey(creator, roomSecret) { + let roomKey; + do { + // get randomInt until keyRoom not occupied + roomKey = crypto.randomInt(1000000, 1999999).toString().substring(1); // include numbers with leading 0s + } while (roomKey in this._roomSecrets) + + this._roomSecrets[roomKey] = { + roomSecret: roomSecret, + creator: creator } + return roomKey; + } + + _removeRoomKey(roomKey) { + if (roomKey in this._roomSecrets) { + this._roomSecrets[roomKey].creator.roomKey = null + delete this._roomSecrets[roomKey]; + } + } + + _joinRoom(peer, roomType = 'ip', roomSecret = '') { + const room = roomType === 'ip' ? peer.ip : roomSecret; + + // if room doesn't exist, create it + if (!this._rooms[room]) { + this._rooms[room] = {}; + } + + this._notifyPeers(peer, roomType, roomSecret); + + // add peer to room + this._rooms[room][peer.id] = peer; + // add secret to peer + if (roomType === 'secret') { + peer.addRoomSecret(roomSecret); + } + } + + _leaveRoom(peer, roomType = 'ip', roomSecret = '') { + const room = roomType === 'ip' ? peer.ip : roomSecret; + + if (!this._rooms[room] || !this._rooms[room][peer.id]) return; + this._cancelKeepAlive(this._rooms[room][peer.id]); + + // delete the peer + delete this._rooms[room][peer.id]; + + if (roomType === 'ip') { + peer.socket.terminate(); + } + + //if room is empty, delete the room + if (!Object.keys(this._rooms[room]).length) { + delete this._rooms[room]; + } else { + // notify all other peers + for (const otherPeerId in this._rooms[room]) { + const otherPeer = this._rooms[room][otherPeerId]; + this._send(otherPeer, { + type: 'peer-left', + peerId: peer.id, + roomType: roomType, + roomSecret: roomSecret + }); + } + } + //remove secret from peer + if (roomType === 'secret') { + peer.removeRoomSecret(roomSecret); + } + } + + _notifyPeers(peer, roomType = 'ip', roomSecret = '') { + const room = roomType === 'ip' ? peer.ip : roomSecret; + if (!this._rooms[room]) return; + // notify all other peers - for (const otherPeerId in this._rooms[peer.ip]) { + for (const otherPeerId in this._rooms[room]) { if (otherPeerId === peer.id) continue; - const otherPeer = this._rooms[peer.ip][otherPeerId]; + const otherPeer = this._rooms[room][otherPeerId]; this._send(otherPeer, { type: 'peer-joined', - peer: peer.getInfo() + peer: peer.getInfo(), + roomType: roomType, + roomSecret: roomSecret }); } // notify peer about the other peers const otherPeers = []; - for (const otherPeerId in this._rooms[peer.ip]) { + for (const otherPeerId in this._rooms[room]) { if (otherPeerId === peer.id) continue; - otherPeers.push(this._rooms[peer.ip][otherPeerId].getInfo()); + otherPeers.push(this._rooms[room][otherPeerId].getInfo()); } this._send(peer, { type: 'peers', - peers: otherPeers + peers: otherPeers, + roomType: roomType, + roomSecret: roomSecret }); - - // add peer to room - this._rooms[peer.ip][peer.id] = peer; } - _leaveRoom(peer) { - if (!this._rooms[peer.ip] || !this._rooms[peer.ip][peer.id]) return; - this._cancelKeepAlive(this._rooms[peer.ip][peer.id]); + _joinSecretRooms(peer, roomSecrets) { + for (let i=0; i ''); + peer.socket.send(message); } _keepAlive(peer) { @@ -204,6 +404,7 @@ class SnapdropServer { } if (Date.now() - peer.lastBeat > 2 * timeout) { this._leaveRoom(peer); + this._leaveAllSecretRooms(peer); return; } @@ -242,7 +443,10 @@ class Peer { // for keepalive this.timerId = 0; this.lastBeat = Date.now(); - console.debug(this.name.displayName) + + this.roomSecrets = []; + this.roomKey = null; + this.roomKeyRate = 0; } _setIP(request) { @@ -258,7 +462,6 @@ class Peer { if (this.ip === '::1' || this.ip === '::ffff:127.0.0.1' || this.ip === '::1' || this.ipIsPrivate(this.ip)) { this.ip = '127.0.0.1'; } - console.debug(this.ip) } ipIsPrivate(ip) { @@ -300,10 +503,10 @@ class Peer { _setPeerId(request) { let peer_id = new URL(request.url, "http://server").searchParams.get("peer_id"); - if (peer_id && /^([0-9]|[a-f]){8}-(([0-9]|[a-f]){4}-){3}([0-9]|[a-f]){12}$/.test(peer_id)) { + if (peer_id && Peer.isValidUuid(peer_id)) { this.id = peer_id; } else { - this.id = Peer.uuid(); + this.id = crypto.randomUUID(); } } @@ -356,31 +559,21 @@ class Peer { } } - // return uuid of form xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx - static uuid() { - let uuid = '', - ii; - for (ii = 0; ii < 32; ii += 1) { - switch (ii) { - case 8: - case 20: - uuid += '-'; - uuid += (Math.random() * 16 | 0).toString(16); - break; - case 12: - uuid += '-'; - uuid += '4'; - break; - case 16: - uuid += '-'; - uuid += (Math.random() * 4 | 8).toString(16); - break; - default: - uuid += (Math.random() * 16 | 0).toString(16); - } + static isValidUuid(uuid) { + return /^([0-9]|[a-f]){8}-(([0-9]|[a-f]){4}-){3}([0-9]|[a-f]){12}$/.test(uuid); + } + + addRoomSecret(roomSecret) { + if (!roomSecret in this.roomSecrets) { + this.roomSecrets.push(roomSecret); } - return uuid; - }; + } + + removeRoomSecret(roomSecret) { + if (roomSecret in this.roomSecrets) { + delete this.roomSecrets[roomSecret]; + } + } } Object.defineProperty(String.prototype, 'hashCode', { diff --git a/public/index.html b/public/index.html index 1ad145e..5d29773 100644 --- a/public/index.html +++ b/public/index.html @@ -11,6 +11,7 @@ + @@ -43,26 +44,37 @@ - + - - + + + + + +

Open Snapdrop on other devices to send files

+
Pair devices to be discoverable on other networks
@@ -71,9 +83,50 @@ -
+
You can be discovered by everyone on this network
+ + +
+ + +

Pair Devices

+
+

000 000

+
Input this key on another device
or scan the QR-Code.
+
+
+ + + + + + +
+
Enter key from another device to continue.
+
+ + Cancel +
+
+
+
+
+ +
+ + +

Unpair Devices

+
Are you sure to unpair all devices?
+
+ + Cancel +
+
+
+
+
@@ -211,9 +264,18 @@ + + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json index 2cdcc36..ab4277f 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -26,7 +26,8 @@ }], "background_color": "#efefef", "start_url": "/", - "display": "minimal-ui", + "scope": "/", + "display": "standalone", "theme_color": "#3367d6", "share_target": { "method":"GET", @@ -37,4 +38,4 @@ "url": "url" } } -} \ No newline at end of file +} diff --git a/public/scripts/network.js b/public/scripts/network.js index 95e8cd8..053a8c3 100644 --- a/public/scripts/network.js +++ b/public/scripts/network.js @@ -9,6 +9,13 @@ class ServerConnection { Events.on('pagehide', _ => this._disconnect()); document.addEventListener('visibilitychange', _ => this._onVisibilityChange()); Events.on('reconnect', _ => this._reconnect()); + Events.on('room-secrets', e => this._sendRoomSecrets(e.detail)); + Events.on('room-secret-deleted', e => this.send({ type: 'room-secret-deleted', roomSecret: e.detail})); + Events.on('room-secrets-cleared', e => this.send({ type: 'room-secrets-cleared', roomSecrets: e.detail})); + Events.on('resend-peers', _ => this.send({ type: 'resend-peers'})); + Events.on('pair-device-initiate', _ => this._onPairDeviceInitiate()); + Events.on('pair-device-join', e => this._onPairDeviceJoin(e.detail)); + Events.on('pair-device-cancel', _ => this.send({ type: 'pair-device-cancel' })); } _connect() { @@ -25,11 +32,27 @@ class ServerConnection { _onOpen() { console.log('WS: server connected'); - if (!this.firstConnect) { - this.firstConnect = true; + Events.fire('ws-connected'); + } + + _sendRoomSecrets(roomSecrets) { + this.send({ type: 'room-secrets', roomSecrets: roomSecrets }); + } + + _onPairDeviceInitiate() { + if (!this._isConnected()) { + Events.fire('notify-user', 'You need to be online to pair devices.'); return; } - Events.fire('ws-connected'); + this.send({ type: 'pair-device-initiate' }) + } + + _onPairDeviceJoin(roomKey) { + if (!this._isConnected()) { + setTimeout(_ => this._onPairDeviceJoin(roomKey), 200); + return; + } + this.send({ type: 'pair-device-join', roomKey: roomKey }) } _onMessage(msg) { @@ -37,10 +60,10 @@ class ServerConnection { if (msg.type !== 'ping') console.log('WS:', msg); switch (msg.type) { case 'peers': - Events.fire('peers', msg.peers); + Events.fire('peers', msg); break; case 'peer-joined': - Events.fire('peer-joined', msg.peer); + Events.fire('peer-joined', msg); break; case 'peer-left': Events.fire('peer-left', msg.peerId); @@ -52,28 +75,61 @@ class ServerConnection { this.send({ type: 'pong' }); break; case 'display-name': - sessionStorage.setItem("peerId", msg.message.peerId); - Events.fire('display-name', msg); + this._onDisplayName(msg); + break; + case 'pair-device-initiated': + Events.fire('pair-device-initiated', msg); + break; + case 'pair-device-joined': + Events.fire('pair-device-joined', msg.roomSecret); + break; + case 'pair-device-join-key-invalid': + Events.fire('pair-device-join-key-invalid'); + break; + case 'pair-device-canceled': + Events.fire('pair-device-canceled', msg.roomKey); + break; + case 'pair-device-join-key-rate-limit': + Events.fire('notify-user', 'Rate limit reached. Wait 10 seconds and try again.'); + break; + case 'secret-room-deleted': + Events.fire('secret-room-deleted', msg.roomSecret); break; default: console.error('WS: unknown message type', msg); } } - send(message) { + send(msg) { if (!this._isConnected()) return; - this._socket.send(JSON.stringify(message)); + this._socket.send(JSON.stringify(msg)); + } + + _onDisplayName(msg) { + sessionStorage.setItem("peerId", msg.message.peerId); + if (window.matchMedia('(display-mode: standalone)').matches) { + // make peerId persistent when pwa installed + PersistentStorage.set('peerId', msg.message.peerId).then(peerId => { + console.log(`peerId saved to indexedDB: ${peerId}`); + }).catch(e => console.error(e)); + } + Events.fire('display-name', msg); } _endpoint() { // hack to detect if deployment or development environment const protocol = location.protocol.startsWith('https') ? 'wss' : 'ws'; const webrtc = window.isRtcSupported ? '/webrtc' : '/fallback'; - let url = new URL(protocol + '://' + location.host + location.pathname + 'server' + webrtc); - if (sessionStorage.getItem('peerId')) { - url.searchParams.append('peer_id', sessionStorage.getItem('peerId')) + let ws_url = new URL(protocol + '://' + location.host + location.pathname + 'server' + webrtc); + const peerId = this._peerId(); + if (peerId) { + ws_url.searchParams.append('peer_id', peerId) } - return url.toString(); + return ws_url.toString(); + } + + _peerId() { + return sessionStorage.getItem("peerId"); } _disconnect() { @@ -81,7 +137,7 @@ class ServerConnection { this._socket.onclose = null; this._socket.close(); this._socket = null; - Events.fire('ws-disconnect'); + Events.fire('ws-disconnected'); } _onDisconnect() { @@ -89,7 +145,7 @@ class ServerConnection { Events.fire('notify-user', 'Connection lost. Retry in 5 seconds...'); clearTimeout(this._reconnectTimer); this._reconnectTimer = setTimeout(_ => this._connect(), 5000); - Events.fire('ws-disconnect'); + Events.fire('ws-disconnected'); } _onVisibilityChange() { @@ -117,9 +173,11 @@ class ServerConnection { class Peer { - constructor(serverConnection, peerId) { + constructor(serverConnection, peerId, roomType, roomSecret) { this._server = serverConnection; this._peerId = peerId; + this._roomType = roomType; + this._roomSecret = roomSecret; this._filesQueue = []; this._busy = false; } @@ -262,8 +320,8 @@ class Peer { class RTCPeer extends Peer { - constructor(serverConnection, peerId) { - super(serverConnection, peerId); + constructor(serverConnection, peerId, roomType, roomSecret) { + super(serverConnection, peerId, roomType, roomSecret); if (!peerId) return; // we will listen for a caller this._connect(peerId, true); } @@ -283,7 +341,7 @@ class RTCPeer extends Peer { this._peerId = peerId; this._conn = new RTCPeerConnection(RTCPeer.config); this._conn.onicecandidate = e => this._onIceCandidate(e); - this._conn.onconnectionstatechange = e => this._onConnectionStateChange(e); + this._conn.onconnectionstatechange = _ => this._onConnectionStateChange(); this._conn.oniceconnectionstatechange = e => this._onIceConnectionStateChange(e); } @@ -342,7 +400,7 @@ class RTCPeer extends Peer { this._connect(this._peerId, true); // reopen the channel } - _onConnectionStateChange(e) { + _onConnectionStateChange() { console.log('RTC: state changed:', this._conn.connectionState); switch (this._conn.connectionState) { case 'disconnected': @@ -379,11 +437,15 @@ class RTCPeer extends Peer { _sendSignal(signal) { signal.type = 'signal'; signal.to = this._peerId; + signal.roomType = this._roomType; + signal.roomSecret = this._roomSecret; this._server.send(signal); } refresh() { // check if channel is open. otherwise create one + console.debug("refresh:"); + console.debug(this._conn); if (this._isConnected() || this._isConnecting()) return; this._connect(this._peerId, this._isCaller); } @@ -397,6 +459,15 @@ class RTCPeer extends Peer { } } +class WSPeer extends Peer { + _send(message) { + message.to = this._peerId; + message.roomType = this._roomType; + message.roomSecret = this._roomSecret; + this._server.send(message); + } +} + class PeersManager { constructor(serverConnection) { @@ -408,26 +479,40 @@ class PeersManager { Events.on('send-text', e => this._onSendText(e.detail)); Events.on('peer-joined', e => this._onPeerJoined(e.detail)); Events.on('peer-left', e => this._onPeerLeft(e.detail)); - Events.on('ws-disconnect', _ => this._clearPeers()); + Events.on('ws-disconnected', _ => this._clearPeers()); + Events.on('secret-room-deleted', e => this._onSecretRoomDeleted(e.detail)); } _onMessage(message) { - if (!this.peers[message.sender]) { - this.peers[message.sender] = new RTCPeer(this._server); - } + this._refreshOrCreatePeer(message.sender, message.roomType, message.roomSecret); this.peers[message.sender].onServerMessage(message); } - _onPeers(peers) { - peers.forEach(peer => { + _refreshOrCreatePeer(id, roomType, roomSecret) { + if (!this.peers[id]) { + this.peers[id] = new RTCPeer(this._server, undefined, roomType, roomSecret); + }else if (this.peers[id]._roomType !== roomType) { + this.peers[id]._roomType = roomType; + this.peers[id]._roomSecret = roomSecret; + } + } + + _onPeers(msg) { + console.debug(msg) + msg.peers.forEach(peer => { if (this.peers[peer.id]) { - this.peers[peer.id].refresh(); + if (this.peers[peer.id].roomType === msg.roomType) { + this.peers[peer.id].refresh(); + } else { + this.peers[peer.id].roomType = msg.roomType; + this.peers[peer.id].roomSecret = msg.roomSecret; + } return; } if (window.isRtcSupported && peer.rtcSupported) { - this.peers[peer.id] = new RTCPeer(this._server, peer.id); + this.peers[peer.id] = new RTCPeer(this._server, peer.id, msg.roomType, msg.roomSecret); } else { - this.peers[peer.id] = new WSPeer(this._server, peer.id); + this.peers[peer.id] = new WSPeer(this._server, peer.id, msg.roomType, msg.roomSecret); } }) } @@ -444,8 +529,8 @@ class PeersManager { this.peers[message.to].sendText(message.text); } - _onPeerJoined(peer) { - this._onMessage(peer.id); + _onPeerJoined(message) { + this._onMessage({sender: message.peer.id, roomType: message.roomType, roomSecret: message.roomSecret}); } _onPeerLeft(peerId) { @@ -461,12 +546,14 @@ class PeersManager { Object.keys(this.peers).forEach(peerId => this._onPeerLeft(peerId)); } } -} -class WSPeer extends Peer { - _send(message) { - message.to = this._peerId; - this._server.send(message); + _onSecretRoomDeleted(roomSecret) { + for (const peerId in this.peers) { + const peer = this.peers[peerId]; + if (peer._roomSecret === roomSecret) { + this._onPeerLeft(peerId); + } + } } } @@ -538,7 +625,6 @@ class FileDigester { unchunk(chunk) { this._buffer.push(chunk); this._bytesReceived += chunk.byteLength || chunk.size; - const totalChunks = this._buffer.length; this.progress = this._bytesReceived / this._size; if (isNaN(this.progress)) this.progress = 1 @@ -571,7 +657,7 @@ class Events { RTCPeer.config = { 'sdpSemantics': 'unified-plan', - iceServers: [ + 'iceServers': [ { urls: 'stun:stun.l.google.com:19302' }, diff --git a/public/scripts/qrcode.js b/public/scripts/qrcode.js new file mode 100644 index 0000000..569a867 --- /dev/null +++ b/public/scripts/qrcode.js @@ -0,0 +1,2 @@ +/*! qrcode-svg v1.1.0 | https://github.com/papnkukn/qrcode-svg | MIT license */ +function QR8bitByte(t){this.mode=QRMode.MODE_8BIT_BYTE,this.data=t,this.parsedData=[];for(var e=0,r=this.data.length;e65536?(o[0]=240|(1835008&n)>>>18,o[1]=128|(258048&n)>>>12,o[2]=128|(4032&n)>>>6,o[3]=128|63&n):n>2048?(o[0]=224|(61440&n)>>>12,o[1]=128|(4032&n)>>>6,o[2]=128|63&n):n>128?(o[0]=192|(1984&n)>>>6,o[1]=128|63&n):o[0]=n,this.parsedData.push(o)}this.parsedData=Array.prototype.concat.apply([],this.parsedData),this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function QRCodeModel(t,e){this.typeNumber=t,this.errorCorrectLevel=e,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}QR8bitByte.prototype={getLength:function(t){return this.parsedData.length},write:function(t){for(var e=0,r=this.parsedData.length;e=7&&this.setupTypeNumber(t),null==this.dataCache&&(this.dataCache=QRCodeModel.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,e)},setupPositionProbePattern:function(t,e){for(var r=-1;r<=7;r++)if(!(t+r<=-1||this.moduleCount<=t+r))for(var o=-1;o<=7;o++)e+o<=-1||this.moduleCount<=e+o||(this.modules[t+r][e+o]=0<=r&&r<=6&&(0==o||6==o)||0<=o&&o<=6&&(0==r||6==r)||2<=r&&r<=4&&2<=o&&o<=4)},getBestMaskPattern:function(){for(var t=0,e=0,r=0;r<8;r++){this.makeImpl(!0,r);var o=QRUtil.getLostPoint(this);(0==r||t>o)&&(t=o,e=r)}return e},createMovieClip:function(t,e,r){var o=t.createEmptyMovieClip(e,r);this.make();for(var n=0;n>r&1);this.modules[Math.floor(r/3)][r%3+this.moduleCount-8-3]=o}for(r=0;r<18;r++){o=!t&&1==(e>>r&1);this.modules[r%3+this.moduleCount-8-3][Math.floor(r/3)]=o}},setupTypeInfo:function(t,e){for(var r=this.errorCorrectLevel<<3|e,o=QRUtil.getBCHTypeInfo(r),n=0;n<15;n++){var i=!t&&1==(o>>n&1);n<6?this.modules[n][8]=i:n<8?this.modules[n+1][8]=i:this.modules[this.moduleCount-15+n][8]=i}for(n=0;n<15;n++){i=!t&&1==(o>>n&1);n<8?this.modules[8][this.moduleCount-n-1]=i:n<9?this.modules[8][15-n-1+1]=i:this.modules[8][15-n-1]=i}this.modules[this.moduleCount-8][8]=!t},mapData:function(t,e){for(var r=-1,o=this.moduleCount-1,n=7,i=0,a=this.moduleCount-1;a>0;a-=2)for(6==a&&a--;;){for(var s=0;s<2;s++)if(null==this.modules[o][a-s]){var h=!1;i>>n&1)),QRUtil.getMask(e,o,a-s)&&(h=!h),this.modules[o][a-s]=h,-1==--n&&(i++,n=7)}if((o+=r)<0||this.moduleCount<=o){o-=r,r=-r;break}}}},QRCodeModel.PAD0=236,QRCodeModel.PAD1=17,QRCodeModel.createData=function(t,e,r){for(var o=QRRSBlock.getRSBlocks(t,e),n=new QRBitBuffer,i=0;i8*s)throw new Error("code length overflow. ("+n.getLengthInBits()+">"+8*s+")");for(n.getLengthInBits()+4<=8*s&&n.put(0,4);n.getLengthInBits()%8!=0;)n.putBit(!1);for(;!(n.getLengthInBits()>=8*s||(n.put(QRCodeModel.PAD0,8),n.getLengthInBits()>=8*s));)n.put(QRCodeModel.PAD1,8);return QRCodeModel.createBytes(n,o)},QRCodeModel.createBytes=function(t,e){for(var r=0,o=0,n=0,i=new Array(e.length),a=new Array(e.length),s=0;s=0?d.get(f):0}}var c=0;for(u=0;u=0;)e^=QRUtil.G15<=0;)e^=QRUtil.G18<>>=1;return e},getPatternPosition:function(t){return QRUtil.PATTERN_POSITION_TABLE[t-1]},getMask:function(t,e,r){switch(t){case QRMaskPattern.PATTERN000:return(e+r)%2==0;case QRMaskPattern.PATTERN001:return e%2==0;case QRMaskPattern.PATTERN010:return r%3==0;case QRMaskPattern.PATTERN011:return(e+r)%3==0;case QRMaskPattern.PATTERN100:return(Math.floor(e/2)+Math.floor(r/3))%2==0;case QRMaskPattern.PATTERN101:return e*r%2+e*r%3==0;case QRMaskPattern.PATTERN110:return(e*r%2+e*r%3)%2==0;case QRMaskPattern.PATTERN111:return(e*r%3+(e+r)%2)%2==0;default:throw new Error("bad maskPattern:"+t)}},getErrorCorrectPolynomial:function(t){for(var e=new QRPolynomial([1],0),r=0;r5&&(r+=3+i-5)}for(o=0;o=256;)t-=255;return QRMath.EXP_TABLE[t]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},i=0;i<8;i++)QRMath.EXP_TABLE[i]=1<>>7-t%8&1)},put:function(t,e){for(var r=0;r>>e-r-1&1))},getLengthInBits:function(){return this.length},putBit:function(t){var e=Math.floor(this.length/8);this.buffer.length<=e&&this.buffer.push(0),t&&(this.buffer[e]|=128>>>this.length%8),this.length++}};var QRCodeLimitLength=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]];function QRCode(t){if(this.options={padding:4,width:256,height:256,typeNumber:4,color:"#000000",background:"#ffffff",ecl:"M"},"string"==typeof t&&(t={content:t}),t)for(var e in t)this.options[e]=t[e];if("string"!=typeof this.options.content)throw new Error("Expected 'content' as string!");if(0===this.options.content.length)throw new Error("Expected 'content' to be non-empty!");if(!(this.options.padding>=0))throw new Error("Expected 'padding' value to be non-negative!");if(!(this.options.width>0&&this.options.height>0))throw new Error("Expected 'width' or 'height' value to be higher than zero!");var r=this.options.content,o=function(t,e){for(var r=function(t){var e=encodeURI(t).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return e.length+(e.length!=t?3:0)}(t),o=1,n=0,i=0,a=QRCodeLimitLength.length;i<=a;i++){var s=QRCodeLimitLength[i];if(!s)throw new Error("Content too long: expected "+n+" but got "+r);switch(e){case"L":n=s[0];break;case"M":n=s[1];break;case"Q":n=s[2];break;case"H":n=s[3];break;default:throw new Error("Unknwon error correction level: "+e)}if(r<=n)break;o++}if(o>QRCodeLimitLength.length)throw new Error("Content too long");return o}(r,this.options.ecl),n=function(t){switch(t){case"L":return QRErrorCorrectLevel.L;case"M":return QRErrorCorrectLevel.M;case"Q":return QRErrorCorrectLevel.Q;case"H":return QRErrorCorrectLevel.H;default:throw new Error("Unknwon error correction level: "+t)}}(this.options.ecl);this.qrcode=new QRCodeModel(o,n),this.qrcode.addData(r),this.qrcode.make()}QRCode.prototype.svg=function(t){var e=this.options||{},r=this.qrcode.modules;void 0===t&&(t={container:e.container||"svg"});for(var o=void 0===e.pretty||!!e.pretty,n=o?" ":"",i=o?"\r\n":"",a=e.width,s=e.height,h=r.length,l=a/(h+2*e.padding),u=s/(h+2*e.padding),g=void 0!==e.join&&!!e.join,d=void 0!==e.swap&&!!e.swap,f=void 0===e.xmlDeclaration||!!e.xmlDeclaration,c=void 0!==e.predefined&&!!e.predefined,R=c?n+''+i:"",p=n+''+i,m="",Q="",v=0;v'+i:n+''+i}}g&&(m=n+'');var T="";switch(t.container){case"svg":f&&(T+=''+i),T+=''+i,T+=R+p+m,T+="";break;case"svg-viewbox":f&&(T+=''+i),T+=''+i,T+=R+p+m,T+="";break;case"g":T+=''+i,T+=R+p+m,T+="";break;default:T+=(R+p+m).replace(/^\s+/,"")}return T},QRCode.prototype.save=function(t,e){var r=this.svg();"function"!=typeof e&&(e=function(t,e){});try{require("fs").writeFile(t,r,e)}catch(t){e(t)}},"undefined"!=typeof module&&(module.exports=QRCode); \ No newline at end of file diff --git a/public/scripts/theme.js b/public/scripts/theme.js index e452ffb..f839411 100644 --- a/public/scripts/theme.js +++ b/public/scripts/theme.js @@ -1,5 +1,5 @@ (function(){ - + // Select the button const btnTheme = document.getElementById('theme'); // Check for dark mode preference at the OS level @@ -8,30 +8,32 @@ // Get the user's theme preference from local storage, if it's available const currentTheme = localStorage.getItem('theme'); // If the user's preference in localStorage is dark... - if (currentTheme == 'dark') { + if (currentTheme === 'dark') { // ...let's toggle the .dark-theme class on the body document.body.classList.toggle('dark-theme'); // Otherwise, if the user's preference in localStorage is light... - } else if (currentTheme == 'light') { + } else if (currentTheme === 'light') { // ...let's toggle the .light-theme class on the body document.body.classList.toggle('light-theme'); } 
 - // Listen for a click on the button - btnTheme.addEventListener('click', function() { + // Listen for a click on the button + btnTheme.addEventListener('click', function(e) { + e.preventDefault(); // If the user's OS setting is dark and matches our .dark-theme class... + let theme; if (prefersDarkScheme.matches) { // ...then toggle the light mode class document.body.classList.toggle('light-theme'); // ...but use .dark-theme if the .light-theme class is already on the body, - var theme = document.body.classList.contains('light-theme') ? 'light' : 'dark'; + theme = document.body.classList.contains('light-theme') ? 'light' : 'dark'; } else { // Otherwise, let's do the same thing, but for .dark-theme document.body.classList.toggle('dark-theme'); - var theme = document.body.classList.contains('dark-theme') ? 'dark' : 'light'; + theme = document.body.classList.contains('dark-theme') ? 'dark' : 'light'; } // Finally, let's save the current preference to localStorage to keep using it localStorage.setItem('theme', theme); }); -})(); \ No newline at end of file +})(); diff --git a/public/scripts/ui.js b/public/scripts/ui.js index ab187eb..e79d9a6 100644 --- a/public/scripts/ui.js +++ b/public/scripts/ui.js @@ -25,30 +25,56 @@ class PeersUI { Events.on('peers', e => this._onPeers(e.detail)); Events.on('file-progress', e => this._onFileProgress(e.detail)); Events.on('paste', e => this._onPaste(e)); - Events.on('ws-disconnect', _ => this._clearPeers()); + Events.on('ws-disconnected', _ => this._clearPeers()); + Events.on('secret-room-deleted', _ => this._clearPeers('secret')); this.peers = {}; } - _onPeerJoined(peer) { - if (this.peers[peer.id]) return; // peer already exists + _onPeerJoined(msg) { + this._joinPeer(msg.peer, msg.roomType, msg.roomType); + } + + _joinPeer(peer, roomType, roomSecret) { + peer.roomType = roomType; + peer.roomSecret = roomSecret; + if (this.peers[peer.id]) { + this.peers[peer.id].roomType = peer.roomType; + this._redrawPeer(peer); + return; // peer already exists + } this.peers[peer.id] = peer; } _onPeerConnected(peerId) { - if(this.peers[peerId]) + if(this.peers[peerId] && !$(peerId)) new PeerUI(this.peers[peerId]); } - _onPeers(peers) { + _redrawPeer(peer) { + const peerNode = $(peer.id); + if (!peerNode) return; + peerNode.classList.remove('type-ip', 'type-secret'); + peerNode.classList.add(`type-${peer.roomType}`) + } + + _redrawPeers() { + const peers = this._getPeers(); this._clearPeers(); - peers.forEach(peer => this._onPeerJoined(peer)); + peers.forEach(peer => { + this._joinPeer(peer, peer.roomType, peer.roomSecret); + this._onPeerConnected(peer.id); + }); + } + + _onPeers(msg) { + msg.peers.forEach(peer => this._joinPeer(peer, msg.roomType, msg.roomSecret)); } _onPeerDisconnected(peerId) { const $peer = $(peerId); if (!$peer) return; $peer.remove(); - setTimeout(e => window.animateBackground(true), 1750); // Start animation again + setTimeout(_ => window.animateBackground(true), 1750); // Start animation again } _onPeerLeft(peerId) { @@ -56,6 +82,16 @@ class PeersUI { delete this.peers[peerId]; } + _onSecretRoomDeleted(roomSecret) { + for (const peerId in this.peers) { + const peer = this.peers[peerId]; + console.debug(peer); + if (peer.roomSecret === roomSecret) { + this._onPeerLeft(peerId); + } + } + } + _onFileProgress(progress) { const peerId = progress.sender || progress.recipient; const $peer = $(peerId); @@ -63,10 +99,17 @@ class PeersUI { $peer.ui.setProgress(progress.progress); } - _clearPeers() { - const $peers = $$('x-peers').innerHTML = ''; - Object.keys(this.peers).forEach(peerId => delete this.peers[peerId]); - setTimeout(e => window.animateBackground(true), 1750); // Start animation again + _clearPeers(roomType = 'all') { + for (const peerId in this.peers) { + if (roomType === 'all' || this.peers[peerId].roomType === roomType) { + const peerNode = $(peerId); + if(peerNode) peerNode.remove(); + delete this.peers[peerId]; + } + } + if ($$('x-peers').innerHTML === '') { + setTimeout(_ => window.animateBackground(true), 1750); // Start animation again + } } _getPeers() { @@ -76,7 +119,9 @@ class PeersUI { peers.push({ id: peersNode.id, name: peersNode.name, - rtcSupported: peersNode.rtcSupported + rtcSupported: peersNode.rtcSupported, + roomType: peersNode.roomType, + roomSecret: peersNode.roomSecret }) }); return peers; @@ -103,7 +148,6 @@ class PeersUI { descriptor = files[0].name; noPeersMessage = `Open Snapdrop on other devices to send ${descriptor} directly`; } else if (files.length > 1) { - console.debug(files); descriptor = `${files.length} files`; noPeersMessage = `Open Snapdrop on other devices to send ${descriptor} directly`; } else if (text.length > 0) { @@ -132,7 +176,7 @@ class PeersUI { window.pasteMode.activated = true; console.log('Paste mode activated.') - this._onPeers(this._getPeers()); + this._redrawPeers(); } } @@ -159,7 +203,7 @@ class PeersUI { cancelPasteModeBtn.removeEventListener('click', this._cancelPasteMode); cancelPasteModeBtn.setAttribute('hidden', ""); - this._onPeers(this._getPeers()); + this._redrawPeers(); } } @@ -213,22 +257,23 @@ class PeerUI { constructor(peer) { this._peer = peer; + this._roomType = peer.roomType; + this._roomSecret = peer.roomSecret; this._initDom(); this._bindListeners(this.$el); $$('x-peers').appendChild(this.$el); - setTimeout(e => window.animateBackground(false), 1750); // Stop animation + setTimeout(_ => window.animateBackground(false), 1750); // Stop animation } _initDom() { const el = document.createElement('x-peer'); el.id = this._peer.id; - el.name = this._peer.name; - el.rtcSupported = this._peer.rtcSupported; el.innerHTML = this.html(); el.ui = this; el.querySelector('svg use').setAttribute('xlink:href', this._icon()); el.querySelector('.name').textContent = this._displayName(); el.querySelector('.device-name').textContent = this._deviceName(); + el.classList.add(`type-${this._roomType}`); this.$el = el; this.$progress = el.querySelector('.progress'); } @@ -241,7 +286,7 @@ class PeerUI { el.addEventListener('dragleave', e => this._onDragEnd(e)); el.addEventListener('dragover', e => this._onDragOver(e)); el.addEventListener('contextmenu', e => this._onRightClick(e)); - el.addEventListener('touchstart', e => this._onTouchStart(e)); + el.addEventListener('touchstart', _ => this._onTouchStart()); el.addEventListener('touchend', e => this._onTouchEnd(e)); // prevent browser's default file drop behavior Events.on('dragover', e => e.preventDefault()); @@ -329,7 +374,7 @@ class PeerUI { Events.fire('text-recipient', this._peer.id); } - _onTouchStart(e) { + _onTouchStart() { this._touchStart = Date.now(); this._touchTimer = setTimeout(_ => this._onTouchEnd(), 610); } @@ -348,8 +393,9 @@ class PeerUI { class Dialog { constructor(id) { this.$el = $(id); - this.$el.querySelectorAll('[close]').forEach(el => el.addEventListener('click', e => this.hide())) + this.$el.querySelectorAll('[close]').forEach(el => el.addEventListener('click', _ => this.hide())) this.$autoFocus = this.$el.querySelector('[autofocus]'); + Events.on('ws-disconnected', _ => this.hide()); } show() { @@ -359,8 +405,10 @@ class Dialog { hide() { this.$el.removeAttribute('show'); - document.activeElement.blur(); - window.blur(); + if (this.$autoFocus) { + document.activeElement.blur(); + window.blur(); + } } } @@ -419,7 +467,7 @@ class ReceiveDialog extends Dialog { // fallback for iOS $a.target = '_blank'; const reader = new FileReader(); - reader.onload = e => $a.href = reader.result; + reader.onload = _ => $a.href = reader.result; reader.readAsDataURL(file.blob); } @@ -448,10 +496,254 @@ class ReceiveDialog extends Dialog { } } +class PairDeviceDialog extends Dialog { + constructor() { + super('pairDeviceDialog'); + $('pair-device').addEventListener('click', _ => this._pairDeviceInitiate()); + this.$inputRoomKeyChars = this.$el.querySelectorAll('#keyInputContainer>input'); + this.$submitBtn = this.$el.querySelector('button[type="submit"]'); + this.$roomKey = this.$el.querySelector('#roomKey'); + this.$qrCode = this.$el.querySelector('#roomKeyQrCode'); + this.$clearSecretsBtn = $('clear-pair-devices'); + this.$footerInstructions = $$('footer>.font-body2'); + let createJoinForm = this.$el.querySelector('form'); + createJoinForm.addEventListener('submit', _ => this._onSubmit()); + + this.$el.querySelector('[close]').addEventListener('click', _ => this._pairDeviceCancel()) + this.$inputRoomKeyChars.forEach(el => el.addEventListener('input', e => this._onCharsInput(e))); + this.$inputRoomKeyChars.forEach(el => el.addEventListener('keyup', _ => this.evaluateRoomKeyChars())); + this.$inputRoomKeyChars.forEach(el => el.addEventListener('keydown', e => this._onCharsKeyDown(e))); + + Events.on('keydown', e => this._onKeyDown(e)); + Events.on('ws-connected', _ => this._onWsConnected()); + Events.on('pair-device-initiated', e => this._pairDeviceInitiated(e.detail)); + Events.on('pair-device-joined', e => this._pairDeviceJoined(e.detail)); + Events.on('pair-device-join-key-invalid', _ => this._pairDeviceJoinKeyInvalid()); + Events.on('pair-device-canceled', e => this._pairDeviceCanceled(e.detail)); + Events.on('room-secret-delete', e => this._onRoomSecretDelete(e.detail)) + Events.on('clear-room-secrets', e => this._onClearRoomSecrets(e.detail)) + Events.on('secret-room-deleted', e => this._onSecretRoomDeleted(e.detail)); + this.$el.addEventListener('paste', e => this._onPaste(e)); + + this.evaluateRoomKeyChars(); + this.evaluateUrlAttributes(); + } + + _onCharsInput(e) { + e.target.value = e.target.value.replace(/\D/g,''); + if (!e.target.value) return; + let nextSibling = e.target.nextElementSibling; + if (nextSibling) { + e.preventDefault(); + nextSibling.focus(); + nextSibling.select(); + } + } + + _onKeyDown(e) { + if (this.$el.attributes["show"] && e.code === "Escape") { + this.hide(); + this._pairDeviceCancel(); + } + if (this.$el.attributes["show"] && e.code === "keyO") { + this._onRoomSecretDelete() + } + } + + _onCharsKeyDown(e) { + if (this.$el.attributes["show"] && e.code === "Escape") { + this.hide(); + this._pairDeviceCancel(); + } + let previousSibling = e.target.previousElementSibling; + let nextSibling = e.target.nextElementSibling; + if (e.key === "Backspace" && previousSibling && !e.target.value) { + previousSibling.value = ''; + previousSibling.focus(); + } else if (e.key === "ArrowRight" && nextSibling) { + e.preventDefault(); + nextSibling.focus(); + nextSibling.select(); + } else if (e.key === "ArrowLeft" && previousSibling) { + e.preventDefault(); + previousSibling.focus(); + previousSibling.select(); + } + } + + _onPaste(e) { + e.preventDefault(); + let num = e.clipboardData.getData("Text").replace(/\D/g,'').substring(0, 6); + for (let i = 0; i < num.length; i++) { + document.activeElement.value = num.charAt(i); + let nextSibling = document.activeElement.nextElementSibling; + if (!nextSibling) break; + nextSibling.focus(); + nextSibling.select(); + } + } + + evaluateRoomKeyChars() { + if (this.$el.querySelectorAll('#keyInputContainer>input:placeholder-shown').length > 0) { + this.$submitBtn.setAttribute("disabled", ""); + } else { + this.inputRoomKey = ""; + this.$inputRoomKeyChars.forEach(el => { + this.inputRoomKey += el.value; + }) + this.$submitBtn.removeAttribute("disabled"); + } + } + + evaluateUrlAttributes() { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has('room_key')) { + this._pairDeviceJoin(urlParams.get('room_key')); + window.history.replaceState({}, "title**", '/'); //remove room_key from url + } + } + + _onWsConnected() { + PersistentStorage.getAllRoomSecrets().then(roomSecrets => { + Events.fire('room-secrets', roomSecrets); + this._evaluateNumberRoomSecrets(); + }).catch((e) => console.error(e)); + } + + _pairDeviceInitiate() { + Events.fire('pair-device-initiate'); + } + + _pairDeviceInitiated(msg) { + this.roomKey = msg.roomKey; + this.roomSecret = msg.roomSecret; + this.$roomKey.innerText = `${this.roomKey.substring(0,3)} ${this.roomKey.substring(3,6)}` + // Display the QR code for the url + const qr = new QRCode({ + content: this._getShareRoomURL(), + width: 80, + height: 80, + padding: 0, + background: "transparent", + color: getComputedStyle(document.body).getPropertyValue('--text-color'), + ecl: "L", + join: true + }); + this.$qrCode.innerHTML = qr.svg(); + this.show(); + } + + _getShareRoomURL() { + let url = new URL(location.href); + url.searchParams.append('room_key', this.roomKey) + return url.href; + } + + _onSubmit() { + this._pairDeviceJoin(this.inputRoomKey); + } + + _pairDeviceJoin(roomKey) { + if (/^\d{6}$/g.test(roomKey)) { + roomKey = roomKey.substring(0,6); + Events.fire('pair-device-join', roomKey); + let lastChar = this.$inputRoomKeyChars[5]; + lastChar.focus(); + lastChar.select(); + } + } + + _pairDeviceJoined(roomSecret) { + this.hide(); + PersistentStorage.addRoomSecret(roomSecret).then(_ => { + Events.fire('notify-user', 'Devices paired successfully.') + this._evaluateNumberRoomSecrets() + }).finally(_ => { + this._cleanUp() + }) + .catch((e) => console.error(e)); + } + + _pairDeviceJoinKeyInvalid() { + Events.fire('notify-user', 'Key not valid') + } + + _pairDeviceCancel() { + this.hide(); + this._cleanUp(); + Events.fire('pair-device-cancel'); + } + + _pairDeviceCanceled(roomKey) { + Events.fire('notify-user', `Key ${roomKey} invalidated.`) + } + + _cleanUp() { + this.roomSecret = null; + this.roomKey = null; + this.inputRoomKey = ''; + this.$inputRoomKeyChars.forEach(el => el.value = ''); + } + + _onRoomSecretDelete(roomSecret) { + PersistentStorage.deleteRoomSecret(roomSecret).then(_ => { + console.debug("then secret: " + roomSecret) + Events.fire('room-secret-deleted', roomSecret) + this._evaluateNumberRoomSecrets(); + }).catch((e) => console.error(e)); + } + + _onClearRoomSecrets() { + PersistentStorage.getAllRoomSecrets().then(roomSecrets => { + Events.fire('room-secrets-cleared', roomSecrets); + PersistentStorage.clearRoomSecrets().finally(_ => { + Events.fire('notify-user', 'All Devices unpaired.') + this._evaluateNumberRoomSecrets(); + }) + }).catch((e) => console.error(e)); + } + + _onSecretRoomDeleted(roomSecret) { + PersistentStorage.deleteRoomSecret(roomSecret).then(_ => { + this._evaluateNumberRoomSecrets(); + }).catch(e => console.error(e)); + } + + _evaluateNumberRoomSecrets() { + PersistentStorage.getAllRoomSecrets().then(roomSecrets => { + if (roomSecrets.length > 0) { + this.$clearSecretsBtn.removeAttribute('hidden'); + this.$footerInstructions.innerText = "You can be discovered on this network and by paired devices"; + } else { + this.$clearSecretsBtn.setAttribute('hidden', ''); + this.$footerInstructions.innerText = "You can be discovered by everyone on this network"; + } + }).catch((e) => console.error(e)); + } +} + +class ClearDevicesDialog extends Dialog { + constructor() { + super('clearDevicesDialog'); + $('clear-pair-devices').addEventListener('click', _ => this._onClearPairDevices()); + let clearDevicesForm = this.$el.querySelector('form'); + clearDevicesForm.addEventListener('submit', _ => this._onSubmit()); + } + + _onClearPairDevices() { + this.show(); + } + + _onSubmit() { + Events.fire('clear-room-secrets'); + this.hide(); + } +} + class SendTextDialog extends Dialog { constructor() { super('sendTextDialog'); - Events.on('text-recipient', e => this._onRecipient(e.detail)) + Events.on('text-recipient', e => this._onRecipient(e.detail)); this.$text = this.$el.querySelector('#textInput'); const button = this.$el.querySelector('form'); button.addEventListener('submit', e => this._send(e)); @@ -490,6 +782,7 @@ class SendTextDialog extends Dialog { to: this._recipient, text: this.$text.innerText }); + this.$text.innerText = ""; } } @@ -545,7 +838,6 @@ class Toast extends Dialog { } } - class Notifications { constructor() { @@ -556,7 +848,7 @@ class Notifications { if (Notification.permission !== 'granted') { this.$button = $('notification'); this.$button.removeAttribute('hidden'); - this.$button.addEventListener('click', e => this._requestPermission()); + this.$button.addEventListener('click', _ => this._requestPermission()); } Events.on('text-received', e => this._messageNotification(e.detail.text)); Events.on('file-received', e => this._downloadNotification(e.detail.name)); @@ -568,7 +860,7 @@ class Notifications { Events.fire('notify-user', Notifications.PERMISSION_ERROR || 'Error'); return; } - this._notify('Even more snappy sharing!'); + this._notify('Notifications enabled.'); this.$button.setAttribute('hidden', 1); }); } @@ -603,10 +895,10 @@ class Notifications { if (document.visibilityState !== 'visible') { if (isURL(message)) { const notification = this._notify(message, 'Click to open link'); - this._bind(notification, e => window.open(message, '_blank', null, true)); + this._bind(notification, _ => window.open(message, '_blank', null, true)); } else { const notification = this._notify(message, 'Click to copy text'); - this._bind(notification, e => this._copyText(message, notification)); + this._bind(notification, _ => this._copyText(message, notification)); } } } @@ -615,7 +907,7 @@ class Notifications { if (document.visibilityState !== 'visible') { const notification = this._notify(message, 'Click to download'); if (!window.isDownloadSupported) return; - this._bind(notification, e => this._download(notification)); + this._bind(notification, _ => this._download(notification)); } } @@ -625,14 +917,18 @@ class Notifications { } _copyText(message, notification) { - notification.close(); - if (!navigator.clipboard.writeText(message)) return; - this._notify('Copied text to clipboard'); + if (navigator.clipboard.writeText(message)) { + notification.close(); + this._notify('Copied text to clipboard'); + } else { + this._notify('Writing to clipboard failed. Copy manually!'); + + } } _bind(notification, handler) { if (notification.then) { - notification.then(e => serviceWorker.getNotifications().then(notifications => { + notification.then(_ => serviceWorker.getNotifications().then(notifications => { serviceWorker.addEventListener('notificationclick', handler); })); } else { @@ -641,7 +937,6 @@ class Notifications { } } - class NetworkStatusUI { constructor() { @@ -658,6 +953,10 @@ class NetworkStatusUI { } _showOnlineMessage() { + if (!this.firstConnect) { + this.firstConnect = true; + return; + } Events.fire('notify-user', 'You are back online'); window.animateBackground(true); } @@ -682,16 +981,193 @@ class WebShareTargetUI { } } +class PersistentStorage { + constructor() { + if (!('indexedDB' in window)) { + this.logBrowserNotCapable(); + return; + } + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onerror = (e) => { + this.logBrowserNotCapable(); + console.log('Error initializing database: '); + console.error(e) + }; + DBOpenRequest.onsuccess = () => { + console.log('Database initialised.'); + }; + DBOpenRequest.onupgradeneeded = (e) => { + const db = e.target.result; + db.onerror = e => console.log('Error loading database: ' + e); + db.createObjectStore('keyval'); + const roomSecretsObjectStore = db.createObjectStore('room_secrets', {autoIncrement: true}); + roomSecretsObjectStore.createIndex('secret', 'secret', { unique: true }); + } + } + + logBrowserNotCapable() { + console.log("This browser does not support IndexedDB. Paired devices will be gone after closing the browser."); + } + + static set(key, value) { + return new Promise((resolve, reject) => { + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onsuccess = (e) => { + const db = e.target.result; + const transaction = db.transaction('keyval', 'readwrite'); + const objectStore = transaction.objectStore('keyval'); + const objectStoreRequest = objectStore.put(value, key); + objectStoreRequest.onsuccess = _ => { + console.log(`Request successful. Added key-pair: ${key} - ${value}`); + resolve(); + }; + } + DBOpenRequest.onerror = (e) => { + reject(e); + } + }) + } + + static get(key) { + return new Promise((resolve, reject) => { + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onsuccess = (e) => { + const db = e.target.result; + const transaction = db.transaction('keyval', 'readwrite'); + const objectStore = transaction.objectStore('keyval'); + const objectStoreRequest = objectStore.get(key); + objectStoreRequest.onsuccess = _ => { + console.log(`Request successful. Retrieved key-pair: ${key} - ${objectStoreRequest.result}`); + resolve(objectStoreRequest.result); + } + } + DBOpenRequest.onerror = (e) => { + reject(e); + } + }); + } + static delete(key) { + return new Promise((resolve, reject) => { + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onsuccess = (e) => { + const db = e.target.result; + const transaction = db.transaction('keyval', 'readwrite'); + const objectStore = transaction.objectStore('keyval'); + const objectStoreRequest = objectStore.delete(key); + objectStoreRequest.onsuccess = _ => { + console.log(`Request successful. Deleted key: ${key}`); + resolve(); + }; + } + DBOpenRequest.onerror = (e) => { + reject(e); + } + }) + } + + static addRoomSecret(roomSecret) { + return new Promise((resolve, reject) => { + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onsuccess = (e) => { + const db = e.target.result; + const transaction = db.transaction('room_secrets', 'readwrite'); + const objectStore = transaction.objectStore('room_secrets'); + const objectStoreRequest = objectStore.add({'secret': roomSecret}); + objectStoreRequest.onsuccess = _ => { + console.log(`Request successful. RoomSecret added: ${roomSecret}`); + resolve(); + } + } + DBOpenRequest.onerror = (e) => { + reject(e); + } + }) + } + + static getAllRoomSecrets() { + return new Promise((resolve, reject) => { + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onsuccess = (e) => { + const db = e.target.result; + const transaction = db.transaction('room_secrets', 'readwrite'); + const objectStore = transaction.objectStore('room_secrets'); + const objectStoreRequest = objectStore.getAll(); + objectStoreRequest.onsuccess = e => { + let secrets = []; + for (let i=0; i { + reject(e); + } + }); + } + + static deleteRoomSecret(room_secret) { + return new Promise((resolve, reject) => { + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onsuccess = (e) => { + const db = e.target.result; + const transaction = db.transaction('room_secrets', 'readwrite'); + const objectStore = transaction.objectStore('room_secrets'); + const objectStoreRequestKey = objectStore.index("secret").getKey(room_secret); + objectStoreRequestKey.onsuccess = e => { + if (!e.target.result) { + console.log(`Nothing to delete. room_secret not existing: ${room_secret}`); + resolve(); + return; + } + const objectStoreRequestDeletion = objectStore.delete(e.target.result); + objectStoreRequestDeletion.onsuccess = _ => { + console.log(`Request successful. Deleted room_secret: ${room_secret}`); + resolve(); + } + objectStoreRequestDeletion.onerror = (e) => { + reject(e); + } + }; + } + DBOpenRequest.onerror = (e) => { + reject(e); + } + }) + } + + static clearRoomSecrets() { + return new Promise((resolve, reject) => { + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onsuccess = (e) => { + const db = e.target.result; + const transaction = db.transaction('room_secrets', 'readwrite'); + const objectStore = transaction.objectStore('room_secrets'); + const objectStoreRequest = objectStore.clear(); + objectStoreRequest.onsuccess = _ => { + console.log('Request successful. All room_secrets cleared'); + resolve(); + }; + } + DBOpenRequest.onerror = (e) => { + reject(e); + } + }) + } +} class Snapdrop { constructor() { - const server = new ServerConnection(); - const peers = new PeersManager(server); - const peersUI = new PeersUI(); - Events.on('load', e => { + Events.on('load', _ => { + const server = new ServerConnection(); + const peers = new PeersManager(server); + const peersUI = new PeersUI(); const receiveDialog = new ReceiveDialog(); const sendTextDialog = new SendTextDialog(); const receiveTextDialog = new ReceiveTextDialog(); + const pairDeviceDialog = new PairDeviceDialog(); + const clearDevicesDialog = new ClearDevicesDialog(); const toast = new Toast(); const notifications = new Notifications(); const networkStatusUI = new NetworkStatusUI(); @@ -700,10 +1176,10 @@ class Snapdrop { } } +const persistentStorage = new PersistentStorage(); const snapdrop = new Snapdrop(); - if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js') .then(serviceWorker => { @@ -714,6 +1190,11 @@ if ('serviceWorker' in navigator) { window.addEventListener('beforeinstallprompt', e => { if (window.matchMedia('(display-mode: standalone)').matches) { + // make peerId persistent when pwa installed + PersistentStorage.get('peerId').then(peerId => { + sessionStorage.setItem("peerId", peerId); + }).catch(e => console.error(e)); + // don't display install banner when installed return e.preventDefault(); } else { @@ -805,7 +1286,7 @@ as the user has dismissed the permission prompt several times. This can be reset in Page Info which can be accessed by clicking the lock icon next to the URL.`; -document.body.onclick = e => { // safari hack to fix audio +document.body.onclick = _ => { // safari hack to fix audio document.body.onclick = null; if (!(/.*Version.*Safari.*/.test(navigator.userAgent))) return; blop.play(); diff --git a/public/styles.css b/public/styles.css index 2a90bb6..7e55f15 100644 --- a/public/styles.css +++ b/public/styles.css @@ -35,8 +35,13 @@ body { flex-direction: row-reverse; } +.space-between { + justify-content: space-between; +} + .row { display: flex; + justify-content: center; flex-direction: row; } @@ -106,13 +111,18 @@ h3 { font-size: 20px; font-weight: 500; margin: 16px 0; + color: var(--primary-color); } .font-subheading { font-size: 16px; font-weight: 400; line-height: 24px; - word-break: break-all; + word-break: normal; +} + +.text-center { + text-align: center; } .font-body1, @@ -183,6 +193,7 @@ x-peers { overflow: hidden; flex-flow: row wrap; z-index: 2; + transition: color 300ms; } /* Empty Peers List */ @@ -199,6 +210,7 @@ x-no-peers { x-no-peers h2, x-no-peers a { color: var(--primary-color); + margin-bottom: 5px; } x-peers:not(:empty)+x-no-peers { @@ -249,6 +261,10 @@ x-peer x-icon { will-change: transform; } +x-peer:not(.type-ip) x-icon { + background: #00a69c; +} + x-peer:not([transfer]):hover x-icon, x-peer:not([transfer]):focus x-icon { transform: scale(1.05); @@ -266,6 +282,11 @@ x-peer[transfer] x-icon { opacity: 0.7; } +.device-name { + font-size: 14px; + white-space: nowrap; +} + x-peer[transfer] .status:before { content: 'Transferring...'; } @@ -305,6 +326,7 @@ footer { align-items: center; padding: 0 0 16px 0; text-align: center; + transition: color 300ms; } footer .logo { @@ -317,13 +339,6 @@ footer .font-body2 { color: var(--primary-color); } -@media (min-height: 800px) { - footer { - margin-bottom: 16px; - } -} - - /* Dialog */ x-dialog x-background { @@ -359,7 +374,7 @@ x-dialog:not([show]) x-background { } x-dialog .row-reverse>.button { - margin-top: 16px; + margin-top: 10px; margin-left: 8px; } @@ -367,12 +382,77 @@ x-dialog a { color: var(--primary-color); } +x-dialog .font-subheading { + margin-bottom: 5px; +} + +/* PairDevicesDialog */ + +#keyInputContainer { + width: 100%; + display: flex; + justify-content: center; +} + +#keyInputContainer>input { + width: 45px; + height: 45px; + font-size: 30px; + padding: 0; + text-align: center; + display: -webkit-box !important; + display: -webkit-flex !important; + display: -moz-flex !important; + display: -ms-flexbox !important; + display: flex !important; + -webkit-justify-content: center; + -ms-justify-content: center; + justify-content: center; +} + +#keyInputContainer>input + * { + margin-left: 6px; +} + +#keyInputContainer>input:nth-of-type(4) { + margin-left: 18px; +} + +#roomKey { + font-size: 50px; + letter-spacing: min(calc((100vw - 80px - 99px) / 100 * 7), 23px); + display: inline-block; + text-indent: calc(0.5 * (11px + min(calc((100vw - 80px - 99px) / 100 * 6), 23px))); + margin: 15px -15px; +} + +#roomKeyQrCode { + padding: inherit; + margin: auto; + width: 80px; + height: 80px; +} + +#pairDeviceDialog>*>*>*>hr { + margin-top: 40px; + margin-bottom: 40px; +} + /* Receive Dialog */ + #receiveDialog .row { margin-top: 24px; margin-bottom: 8px; } +#fileName{ + word-break: break-all; +} + +#fileSize{ + padding-bottom: 5px; +} + /* Receive Text Dialog */ #receiveTextDialog #text { @@ -420,6 +500,11 @@ x-dialog a { color: var(--primary-color); } +.button[disabled] { + color: #5B5B66; +} + + .button, .icon-button { position: relative; @@ -445,7 +530,7 @@ x-dialog a { transition: opacity 300ms; } -.button:hover:before, +.button:not([disabled]):hover:before, .icon-button:hover:before { opacity: 0.1; } @@ -487,7 +572,7 @@ button::-moz-focus-inner { outline: none; padding: 16px 24px; border-radius: 16px; - margin: 8px 0; + margin: 10px 0; font-size: 14px; font-family: inherit; background: #f1f3f4; @@ -521,7 +606,7 @@ button::-moz-focus-inner { #about:not(:target) .fade-in { opacity: 0; pointer-events: none; - transition-delay: 0; + transition-delay: 0s; } #about .logo { @@ -561,7 +646,7 @@ button::-moz-focus-inner { width: 80px; height: 80px; position: absolute; - top: 0px; + top: 0; clip: rect(0px, 80px, 80px, 40px); --progress: rotate(0deg); transition: transform 200ms; @@ -756,3 +841,15 @@ x-dialog x-paper { overflow: hidden; } } + +/* webkit scrollbar style*/ + +::-webkit-scrollbar{ + width: 4px; + height: 4px; +} + +::-webkit-scrollbar-thumb{ + background: #bfbfbf; + border-radius: 4px; +} diff --git a/server/package-lock.json b/server/package-lock.json deleted file mode 100644 index 1f46126..0000000 --- a/server/package-lock.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "snapdrop", - "version": "1.0.0", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "ua-parser-js": { - "version": "0.7.24", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.24.tgz", - "integrity": "sha512-yo+miGzQx5gakzVK3QFfN0/L9uVhosXBBO7qmnk7c2iw1IhL212wfA3zbnI54B0obGwC/5NWub/iT9sReMx+Fw==" - }, - "unique-names-generator": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.3.0.tgz", - "integrity": "sha512-uNX6jVFjBXfZtsc7B8jVPJ3QdfCF/Sjde4gxsy3rNQmHuWGFarnU7IFGdxZKJ4h4uRjANQc6rG7GiGolRW9fgA==" - }, - "ws": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", - "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==" - } - } -}