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

View file

@ -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'
},

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(){
// 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);
});
})();
})();

View file

@ -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 <i>${descriptor}</i> 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<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 {
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();