implement device pairing via 6-digit code and qr-code

This commit is contained in:
schlagmichdoch 2023-01-10 05:07:57 +01:00
parent e559aecde7
commit 3c07a4199b
11 changed files with 1098 additions and 195 deletions

3
.gitignore vendored
View file

@ -1,4 +1,5 @@
node_modules node_modules
.DS_Store .DS_Store
fqdn.env fqdn.env
/docker/certs /docker/certs
qrcode-svg/

View file

@ -1,3 +1,4 @@
#Todo: fix turn server
version: "3" version: "3"
services: services:
node: node:

321
index.js
View file

@ -1,6 +1,8 @@
var process = require('process') var process = require('process')
var crypto = require('crypto')
var {spawn} = require('child_process') var {spawn} = require('child_process')
var net = require('net') var net = require('net')
// Handle SIGINT // Handle SIGINT
process.on('SIGINT', () => { process.on('SIGINT', () => {
console.info("SIGINT Received, exiting...") console.info("SIGINT Received, exiting...")
@ -87,6 +89,7 @@ class SnapdropServer {
this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request))); this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request)));
this._rooms = {}; this._rooms = {};
this._roomSecrets = {};
console.log('Snapdrop is running on port', port); console.log('Snapdrop is running on port', port);
} }
@ -94,7 +97,8 @@ class SnapdropServer {
_onConnection(peer) { _onConnection(peer) {
this._joinRoom(peer); this._joinRoom(peer);
peer.socket.on('message', message => this._onMessage(peer, message)); 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); this._keepAlive(peer);
// send displayName // send displayName
@ -118,74 +122,270 @@ class SnapdropServer {
switch (message.type) { switch (message.type) {
case 'disconnect': case 'disconnect':
this._leaveRoom(sender); this._onDisconnect(sender);
break; break;
case 'pong': case 'pong':
sender.lastBeat = Date.now(); sender.lastBeat = Date.now();
break; 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 // relay message to recipient
if (message.to && this._rooms[sender.ip]) { if (message.to && Peer.isValidUuid(message.to) && this._rooms[room]) {
const recipientId = message.to; // TODO: sanitize const recipientId = message.to;
const recipient = this._rooms[sender.ip][recipientId]; const recipient = this._rooms[room][recipientId];
delete message.to; delete message.to;
// add sender id // add sender id
message.sender = sender.id; message.sender = sender.id;
this._send(recipient, message); this._send(recipient, message);
return;
} }
} }
_joinRoom(peer) { _onDisconnect(sender) {
// if room doesn't exist, create it this._leaveRoom(sender);
if (!this._rooms[peer.ip]) { this._leaveAllSecretRooms(sender);
this._rooms[peer.ip] = {}; 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<message.roomSecrets.length; i++) {
this._deleteSecretRoom(sender, message.roomSecrets[i]);
}
}
_deleteSecretRoom(sender, roomSecret) {
const room = this._rooms[roomSecret];
if (room) {
for (const peerId in room) {
const peer = room[peerId];
this._leaveRoom(peer, 'secret', roomSecret);
this._send(peer, {
type: 'secret-room-deleted',
roomSecret: roomSecret,
});
}
}
this._notifyPeers(sender);
}
getRandomString(length) {
let string = "";
while (string.length < length) {
let arr = new Uint16Array(length);
crypto.webcrypto.getRandomValues(arr);
arr = Array.apply([], arr); /* turn into non-typed array */
arr = arr.map(function (r) {
return r % 128
})
arr = arr.filter(function (r) {
/* strip non-printables: if we transform into desirable range we have a propability bias, so I suppose we better skip this character */
return r === 45 || r >= 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 // notify all other peers
for (const otherPeerId in this._rooms[peer.ip]) { for (const otherPeerId in this._rooms[room]) {
if (otherPeerId === peer.id) continue; if (otherPeerId === peer.id) continue;
const otherPeer = this._rooms[peer.ip][otherPeerId]; const otherPeer = this._rooms[room][otherPeerId];
this._send(otherPeer, { this._send(otherPeer, {
type: 'peer-joined', type: 'peer-joined',
peer: peer.getInfo() peer: peer.getInfo(),
roomType: roomType,
roomSecret: roomSecret
}); });
} }
// notify peer about the other peers // notify peer about the other peers
const otherPeers = []; const otherPeers = [];
for (const otherPeerId in this._rooms[peer.ip]) { for (const otherPeerId in this._rooms[room]) {
if (otherPeerId === peer.id) continue; if (otherPeerId === peer.id) continue;
otherPeers.push(this._rooms[peer.ip][otherPeerId].getInfo()); otherPeers.push(this._rooms[room][otherPeerId].getInfo());
} }
this._send(peer, { this._send(peer, {
type: 'peers', type: 'peers',
peers: otherPeers peers: otherPeers,
roomType: roomType,
roomSecret: roomSecret
}); });
// add peer to room
this._rooms[peer.ip][peer.id] = peer;
} }
_leaveRoom(peer) { _joinSecretRooms(peer, roomSecrets) {
if (!this._rooms[peer.ip] || !this._rooms[peer.ip][peer.id]) return; for (let i=0; i<roomSecrets.length; i++) {
this._cancelKeepAlive(this._rooms[peer.ip][peer.id]); this._joinRoom(peer, 'secret', roomSecrets[i])
}
}
// delete the peer _leaveAllSecretRooms(peer) {
delete this._rooms[peer.ip][peer.id]; for (const roomSecret in peer.roomSecrets) {
this._leaveRoom(peer, 'secret', roomSecret);
peer.socket.terminate();
//if room is empty, delete the room
if (!Object.keys(this._rooms[peer.ip]).length) {
delete this._rooms[peer.ip];
} else {
// notify all other peers
for (const otherPeerId in this._rooms[peer.ip]) {
const otherPeer = this._rooms[peer.ip][otherPeerId];
this._send(otherPeer, { type: 'peer-left', peerId: peer.id });
}
} }
} }
@ -193,7 +393,7 @@ class SnapdropServer {
if (!peer) return; if (!peer) return;
if (this._wss.readyState !== this._wss.OPEN) return; if (this._wss.readyState !== this._wss.OPEN) return;
message = JSON.stringify(message); message = JSON.stringify(message);
peer.socket.send(message, error => ''); peer.socket.send(message);
} }
_keepAlive(peer) { _keepAlive(peer) {
@ -204,6 +404,7 @@ class SnapdropServer {
} }
if (Date.now() - peer.lastBeat > 2 * timeout) { if (Date.now() - peer.lastBeat > 2 * timeout) {
this._leaveRoom(peer); this._leaveRoom(peer);
this._leaveAllSecretRooms(peer);
return; return;
} }
@ -242,7 +443,10 @@ class Peer {
// for keepalive // for keepalive
this.timerId = 0; this.timerId = 0;
this.lastBeat = Date.now(); this.lastBeat = Date.now();
console.debug(this.name.displayName)
this.roomSecrets = [];
this.roomKey = null;
this.roomKeyRate = 0;
} }
_setIP(request) { _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)) { if (this.ip === '::1' || this.ip === '::ffff:127.0.0.1' || this.ip === '::1' || this.ipIsPrivate(this.ip)) {
this.ip = '127.0.0.1'; this.ip = '127.0.0.1';
} }
console.debug(this.ip)
} }
ipIsPrivate(ip) { ipIsPrivate(ip) {
@ -300,10 +503,10 @@ class Peer {
_setPeerId(request) { _setPeerId(request) {
let peer_id = new URL(request.url, "http://server").searchParams.get("peer_id"); 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; this.id = peer_id;
} else { } 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 isValidUuid(uuid) {
static uuid() { return /^([0-9]|[a-f]){8}-(([0-9]|[a-f]){4}-){3}([0-9]|[a-f]){12}$/.test(uuid);
let uuid = '', }
ii;
for (ii = 0; ii < 32; ii += 1) { addRoomSecret(roomSecret) {
switch (ii) { if (!roomSecret in this.roomSecrets) {
case 8: this.roomSecrets.push(roomSecret);
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);
}
} }
return uuid; }
};
removeRoomSecret(roomSecret) {
if (roomSecret in this.roomSecrets) {
delete this.roomSecrets[roomSecret];
}
}
} }
Object.defineProperty(String.prototype, 'hashCode', { Object.defineProperty(String.prototype, 'hashCode', {

View file

@ -11,6 +11,7 @@
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<meta name="apple-mobile-web-app-capable" content="no"> <meta name="apple-mobile-web-app-capable" content="no">
<meta name="apple-mobile-web-app-title" content="Snapdrop"> <meta name="apple-mobile-web-app-title" content="Snapdrop">
<meta name="application-name" content="Snapdrop">
<!-- Descriptions --> <!-- Descriptions -->
<meta name="description" content="Instantly share images, videos, PDFs, and links with people nearby. Peer2Peer and Open Source. No Setup, No Signup."> <meta name="description" content="Instantly share images, videos, PDFs, and links with people nearby. Peer2Peer and Open Source. No Setup, No Signup.">
<meta name="keywords" content="File, Transfer, Share, Peer2Peer"> <meta name="keywords" content="File, Transfer, Share, Peer2Peer">
@ -43,26 +44,37 @@
<use xlink:href="#info-outline" /> <use xlink:href="#info-outline" />
</svg> </svg>
</a> </a>
<a href="#" id="theme" class="icon-button" title="Switch Darkmode/Lightmode" > <a id="theme" class="icon-button" title="Switch Darkmode/Lightmode" >
<svg class="icon"> <svg class="icon">
<use xlink:href="#icon-theme" /> <use xlink:href="#icon-theme" />
</svg> </svg>
</a> </a>
<a href="#" id="notification" class="icon-button" title="Enable Notifications" hidden> <a id="notification" class="icon-button" title="Enable Notifications" hidden>
<svg class="icon"> <svg class="icon">
<use xlink:href="#notifications" /> <use xlink:href="#notifications" />
</svg> </svg>
</a> </a>
<a href="#" id="install" class="icon-button" title="Install Snapdrop" hidden> <a id="install" class="icon-button" title="Install Snapdrop" hidden>
<svg class="icon"> <svg class="icon">
<use xlink:href="#homescreen" /> <use xlink:href="#homescreen" />
</svg> </svg>
</a> </a>
<a id="pair-device" class="icon-button" >
<svg class="icon">
<use xlink:href="#pair-device-icon" />
</svg>
</a>
<a id="clear-pair-devices" class="icon-button" hidden>
<svg class="icon">
<use xlink:href="#clear-pair-devices-icon" />
</svg>
</a>
</header> </header>
<!-- Peers --> <!-- Peers -->
<x-peers class="center"></x-peers> <x-peers class="center"></x-peers>
<x-no-peers> <x-no-peers>
<h2>Open Snapdrop on other devices to send files</h2> <h2>Open Snapdrop on other devices to send files</h2>
<div>Pair devices to be discoverable on other networks</div>
</x-no-peers> </x-no-peers>
<x-instructions desktop="Click to send files or right click to send a message" mobile="Tap to send files or long tap to send a message"></x-instructions> <x-instructions desktop="Click to send files or right click to send a message" mobile="Tap to send files or long tap to send a message"></x-instructions>
<a id="cancelPasteModeBtn" class="button" close hidden style="z-index: 2">Cancel</a> <a id="cancelPasteModeBtn" class="button" close hidden style="z-index: 2">Cancel</a>
@ -71,9 +83,50 @@
<svg class="icon logo"> <svg class="icon logo">
<use xlink:href="#wifi-tethering" /> <use xlink:href="#wifi-tethering" />
</svg> </svg>
<div id="displayName" placeholder="The easiest way to transfer data across devices"></div> <div id="displayName" placeholder="&nbsp;"></div>
<div class="font-body2">You can be discovered by everyone on this network</div> <div class="font-body2">You can be discovered by everyone on this network</div>
</footer> </footer>
<!-- JoinRoom Dialog -->
<x-dialog id="pairDeviceDialog">
<form action="#">
<x-background class="full center text-center">
<x-paper shadow="2">
<h2 class="center">Pair Devices</h2>
<div class="center" id="roomKeyQrCode"></div>
<h1 class="center" id="roomKey">000 000</h1>
<div id="pairInstructions" class="font-subheading center text-center">Input this key on another device<br>or scan the QR-Code.</div>
<hr>
<div id="keyInputContainer">
<input type="tel" id="char0" class="textarea center" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" autofocus contenteditable placeholder="">
<input type="tel" id="char1" class="textarea center" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" contenteditable placeholder="">
<input type="tel" id="char2" class="textarea center" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" contenteditable placeholder="">
<input type="tel" id="char3" class="textarea center" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" contenteditable placeholder="">
<input type="tel" id="char4" class="textarea center" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" contenteditable placeholder="">
<input type="tel" id="char5" class="textarea center" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" contenteditable placeholder="">
</div>
<div class="font-subheading center text-center">Enter key from another device to continue.</div>
<div class="row-reverse space-between">
<button class="button" type="submit" disabled>Pair</button>
<a class="button" close>Cancel</a>
</div>
</x-paper>
</x-background>
</form>
</x-dialog>
<x-dialog id="clearDevicesDialog">
<form action="#">
<x-background class="full center text-center">
<x-paper shadow="2">
<h2 class="center">Unpair Devices</h2>
<div class="font-subheading center text-center">Are you sure to unpair all devices?</div>
<div class="row-reverse space-between">
<button class="button" type="submit">Unpair all devices</button>
<a class="button" close>Cancel</a>
</div>
</x-paper>
</x-background>
</form>
</x-dialog>
<!-- Receive Dialog --> <!-- Receive Dialog -->
<x-dialog id="receiveDialog"> <x-dialog id="receiveDialog">
<x-background class="full center"> <x-background class="full center">
@ -211,9 +264,18 @@
<symbol id="icon-theme" viewBox="0 0 24 24"> <symbol id="icon-theme" viewBox="0 0 24 24">
<rect fill="none" height="24" width="24"/><path d="M12,3c-4.97,0-9,4.03-9,9s4.03,9,9,9s9-4.03,9-9c0-0.46-0.04-0.92-0.1-1.36c-0.98,1.37-2.58,2.26-4.4,2.26 c-2.98,0-5.4-2.42-5.4-5.4c0-1.81,0.89-3.42,2.26-4.4C12.92,3.04,12.46,3,12,3L12,3z"/> <rect fill="none" height="24" width="24"/><path d="M12,3c-4.97,0-9,4.03-9,9s4.03,9,9,9s9-4.03,9-9c0-0.46-0.04-0.92-0.1-1.36c-0.98,1.37-2.58,2.26-4.4,2.26 c-2.98,0-5.4-2.42-5.4-5.4c0-1.81,0.89-3.42,2.26-4.4C12.92,3.04,12.46,3,12,3L12,3z"/>
</symbol> </symbol>
<symbol id="pair-device-icon" viewBox="0 0 640 512">
<!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path d="M579.8 267.7c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114L422.3 334.8c-31.5 31.5-82.5 31.5-114 0c-27.9-27.9-31.5-71.8-8.6-103.8l1.1-1.6c10.3-14.4 6.9-34.4-7.4-44.6s-34.4-6.9-44.6 7.4l-1.1 1.6C206.5 251.2 213 330 263 380c56.5 56.5 148 56.5 204.5 0L579.8 267.7zM60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5L217.7 177.2c31.5-31.5 82.5-31.5 114 0c27.9 27.9 31.5 71.8 8.6 103.9l-1.1 1.6c-10.3 14.4-6.9 34.4 7.4 44.6s34.4 6.9 44.6-7.4l1.1-1.6C433.5 260.8 427 182 377 132c-56.5-56.5-148-56.5-204.5 0L60.2 244.3z"/>
</symbol>
<symbol id="clear-pair-devices-icon" viewBox="0 0 640 512">
<!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L489.3 358.2l90.5-90.5c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114l-96 96-31.9-25C430.9 239.6 420.1 175.1 377 132c-52.2-52.3-134.5-56.2-191.3-11.7L38.8 5.1zM239 162c30.1-14.9 67.7-9.9 92.8 15.3c20 20 27.5 48.3 21.7 74.5L239 162zM406.6 416.4L220.9 270c-2.1 39.8 12.2 80.1 42.2 110c38.9 38.9 94.4 51 143.6 36.3zm-290-228.5L60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5l61.8-61.8-50.6-39.9z"/>
</symbol>
</svg> </svg>
<!-- Scripts --> <!-- Scripts -->
<script src="scripts/network.js"></script> <script src="scripts/network.js"></script>
<script src="scripts/qrcode.js" async></script>
<script src="scripts/ui.js"></script> <script src="scripts/ui.js"></script>
<script src="scripts/theme.js" async></script> <script src="scripts/theme.js" async></script>
<script src="scripts/clipboard.js" async></script> <script src="scripts/clipboard.js" async></script>

View file

@ -26,7 +26,8 @@
}], }],
"background_color": "#efefef", "background_color": "#efefef",
"start_url": "/", "start_url": "/",
"display": "minimal-ui", "scope": "/",
"display": "standalone",
"theme_color": "#3367d6", "theme_color": "#3367d6",
"share_target": { "share_target": {
"method":"GET", "method":"GET",
@ -37,4 +38,4 @@
"url": "url" "url": "url"
} }
} }
} }

View file

@ -9,6 +9,13 @@ class ServerConnection {
Events.on('pagehide', _ => this._disconnect()); Events.on('pagehide', _ => this._disconnect());
document.addEventListener('visibilitychange', _ => this._onVisibilityChange()); document.addEventListener('visibilitychange', _ => this._onVisibilityChange());
Events.on('reconnect', _ => this._reconnect()); 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() { _connect() {
@ -25,11 +32,27 @@ class ServerConnection {
_onOpen() { _onOpen() {
console.log('WS: server connected'); console.log('WS: server connected');
if (!this.firstConnect) { Events.fire('ws-connected');
this.firstConnect = true; }
_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; 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) { _onMessage(msg) {
@ -37,10 +60,10 @@ class ServerConnection {
if (msg.type !== 'ping') console.log('WS:', msg); if (msg.type !== 'ping') console.log('WS:', msg);
switch (msg.type) { switch (msg.type) {
case 'peers': case 'peers':
Events.fire('peers', msg.peers); Events.fire('peers', msg);
break; break;
case 'peer-joined': case 'peer-joined':
Events.fire('peer-joined', msg.peer); Events.fire('peer-joined', msg);
break; break;
case 'peer-left': case 'peer-left':
Events.fire('peer-left', msg.peerId); Events.fire('peer-left', msg.peerId);
@ -52,28 +75,61 @@ class ServerConnection {
this.send({ type: 'pong' }); this.send({ type: 'pong' });
break; break;
case 'display-name': case 'display-name':
sessionStorage.setItem("peerId", msg.message.peerId); this._onDisplayName(msg);
Events.fire('display-name', 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; break;
default: default:
console.error('WS: unknown message type', msg); console.error('WS: unknown message type', msg);
} }
} }
send(message) { send(msg) {
if (!this._isConnected()) return; 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() { _endpoint() {
// hack to detect if deployment or development environment // hack to detect if deployment or development environment
const protocol = location.protocol.startsWith('https') ? 'wss' : 'ws'; const protocol = location.protocol.startsWith('https') ? 'wss' : 'ws';
const webrtc = window.isRtcSupported ? '/webrtc' : '/fallback'; const webrtc = window.isRtcSupported ? '/webrtc' : '/fallback';
let url = new URL(protocol + '://' + location.host + location.pathname + 'server' + webrtc); let ws_url = new URL(protocol + '://' + location.host + location.pathname + 'server' + webrtc);
if (sessionStorage.getItem('peerId')) { const peerId = this._peerId();
url.searchParams.append('peer_id', sessionStorage.getItem('peerId')) if (peerId) {
ws_url.searchParams.append('peer_id', peerId)
} }
return url.toString(); return ws_url.toString();
}
_peerId() {
return sessionStorage.getItem("peerId");
} }
_disconnect() { _disconnect() {
@ -81,7 +137,7 @@ class ServerConnection {
this._socket.onclose = null; this._socket.onclose = null;
this._socket.close(); this._socket.close();
this._socket = null; this._socket = null;
Events.fire('ws-disconnect'); Events.fire('ws-disconnected');
} }
_onDisconnect() { _onDisconnect() {
@ -89,7 +145,7 @@ class ServerConnection {
Events.fire('notify-user', 'Connection lost. Retry in 5 seconds...'); Events.fire('notify-user', 'Connection lost. Retry in 5 seconds...');
clearTimeout(this._reconnectTimer); clearTimeout(this._reconnectTimer);
this._reconnectTimer = setTimeout(_ => this._connect(), 5000); this._reconnectTimer = setTimeout(_ => this._connect(), 5000);
Events.fire('ws-disconnect'); Events.fire('ws-disconnected');
} }
_onVisibilityChange() { _onVisibilityChange() {
@ -117,9 +173,11 @@ class ServerConnection {
class Peer { class Peer {
constructor(serverConnection, peerId) { constructor(serverConnection, peerId, roomType, roomSecret) {
this._server = serverConnection; this._server = serverConnection;
this._peerId = peerId; this._peerId = peerId;
this._roomType = roomType;
this._roomSecret = roomSecret;
this._filesQueue = []; this._filesQueue = [];
this._busy = false; this._busy = false;
} }
@ -262,8 +320,8 @@ class Peer {
class RTCPeer extends Peer { class RTCPeer extends Peer {
constructor(serverConnection, peerId) { constructor(serverConnection, peerId, roomType, roomSecret) {
super(serverConnection, peerId); super(serverConnection, peerId, roomType, roomSecret);
if (!peerId) return; // we will listen for a caller if (!peerId) return; // we will listen for a caller
this._connect(peerId, true); this._connect(peerId, true);
} }
@ -283,7 +341,7 @@ class RTCPeer extends Peer {
this._peerId = peerId; this._peerId = peerId;
this._conn = new RTCPeerConnection(RTCPeer.config); this._conn = new RTCPeerConnection(RTCPeer.config);
this._conn.onicecandidate = e => this._onIceCandidate(e); 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); this._conn.oniceconnectionstatechange = e => this._onIceConnectionStateChange(e);
} }
@ -342,7 +400,7 @@ class RTCPeer extends Peer {
this._connect(this._peerId, true); // reopen the channel this._connect(this._peerId, true); // reopen the channel
} }
_onConnectionStateChange(e) { _onConnectionStateChange() {
console.log('RTC: state changed:', this._conn.connectionState); console.log('RTC: state changed:', this._conn.connectionState);
switch (this._conn.connectionState) { switch (this._conn.connectionState) {
case 'disconnected': case 'disconnected':
@ -379,11 +437,15 @@ class RTCPeer extends Peer {
_sendSignal(signal) { _sendSignal(signal) {
signal.type = 'signal'; signal.type = 'signal';
signal.to = this._peerId; signal.to = this._peerId;
signal.roomType = this._roomType;
signal.roomSecret = this._roomSecret;
this._server.send(signal); this._server.send(signal);
} }
refresh() { refresh() {
// check if channel is open. otherwise create one // check if channel is open. otherwise create one
console.debug("refresh:");
console.debug(this._conn);
if (this._isConnected() || this._isConnecting()) return; if (this._isConnected() || this._isConnecting()) return;
this._connect(this._peerId, this._isCaller); 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 { class PeersManager {
constructor(serverConnection) { constructor(serverConnection) {
@ -408,26 +479,40 @@ class PeersManager {
Events.on('send-text', e => this._onSendText(e.detail)); Events.on('send-text', e => this._onSendText(e.detail));
Events.on('peer-joined', e => this._onPeerJoined(e.detail)); Events.on('peer-joined', e => this._onPeerJoined(e.detail));
Events.on('peer-left', e => this._onPeerLeft(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) { _onMessage(message) {
if (!this.peers[message.sender]) { this._refreshOrCreatePeer(message.sender, message.roomType, message.roomSecret);
this.peers[message.sender] = new RTCPeer(this._server);
}
this.peers[message.sender].onServerMessage(message); this.peers[message.sender].onServerMessage(message);
} }
_onPeers(peers) { _refreshOrCreatePeer(id, roomType, roomSecret) {
peers.forEach(peer => { 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]) { 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; return;
} }
if (window.isRtcSupported && peer.rtcSupported) { 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 { } 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); this.peers[message.to].sendText(message.text);
} }
_onPeerJoined(peer) { _onPeerJoined(message) {
this._onMessage(peer.id); this._onMessage({sender: message.peer.id, roomType: message.roomType, roomSecret: message.roomSecret});
} }
_onPeerLeft(peerId) { _onPeerLeft(peerId) {
@ -461,12 +546,14 @@ class PeersManager {
Object.keys(this.peers).forEach(peerId => this._onPeerLeft(peerId)); Object.keys(this.peers).forEach(peerId => this._onPeerLeft(peerId));
} }
} }
}
class WSPeer extends Peer { _onSecretRoomDeleted(roomSecret) {
_send(message) { for (const peerId in this.peers) {
message.to = this._peerId; const peer = this.peers[peerId];
this._server.send(message); if (peer._roomSecret === roomSecret) {
this._onPeerLeft(peerId);
}
}
} }
} }
@ -538,7 +625,6 @@ class FileDigester {
unchunk(chunk) { unchunk(chunk) {
this._buffer.push(chunk); this._buffer.push(chunk);
this._bytesReceived += chunk.byteLength || chunk.size; this._bytesReceived += chunk.byteLength || chunk.size;
const totalChunks = this._buffer.length;
this.progress = this._bytesReceived / this._size; this.progress = this._bytesReceived / this._size;
if (isNaN(this.progress)) this.progress = 1 if (isNaN(this.progress)) this.progress = 1
@ -571,7 +657,7 @@ class Events {
RTCPeer.config = { RTCPeer.config = {
'sdpSemantics': 'unified-plan', 'sdpSemantics': 'unified-plan',
iceServers: [ 'iceServers': [
{ {
urls: 'stun:stun.l.google.com:19302' urls: 'stun:stun.l.google.com:19302'
}, },

2
public/scripts/qrcode.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
(function(){ (function(){
// Select the button // Select the button
const btnTheme = document.getElementById('theme'); const btnTheme = document.getElementById('theme');
// Check for dark mode preference at the OS level // 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 // Get the user's theme preference from local storage, if it's available
const currentTheme = localStorage.getItem('theme'); const currentTheme = localStorage.getItem('theme');
// If the user's preference in localStorage is dark... // 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 // ...let's toggle the .dark-theme class on the body
document.body.classList.toggle('dark-theme'); document.body.classList.toggle('dark-theme');
// Otherwise, if the user's preference in localStorage is light... // 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 // ...let's toggle the .light-theme class on the body
document.body.classList.toggle('light-theme'); document.body.classList.toggle('light-theme');
} }
// Listen for a click on the button // Listen for a click on the button
btnTheme.addEventListener('click', function() { btnTheme.addEventListener('click', function(e) {
e.preventDefault();
// If the user's OS setting is dark and matches our .dark-theme class... // If the user's OS setting is dark and matches our .dark-theme class...
let theme;
if (prefersDarkScheme.matches) { if (prefersDarkScheme.matches) {
// ...then toggle the light mode class // ...then toggle the light mode class
document.body.classList.toggle('light-theme'); document.body.classList.toggle('light-theme');
// ...but use .dark-theme if the .light-theme class is already on the body, // ...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 { } else {
// Otherwise, let's do the same thing, but for .dark-theme // Otherwise, let's do the same thing, but for .dark-theme
document.body.classList.toggle('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 // Finally, let's save the current preference to localStorage to keep using it
localStorage.setItem('theme', theme); localStorage.setItem('theme', theme);
}); });
})(); })();

View file

@ -25,30 +25,56 @@ class PeersUI {
Events.on('peers', e => this._onPeers(e.detail)); Events.on('peers', e => this._onPeers(e.detail));
Events.on('file-progress', e => this._onFileProgress(e.detail)); Events.on('file-progress', e => this._onFileProgress(e.detail));
Events.on('paste', e => this._onPaste(e)); 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 = {}; this.peers = {};
} }
_onPeerJoined(peer) { _onPeerJoined(msg) {
if (this.peers[peer.id]) return; // peer already exists 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; this.peers[peer.id] = peer;
} }
_onPeerConnected(peerId) { _onPeerConnected(peerId) {
if(this.peers[peerId]) if(this.peers[peerId] && !$(peerId))
new PeerUI(this.peers[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(); 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) { _onPeerDisconnected(peerId) {
const $peer = $(peerId); const $peer = $(peerId);
if (!$peer) return; if (!$peer) return;
$peer.remove(); $peer.remove();
setTimeout(e => window.animateBackground(true), 1750); // Start animation again setTimeout(_ => window.animateBackground(true), 1750); // Start animation again
} }
_onPeerLeft(peerId) { _onPeerLeft(peerId) {
@ -56,6 +82,16 @@ class PeersUI {
delete this.peers[peerId]; 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) { _onFileProgress(progress) {
const peerId = progress.sender || progress.recipient; const peerId = progress.sender || progress.recipient;
const $peer = $(peerId); const $peer = $(peerId);
@ -63,10 +99,17 @@ class PeersUI {
$peer.ui.setProgress(progress.progress); $peer.ui.setProgress(progress.progress);
} }
_clearPeers() { _clearPeers(roomType = 'all') {
const $peers = $$('x-peers').innerHTML = ''; for (const peerId in this.peers) {
Object.keys(this.peers).forEach(peerId => delete this.peers[peerId]); if (roomType === 'all' || this.peers[peerId].roomType === roomType) {
setTimeout(e => window.animateBackground(true), 1750); // Start animation again const peerNode = $(peerId);
if(peerNode) peerNode.remove();
delete this.peers[peerId];
}
}
if ($$('x-peers').innerHTML === '') {
setTimeout(_ => window.animateBackground(true), 1750); // Start animation again
}
} }
_getPeers() { _getPeers() {
@ -76,7 +119,9 @@ class PeersUI {
peers.push({ peers.push({
id: peersNode.id, id: peersNode.id,
name: peersNode.name, name: peersNode.name,
rtcSupported: peersNode.rtcSupported rtcSupported: peersNode.rtcSupported,
roomType: peersNode.roomType,
roomSecret: peersNode.roomSecret
}) })
}); });
return peers; return peers;
@ -103,7 +148,6 @@ class PeersUI {
descriptor = files[0].name; descriptor = files[0].name;
noPeersMessage = `Open Snapdrop on other devices to send <i>${descriptor}</i> directly`; noPeersMessage = `Open Snapdrop on other devices to send <i>${descriptor}</i> directly`;
} else if (files.length > 1) { } else if (files.length > 1) {
console.debug(files);
descriptor = `${files.length} files`; descriptor = `${files.length} files`;
noPeersMessage = `Open Snapdrop on other devices to send ${descriptor} directly`; noPeersMessage = `Open Snapdrop on other devices to send ${descriptor} directly`;
} else if (text.length > 0) { } else if (text.length > 0) {
@ -132,7 +176,7 @@ class PeersUI {
window.pasteMode.activated = true; window.pasteMode.activated = true;
console.log('Paste mode activated.') console.log('Paste mode activated.')
this._onPeers(this._getPeers()); this._redrawPeers();
} }
} }
@ -159,7 +203,7 @@ class PeersUI {
cancelPasteModeBtn.removeEventListener('click', this._cancelPasteMode); cancelPasteModeBtn.removeEventListener('click', this._cancelPasteMode);
cancelPasteModeBtn.setAttribute('hidden', ""); cancelPasteModeBtn.setAttribute('hidden', "");
this._onPeers(this._getPeers()); this._redrawPeers();
} }
} }
@ -213,22 +257,23 @@ class PeerUI {
constructor(peer) { constructor(peer) {
this._peer = peer; this._peer = peer;
this._roomType = peer.roomType;
this._roomSecret = peer.roomSecret;
this._initDom(); this._initDom();
this._bindListeners(this.$el); this._bindListeners(this.$el);
$$('x-peers').appendChild(this.$el); $$('x-peers').appendChild(this.$el);
setTimeout(e => window.animateBackground(false), 1750); // Stop animation setTimeout(_ => window.animateBackground(false), 1750); // Stop animation
} }
_initDom() { _initDom() {
const el = document.createElement('x-peer'); const el = document.createElement('x-peer');
el.id = this._peer.id; el.id = this._peer.id;
el.name = this._peer.name;
el.rtcSupported = this._peer.rtcSupported;
el.innerHTML = this.html(); el.innerHTML = this.html();
el.ui = this; el.ui = this;
el.querySelector('svg use').setAttribute('xlink:href', this._icon()); el.querySelector('svg use').setAttribute('xlink:href', this._icon());
el.querySelector('.name').textContent = this._displayName(); el.querySelector('.name').textContent = this._displayName();
el.querySelector('.device-name').textContent = this._deviceName(); el.querySelector('.device-name').textContent = this._deviceName();
el.classList.add(`type-${this._roomType}`);
this.$el = el; this.$el = el;
this.$progress = el.querySelector('.progress'); this.$progress = el.querySelector('.progress');
} }
@ -241,7 +286,7 @@ class PeerUI {
el.addEventListener('dragleave', e => this._onDragEnd(e)); el.addEventListener('dragleave', e => this._onDragEnd(e));
el.addEventListener('dragover', e => this._onDragOver(e)); el.addEventListener('dragover', e => this._onDragOver(e));
el.addEventListener('contextmenu', e => this._onRightClick(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)); el.addEventListener('touchend', e => this._onTouchEnd(e));
// prevent browser's default file drop behavior // prevent browser's default file drop behavior
Events.on('dragover', e => e.preventDefault()); Events.on('dragover', e => e.preventDefault());
@ -329,7 +374,7 @@ class PeerUI {
Events.fire('text-recipient', this._peer.id); Events.fire('text-recipient', this._peer.id);
} }
_onTouchStart(e) { _onTouchStart() {
this._touchStart = Date.now(); this._touchStart = Date.now();
this._touchTimer = setTimeout(_ => this._onTouchEnd(), 610); this._touchTimer = setTimeout(_ => this._onTouchEnd(), 610);
} }
@ -348,8 +393,9 @@ class PeerUI {
class Dialog { class Dialog {
constructor(id) { constructor(id) {
this.$el = $(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]'); this.$autoFocus = this.$el.querySelector('[autofocus]');
Events.on('ws-disconnected', _ => this.hide());
} }
show() { show() {
@ -359,8 +405,10 @@ class Dialog {
hide() { hide() {
this.$el.removeAttribute('show'); this.$el.removeAttribute('show');
document.activeElement.blur(); if (this.$autoFocus) {
window.blur(); document.activeElement.blur();
window.blur();
}
} }
} }
@ -419,7 +467,7 @@ class ReceiveDialog extends Dialog {
// fallback for iOS // fallback for iOS
$a.target = '_blank'; $a.target = '_blank';
const reader = new FileReader(); const reader = new FileReader();
reader.onload = e => $a.href = reader.result; reader.onload = _ => $a.href = reader.result;
reader.readAsDataURL(file.blob); 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 { class SendTextDialog extends Dialog {
constructor() { constructor() {
super('sendTextDialog'); 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'); this.$text = this.$el.querySelector('#textInput');
const button = this.$el.querySelector('form'); const button = this.$el.querySelector('form');
button.addEventListener('submit', e => this._send(e)); button.addEventListener('submit', e => this._send(e));
@ -490,6 +782,7 @@ class SendTextDialog extends Dialog {
to: this._recipient, to: this._recipient,
text: this.$text.innerText text: this.$text.innerText
}); });
this.$text.innerText = "";
} }
} }
@ -545,7 +838,6 @@ class Toast extends Dialog {
} }
} }
class Notifications { class Notifications {
constructor() { constructor() {
@ -556,7 +848,7 @@ class Notifications {
if (Notification.permission !== 'granted') { if (Notification.permission !== 'granted') {
this.$button = $('notification'); this.$button = $('notification');
this.$button.removeAttribute('hidden'); 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('text-received', e => this._messageNotification(e.detail.text));
Events.on('file-received', e => this._downloadNotification(e.detail.name)); Events.on('file-received', e => this._downloadNotification(e.detail.name));
@ -568,7 +860,7 @@ class Notifications {
Events.fire('notify-user', Notifications.PERMISSION_ERROR || 'Error'); Events.fire('notify-user', Notifications.PERMISSION_ERROR || 'Error');
return; return;
} }
this._notify('Even more snappy sharing!'); this._notify('Notifications enabled.');
this.$button.setAttribute('hidden', 1); this.$button.setAttribute('hidden', 1);
}); });
} }
@ -603,10 +895,10 @@ class Notifications {
if (document.visibilityState !== 'visible') { if (document.visibilityState !== 'visible') {
if (isURL(message)) { if (isURL(message)) {
const notification = this._notify(message, 'Click to open link'); 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 { } else {
const notification = this._notify(message, 'Click to copy text'); 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') { if (document.visibilityState !== 'visible') {
const notification = this._notify(message, 'Click to download'); const notification = this._notify(message, 'Click to download');
if (!window.isDownloadSupported) return; 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) { _copyText(message, notification) {
notification.close(); if (navigator.clipboard.writeText(message)) {
if (!navigator.clipboard.writeText(message)) return; notification.close();
this._notify('Copied text to clipboard'); this._notify('Copied text to clipboard');
} else {
this._notify('Writing to clipboard failed. Copy manually!');
}
} }
_bind(notification, handler) { _bind(notification, handler) {
if (notification.then) { if (notification.then) {
notification.then(e => serviceWorker.getNotifications().then(notifications => { notification.then(_ => serviceWorker.getNotifications().then(notifications => {
serviceWorker.addEventListener('notificationclick', handler); serviceWorker.addEventListener('notificationclick', handler);
})); }));
} else { } else {
@ -641,7 +937,6 @@ class Notifications {
} }
} }
class NetworkStatusUI { class NetworkStatusUI {
constructor() { constructor() {
@ -658,6 +953,10 @@ class NetworkStatusUI {
} }
_showOnlineMessage() { _showOnlineMessage() {
if (!this.firstConnect) {
this.firstConnect = true;
return;
}
Events.fire('notify-user', 'You are back online'); Events.fire('notify-user', 'You are back online');
window.animateBackground(true); 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<e.target.result.length; i++) {
secrets.push(e.target.result[i].secret);
}
console.log(`Request successful. Retrieved ${secrets.length} room_secrets`);
resolve(secrets);
}
}
DBOpenRequest.onerror = (e) => {
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 { class Snapdrop {
constructor() { constructor() {
const server = new ServerConnection(); Events.on('load', _ => {
const peers = new PeersManager(server); const server = new ServerConnection();
const peersUI = new PeersUI(); const peers = new PeersManager(server);
Events.on('load', e => { const peersUI = new PeersUI();
const receiveDialog = new ReceiveDialog(); const receiveDialog = new ReceiveDialog();
const sendTextDialog = new SendTextDialog(); const sendTextDialog = new SendTextDialog();
const receiveTextDialog = new ReceiveTextDialog(); const receiveTextDialog = new ReceiveTextDialog();
const pairDeviceDialog = new PairDeviceDialog();
const clearDevicesDialog = new ClearDevicesDialog();
const toast = new Toast(); const toast = new Toast();
const notifications = new Notifications(); const notifications = new Notifications();
const networkStatusUI = new NetworkStatusUI(); const networkStatusUI = new NetworkStatusUI();
@ -700,10 +1176,10 @@ class Snapdrop {
} }
} }
const persistentStorage = new PersistentStorage();
const snapdrop = new Snapdrop(); const snapdrop = new Snapdrop();
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js') navigator.serviceWorker.register('/service-worker.js')
.then(serviceWorker => { .then(serviceWorker => {
@ -714,6 +1190,11 @@ if ('serviceWorker' in navigator) {
window.addEventListener('beforeinstallprompt', e => { window.addEventListener('beforeinstallprompt', e => {
if (window.matchMedia('(display-mode: standalone)').matches) { 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 // don't display install banner when installed
return e.preventDefault(); return e.preventDefault();
} else { } else {
@ -805,7 +1286,7 @@ as the user has dismissed the permission prompt several times.
This can be reset in Page Info This can be reset in Page Info
which can be accessed by clicking the lock icon next to the URL.`; 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; document.body.onclick = null;
if (!(/.*Version.*Safari.*/.test(navigator.userAgent))) return; if (!(/.*Version.*Safari.*/.test(navigator.userAgent))) return;
blop.play(); blop.play();

View file

@ -35,8 +35,13 @@ body {
flex-direction: row-reverse; flex-direction: row-reverse;
} }
.space-between {
justify-content: space-between;
}
.row { .row {
display: flex; display: flex;
justify-content: center;
flex-direction: row; flex-direction: row;
} }
@ -106,13 +111,18 @@ h3 {
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
margin: 16px 0; margin: 16px 0;
color: var(--primary-color);
} }
.font-subheading { .font-subheading {
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 400;
line-height: 24px; line-height: 24px;
word-break: break-all; word-break: normal;
}
.text-center {
text-align: center;
} }
.font-body1, .font-body1,
@ -183,6 +193,7 @@ x-peers {
overflow: hidden; overflow: hidden;
flex-flow: row wrap; flex-flow: row wrap;
z-index: 2; z-index: 2;
transition: color 300ms;
} }
/* Empty Peers List */ /* Empty Peers List */
@ -199,6 +210,7 @@ x-no-peers {
x-no-peers h2, x-no-peers h2,
x-no-peers a { x-no-peers a {
color: var(--primary-color); color: var(--primary-color);
margin-bottom: 5px;
} }
x-peers:not(:empty)+x-no-peers { x-peers:not(:empty)+x-no-peers {
@ -249,6 +261,10 @@ x-peer x-icon {
will-change: transform; will-change: transform;
} }
x-peer:not(.type-ip) x-icon {
background: #00a69c;
}
x-peer:not([transfer]):hover x-icon, x-peer:not([transfer]):hover x-icon,
x-peer:not([transfer]):focus x-icon { x-peer:not([transfer]):focus x-icon {
transform: scale(1.05); transform: scale(1.05);
@ -266,6 +282,11 @@ x-peer[transfer] x-icon {
opacity: 0.7; opacity: 0.7;
} }
.device-name {
font-size: 14px;
white-space: nowrap;
}
x-peer[transfer] .status:before { x-peer[transfer] .status:before {
content: 'Transferring...'; content: 'Transferring...';
} }
@ -305,6 +326,7 @@ footer {
align-items: center; align-items: center;
padding: 0 0 16px 0; padding: 0 0 16px 0;
text-align: center; text-align: center;
transition: color 300ms;
} }
footer .logo { footer .logo {
@ -317,13 +339,6 @@ footer .font-body2 {
color: var(--primary-color); color: var(--primary-color);
} }
@media (min-height: 800px) {
footer {
margin-bottom: 16px;
}
}
/* Dialog */ /* Dialog */
x-dialog x-background { x-dialog x-background {
@ -359,7 +374,7 @@ x-dialog:not([show]) x-background {
} }
x-dialog .row-reverse>.button { x-dialog .row-reverse>.button {
margin-top: 16px; margin-top: 10px;
margin-left: 8px; margin-left: 8px;
} }
@ -367,12 +382,77 @@ x-dialog a {
color: var(--primary-color); 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 */ /* Receive Dialog */
#receiveDialog .row { #receiveDialog .row {
margin-top: 24px; margin-top: 24px;
margin-bottom: 8px; margin-bottom: 8px;
} }
#fileName{
word-break: break-all;
}
#fileSize{
padding-bottom: 5px;
}
/* Receive Text Dialog */ /* Receive Text Dialog */
#receiveTextDialog #text { #receiveTextDialog #text {
@ -420,6 +500,11 @@ x-dialog a {
color: var(--primary-color); color: var(--primary-color);
} }
.button[disabled] {
color: #5B5B66;
}
.button, .button,
.icon-button { .icon-button {
position: relative; position: relative;
@ -445,7 +530,7 @@ x-dialog a {
transition: opacity 300ms; transition: opacity 300ms;
} }
.button:hover:before, .button:not([disabled]):hover:before,
.icon-button:hover:before { .icon-button:hover:before {
opacity: 0.1; opacity: 0.1;
} }
@ -487,7 +572,7 @@ button::-moz-focus-inner {
outline: none; outline: none;
padding: 16px 24px; padding: 16px 24px;
border-radius: 16px; border-radius: 16px;
margin: 8px 0; margin: 10px 0;
font-size: 14px; font-size: 14px;
font-family: inherit; font-family: inherit;
background: #f1f3f4; background: #f1f3f4;
@ -521,7 +606,7 @@ button::-moz-focus-inner {
#about:not(:target) .fade-in { #about:not(:target) .fade-in {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition-delay: 0; transition-delay: 0s;
} }
#about .logo { #about .logo {
@ -561,7 +646,7 @@ button::-moz-focus-inner {
width: 80px; width: 80px;
height: 80px; height: 80px;
position: absolute; position: absolute;
top: 0px; top: 0;
clip: rect(0px, 80px, 80px, 40px); clip: rect(0px, 80px, 80px, 40px);
--progress: rotate(0deg); --progress: rotate(0deg);
transition: transform 200ms; transition: transform 200ms;
@ -756,3 +841,15 @@ x-dialog x-paper {
overflow: hidden; overflow: hidden;
} }
} }
/* webkit scrollbar style*/
::-webkit-scrollbar{
width: 4px;
height: 4px;
}
::-webkit-scrollbar-thumb{
background: #bfbfbf;
border-radius: 4px;
}

View file

@ -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=="
}
}
}