diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f193b45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Logs +# 忽略日志文件 +logs +*.log +npm-debug.log* # npm 调试日志文件 +yarn-debug.log* # yarn 调试日志文件 +yarn-error.log* # yarn 错误日志文件 +pnpm-debug.log* # pnpm 调试日志文件 +lerna-debug.log* # lerna 调试日志文件 + +# Node Modules +# 忽略 node_modules 文件夹,依赖可以通过 npm 或 yarn 重新安装 +node_modules + +# macOS 系统文件 +# 忽略 macOS 系统生成的 .DS_Store 文件 +.DS_Store + +# 构建输出 +# 忽略项目的构建输出文件夹 +dist # 通常的构建输出目录 +dist-ssr # 服务端渲染的构建输出目录 +coverage # 测试覆盖率报告目录 +*.local # 本地环境相关的临时文件 + +# Cypress 文件 +# 忽略 Cypress 生成的视频和截图文件 +/cypress/videos/ # 测试时录制的视频 +/cypress/screenshots/ # 测试失败时的截图 + +# 编辑器相关配置文件 +# 忽略常见编辑器和 IDE 的配置文件 +.vscode/* # VSCode 配置文件夹 +!.vscode/extensions.json # 保留 VSCode 的扩展插件推荐配置 +.idea # WebStorm/IntelliJ 的配置文件夹 +*.suo # Visual Studio 的解决方案用户选项文件 +*.ntvs* # Node.js Tools for Visual Studio 的配置文件 +*.njsproj # Node.js 项目文件(Visual Studio) +*.sln # Visual Studio 的解决方案文件 +*.sw? # Vim 的交换文件 + +# TypeScript +# 忽略 TypeScript 构建过程生成的缓存文件 +*.tsbuildinfo diff --git a/README.md b/README.md index 92ba9e0..01af1b9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ # drop.a-hxin.cn +一个局域网传输网站,原地址:github.com/SnapDrop +# Snapdrop 局域网分享网站搭建 -一个局域网传输网站,原地址:github.com/SnapDrop \ No newline at end of file +## 下载安装 + +github 链接:https://github.com/A-hxin/snapdrop + +### 后端部署 + +- 切换至目录 `/server/`使用 `npm i` 安装环境包; +- 使用 `node index.js` 运行后端项目 + +### 前端部署 + +- 将目录 `/client/`上传至服务器,并建立 PHP 服务; +- 下面反代理一定要设置否则无法连接后端 `api`; + +**重点,反代理** + +```css +location / { + root /www/wwwroot/drop.a-hxin.cn/client; + index index.html index.htm; + } + + location /server { + proxy_connect_timeout 300; + proxy_pass http://127.0.0.1:3000; + proxy_set_header Connection "upgrade"; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header X-Forwarded-for $remote_addr; + } +``` + +### 开放端口 + +默认后端端口是 `3000`,如要修改则在 `/server/index.js`中修改; diff --git a/client/images/android-chrome-192x192-maskable.png b/client/images/android-chrome-192x192-maskable.png new file mode 100755 index 0000000..f0e9245 Binary files /dev/null and b/client/images/android-chrome-192x192-maskable.png differ diff --git a/client/images/android-chrome-192x192.png b/client/images/android-chrome-192x192.png new file mode 100755 index 0000000..0bdca51 Binary files /dev/null and b/client/images/android-chrome-192x192.png differ diff --git a/client/images/android-chrome-512x512-maskable.png b/client/images/android-chrome-512x512-maskable.png new file mode 100755 index 0000000..cdda606 Binary files /dev/null and b/client/images/android-chrome-512x512-maskable.png differ diff --git a/client/images/android-chrome-512x512.png b/client/images/android-chrome-512x512.png new file mode 100755 index 0000000..b01e679 Binary files /dev/null and b/client/images/android-chrome-512x512.png differ diff --git a/client/images/apple-touch-icon.png b/client/images/apple-touch-icon.png new file mode 100755 index 0000000..0a32878 Binary files /dev/null and b/client/images/apple-touch-icon.png differ diff --git a/client/images/favicon-96x96.png b/client/images/favicon-96x96.png new file mode 100755 index 0000000..b8a5746 Binary files /dev/null and b/client/images/favicon-96x96.png differ diff --git a/client/images/logo_blue_512x512.png b/client/images/logo_blue_512x512.png new file mode 100755 index 0000000..41d13fc Binary files /dev/null and b/client/images/logo_blue_512x512.png differ diff --git a/client/images/logo_transparent_128x128.png b/client/images/logo_transparent_128x128.png new file mode 100755 index 0000000..c276efe Binary files /dev/null and b/client/images/logo_transparent_128x128.png differ diff --git a/client/images/logo_transparent_512x512.png b/client/images/logo_transparent_512x512.png new file mode 100755 index 0000000..367e24f Binary files /dev/null and b/client/images/logo_transparent_512x512.png differ diff --git a/client/images/logo_transparent_white_512x512.png b/client/images/logo_transparent_white_512x512.png new file mode 100755 index 0000000..37589b6 Binary files /dev/null and b/client/images/logo_transparent_white_512x512.png differ diff --git a/client/images/logo_white_512x512.png b/client/images/logo_white_512x512.png new file mode 100755 index 0000000..d7750d9 Binary files /dev/null and b/client/images/logo_white_512x512.png differ diff --git a/client/images/mstile-150x150.png b/client/images/mstile-150x150.png new file mode 100755 index 0000000..6380e32 Binary files /dev/null and b/client/images/mstile-150x150.png differ diff --git a/client/images/safari-pinned-tab.svg b/client/images/safari-pinned-tab.svg new file mode 100755 index 0000000..263ee4e --- /dev/null +++ b/client/images/safari-pinned-tab.svg @@ -0,0 +1,251 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/images/snapdrop-graphics.sketch b/client/images/snapdrop-graphics.sketch new file mode 100755 index 0000000..b8b756a Binary files /dev/null and b/client/images/snapdrop-graphics.sketch differ diff --git a/client/images/twitter-stream.jpg b/client/images/twitter-stream.jpg new file mode 100755 index 0000000..1da99f3 Binary files /dev/null and b/client/images/twitter-stream.jpg differ diff --git a/client/scripts/clipboard.js b/client/scripts/clipboard.js new file mode 100755 index 0000000..f6a69df --- /dev/null +++ b/client/scripts/clipboard.js @@ -0,0 +1,38 @@ +// Polyfill for Navigator.clipboard.writeText +if (!navigator.clipboard) { + navigator.clipboard = { + writeText: text => { + + // A contains the text to copy + const span = document.createElement('span'); + span.textContent = text; + span.style.whiteSpace = 'pre'; // Preserve consecutive spaces and newlines + + // Paint the span outside the viewport + span.style.position = 'absolute'; + span.style.left = '-9999px'; + span.style.top = '-9999px'; + + const win = window; + const selection = win.getSelection(); + win.document.body.appendChild(span); + + const range = win.document.createRange(); + selection.removeAllRanges(); + range.selectNode(span); + selection.addRange(range); + + let success = false; + try { + success = win.document.execCommand('copy'); + } catch (err) { + return Promise.error(); + } + + selection.removeAllRanges(); + span.remove(); + + return Promise.resolve(); + } + } +} \ No newline at end of file diff --git a/client/scripts/network.js b/client/scripts/network.js new file mode 100755 index 0000000..e1383f3 --- /dev/null +++ b/client/scripts/network.js @@ -0,0 +1,528 @@ +window.URL = window.URL || window.webkitURL; +window.isRtcSupported = !!(window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection); + +class ServerConnection { + + constructor() { + this._connect(); + Events.on('beforeunload', e => this._disconnect()); + Events.on('pagehide', e => this._disconnect()); + document.addEventListener('visibilitychange', e => this._onVisibilityChange()); + } + + _connect() { + clearTimeout(this._reconnectTimer); + if (this._isConnected() || this._isConnecting()) return; + const ws = new WebSocket(this._endpoint()); + ws.binaryType = 'arraybuffer'; + ws.onopen = e => console.log('WS: server connected'); + ws.onmessage = e => this._onMessage(e.data); + ws.onclose = e => this._onDisconnect(); + ws.onerror = e => console.error(e); + this._socket = ws; + } + + _onMessage(msg) { + msg = JSON.parse(msg); + console.log('WS:', msg); + switch (msg.type) { + case 'peers': + Events.fire('peers', msg.peers); + break; + case 'peer-joined': + Events.fire('peer-joined', msg.peer); + break; + case 'peer-left': + Events.fire('peer-left', msg.peerId); + break; + case 'signal': + Events.fire('signal', msg); + break; + case 'ping': + this.send({ type: 'pong' }); + break; + case 'display-name': + Events.fire('display-name', msg); + break; + default: + console.error('WS: unkown message type', msg); + } + } + + send(message) { + if (!this._isConnected()) return; + this._socket.send(JSON.stringify(message)); + } + + _endpoint() { + // hack to detect if deployment or development environment + const protocol = location.protocol.startsWith('https') ? 'wss' : 'ws'; + const webrtc = window.isRtcSupported ? '/webrtc' : '/fallback'; + const url = protocol + '://' + location.host + location.pathname + 'server' + webrtc; + return url; + } + + _disconnect() { + this.send({ type: 'disconnect' }); + this._socket.onclose = null; + this._socket.close(); + } + + _onDisconnect() { + console.log('WS: server disconnected'); + Events.fire('notify-user', 'Connection lost. Retry in 5 seconds...'); + clearTimeout(this._reconnectTimer); + this._reconnectTimer = setTimeout(_ => this._connect(), 5000); + } + + _onVisibilityChange() { + if (document.hidden) return; + this._connect(); + } + + _isConnected() { + return this._socket && this._socket.readyState === this._socket.OPEN; + } + + _isConnecting() { + return this._socket && this._socket.readyState === this._socket.CONNECTING; + } +} + +class Peer { + + constructor(serverConnection, peerId) { + this._server = serverConnection; + this._peerId = peerId; + this._filesQueue = []; + this._busy = false; + } + + sendJSON(message) { + this._send(JSON.stringify(message)); + } + + sendFiles(files) { + for (let i = 0; i < files.length; i++) { + this._filesQueue.push(files[i]); + } + if (this._busy) return; + this._dequeueFile(); + } + + _dequeueFile() { + if (!this._filesQueue.length) return; + this._busy = true; + const file = this._filesQueue.shift(); + this._sendFile(file); + } + + _sendFile(file) { + this.sendJSON({ + type: 'header', + name: file.name, + mime: file.type, + size: file.size + }); + this._chunker = new FileChunker(file, + chunk => this._send(chunk), + offset => this._onPartitionEnd(offset)); + this._chunker.nextPartition(); + } + + _onPartitionEnd(offset) { + this.sendJSON({ type: 'partition', offset: offset }); + } + + _onReceivedPartitionEnd(offset) { + this.sendJSON({ type: 'partition-received', offset: offset }); + } + + _sendNextPartition() { + if (!this._chunker || this._chunker.isFileEnd()) return; + this._chunker.nextPartition(); + } + + _sendProgress(progress) { + this.sendJSON({ type: 'progress', progress: progress }); + } + + _onMessage(message) { + if (typeof message !== 'string') { + this._onChunkReceived(message); + return; + } + message = JSON.parse(message); + console.log('RTC:', message); + switch (message.type) { + case 'header': + this._onFileHeader(message); + break; + case 'partition': + this._onReceivedPartitionEnd(message); + break; + case 'partition-received': + this._sendNextPartition(); + break; + case 'progress': + this._onDownloadProgress(message.progress); + break; + case 'transfer-complete': + this._onTransferCompleted(); + break; + case 'text': + this._onTextReceived(message); + break; + } + } + + _onFileHeader(header) { + this._lastProgress = 0; + this._digester = new FileDigester({ + name: header.name, + mime: header.mime, + size: header.size + }, file => this._onFileReceived(file)); + } + + _onChunkReceived(chunk) { + if(!chunk.byteLength) return; + + this._digester.unchunk(chunk); + const progress = this._digester.progress; + this._onDownloadProgress(progress); + + // occasionally notify sender about our progress + if (progress - this._lastProgress < 0.01) return; + this._lastProgress = progress; + this._sendProgress(progress); + } + + _onDownloadProgress(progress) { + Events.fire('file-progress', { sender: this._peerId, progress: progress }); + } + + _onFileReceived(proxyFile) { + Events.fire('file-received', proxyFile); + this.sendJSON({ type: 'transfer-complete' }); + } + + _onTransferCompleted() { + this._onDownloadProgress(1); + this._reader = null; + this._busy = false; + this._dequeueFile(); + Events.fire('notify-user', 'File transfer completed.'); + } + + sendText(text) { + const unescaped = btoa(unescape(encodeURIComponent(text))); + this.sendJSON({ type: 'text', text: unescaped }); + } + + _onTextReceived(message) { + const escaped = decodeURIComponent(escape(atob(message.text))); + Events.fire('text-received', { text: escaped, sender: this._peerId }); + } +} + +class RTCPeer extends Peer { + + constructor(serverConnection, peerId) { + super(serverConnection, peerId); + if (!peerId) return; // we will listen for a caller + this._connect(peerId, true); + } + + _connect(peerId, isCaller) { + if (!this._conn) this._openConnection(peerId, isCaller); + + if (isCaller) { + this._openChannel(); + } else { + this._conn.ondatachannel = e => this._onChannelOpened(e); + } + } + + _openConnection(peerId, isCaller) { + this._isCaller = isCaller; + 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.oniceconnectionstatechange = e => this._onIceConnectionStateChange(e); + } + + _openChannel() { + const channel = this._conn.createDataChannel('data-channel', { + ordered: true, + reliable: true // Obsolete. See https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/reliable + }); + channel.onopen = e => this._onChannelOpened(e); + this._conn.createOffer().then(d => this._onDescription(d)).catch(e => this._onError(e)); + } + + _onDescription(description) { + // description.sdp = description.sdp.replace('b=AS:30', 'b=AS:1638400'); + this._conn.setLocalDescription(description) + .then(_ => this._sendSignal({ sdp: description })) + .catch(e => this._onError(e)); + } + + _onIceCandidate(event) { + if (!event.candidate) return; + this._sendSignal({ ice: event.candidate }); + } + + onServerMessage(message) { + if (!this._conn) this._connect(message.sender, false); + + if (message.sdp) { + this._conn.setRemoteDescription(new RTCSessionDescription(message.sdp)) + .then( _ => { + if (message.sdp.type === 'offer') { + return this._conn.createAnswer() + .then(d => this._onDescription(d)); + } + }) + .catch(e => this._onError(e)); + } else if (message.ice) { + this._conn.addIceCandidate(new RTCIceCandidate(message.ice)); + } + } + + _onChannelOpened(event) { + console.log('RTC: channel opened with', this._peerId); + const channel = event.channel || event.target; + channel.binaryType = 'arraybuffer'; + channel.onmessage = e => this._onMessage(e.data); + channel.onclose = e => this._onChannelClosed(); + this._channel = channel; + } + + _onChannelClosed() { + console.log('RTC: channel closed', this._peerId); + if (!this.isCaller) return; + this._connect(this._peerId, true); // reopen the channel + } + + _onConnectionStateChange(e) { + console.log('RTC: state changed:', this._conn.connectionState); + switch (this._conn.connectionState) { + case 'disconnected': + this._onChannelClosed(); + break; + case 'failed': + this._conn = null; + this._onChannelClosed(); + break; + } + } + + _onIceConnectionStateChange() { + switch (this._conn.iceConnectionState) { + case 'failed': + console.error('ICE Gathering failed'); + break; + default: + console.log('ICE Gathering', this._conn.iceConnectionState); + } + } + + _onError(error) { + console.error(error); + } + + _send(message) { + if (!this._channel) return this.refresh(); + this._channel.send(message); + } + + _sendSignal(signal) { + signal.type = 'signal'; + signal.to = this._peerId; + this._server.send(signal); + } + + refresh() { + // check if channel is open. otherwise create one + if (this._isConnected() || this._isConnecting()) return; + this._connect(this._peerId, this._isCaller); + } + + _isConnected() { + return this._channel && this._channel.readyState === 'open'; + } + + _isConnecting() { + return this._channel && this._channel.readyState === 'connecting'; + } +} + +class PeersManager { + + constructor(serverConnection) { + this.peers = {}; + this._server = serverConnection; + Events.on('signal', e => this._onMessage(e.detail)); + Events.on('peers', e => this._onPeers(e.detail)); + Events.on('files-selected', e => this._onFilesSelected(e.detail)); + Events.on('send-text', e => this._onSendText(e.detail)); + Events.on('peer-left', e => this._onPeerLeft(e.detail)); + } + + _onMessage(message) { + if (!this.peers[message.sender]) { + this.peers[message.sender] = new RTCPeer(this._server); + } + this.peers[message.sender].onServerMessage(message); + } + + _onPeers(peers) { + peers.forEach(peer => { + if (this.peers[peer.id]) { + this.peers[peer.id].refresh(); + return; + } + if (window.isRtcSupported && peer.rtcSupported) { + this.peers[peer.id] = new RTCPeer(this._server, peer.id); + } else { + this.peers[peer.id] = new WSPeer(this._server, peer.id); + } + }) + } + + sendTo(peerId, message) { + this.peers[peerId].send(message); + } + + _onFilesSelected(message) { + this.peers[message.to].sendFiles(message.files); + } + + _onSendText(message) { + this.peers[message.to].sendText(message.text); + } + + _onPeerLeft(peerId) { + const peer = this.peers[peerId]; + delete this.peers[peerId]; + if (!peer || !peer._peer) return; + peer._peer.close(); + } + +} + +class WSPeer { + _send(message) { + message.to = this._peerId; + this._server.send(message); + } +} + +class FileChunker { + + constructor(file, onChunk, onPartitionEnd) { + this._chunkSize = 64000; // 64 KB + this._maxPartitionSize = 1e6; // 1 MB + this._offset = 0; + this._partitionSize = 0; + this._file = file; + this._onChunk = onChunk; + this._onPartitionEnd = onPartitionEnd; + this._reader = new FileReader(); + this._reader.addEventListener('load', e => this._onChunkRead(e.target.result)); + } + + nextPartition() { + this._partitionSize = 0; + this._readChunk(); + } + + _readChunk() { + const chunk = this._file.slice(this._offset, this._offset + this._chunkSize); + this._reader.readAsArrayBuffer(chunk); + } + + _onChunkRead(chunk) { + this._offset += chunk.byteLength; + this._partitionSize += chunk.byteLength; + this._onChunk(chunk); + if (this.isFileEnd()) return; + if (this._isPartitionEnd()) { + this._onPartitionEnd(this._offset); + return; + } + this._readChunk(); + } + + repeatPartition() { + this._offset -= this._partitionSize; + this._nextPartition(); + } + + _isPartitionEnd() { + return this._partitionSize >= this._maxPartitionSize; + } + + isFileEnd() { + return this._offset >= this._file.size; + } + + get progress() { + return this._offset / this._file.size; + } +} + +class FileDigester { + + constructor(meta, callback) { + this._buffer = []; + this._bytesReceived = 0; + this._size = meta.size; + this._mime = meta.mime || 'application/octet-stream'; + this._name = meta.name; + this._callback = callback; + } + + 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 + + if (this._bytesReceived < this._size) return; + // we are done + let blob = new Blob(this._buffer, { type: this._mime }); + this._callback({ + name: this._name, + mime: this._mime, + size: this._size, + blob: blob + }); + } + +} + +class Events { + static fire(type, detail) { + window.dispatchEvent(new CustomEvent(type, { detail: detail })); + } + + static on(type, callback) { + return window.addEventListener(type, callback, false); + } + + static off(type, callback) { + return window.removeEventListener(type, callback, false); + } +} + + +RTCPeer.config = { + 'sdpSemantics': 'unified-plan', + 'iceServers': [{ + urls: 'stun:stun.l.google.com:19302' + }] +} diff --git a/client/scripts/ui.js b/client/scripts/ui.js new file mode 100755 index 0000000..6675a3c --- /dev/null +++ b/client/scripts/ui.js @@ -0,0 +1,642 @@ +const $ = query => document.getElementById(query); +const $$ = query => document.body.querySelector(query); +const isURL = text => /^((https?:\/\/|www)[^\s]+)/g.test(text.toLowerCase()); +window.isDownloadSupported = (typeof document.createElement('a').download !== 'undefined'); +window.isProductionEnvironment = !window.location.host.startsWith('localhost'); +window.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + +// set display name +Events.on('display-name', e => { + const me = e.detail.message; + const $displayName = $('displayName') + $displayName.textContent = '你的设备名称为: ' + me.displayName; + $displayName.title = me.deviceName; +}); + +class PeersUI { + + constructor() { + Events.on('peer-joined', e => this._onPeerJoined(e.detail)); + Events.on('peer-left', e => this._onPeerLeft(e.detail)); + Events.on('peers', e => this._onPeers(e.detail)); + Events.on('file-progress', e => this._onFileProgress(e.detail)); + Events.on('paste', e => this._onPaste(e)); + } + + _onPeerJoined(peer) { + if ($(peer.id)) return; // peer already exists + const peerUI = new PeerUI(peer); + $$('x-peers').appendChild(peerUI.$el); + setTimeout(e => window.animateBackground(false), 1750); // Stop animation + } + + _onPeers(peers) { + this._clearPeers(); + peers.forEach(peer => this._onPeerJoined(peer)); + } + + _onPeerLeft(peerId) { + const $peer = $(peerId); + if (!$peer) return; + $peer.remove(); + } + + _onFileProgress(progress) { + const peerId = progress.sender || progress.recipient; + const $peer = $(peerId); + if (!$peer) return; + $peer.ui.setProgress(progress.progress); + } + + _clearPeers() { + const $peers = $$('x-peers').innerHTML = ''; + } + + _onPaste(e) { + const files = e.clipboardData.files || e.clipboardData.items + .filter(i => i.type.indexOf('image') > -1) + .map(i => i.getAsFile()); + const peers = document.querySelectorAll('x-peer'); + // send the pasted image content to the only peer if there is one + // otherwise, select the peer somehow by notifying the client that + // "image data has been pasted, click the client to which to send it" + // not implemented + if (files.length > 0 && peers.length === 1) { + Events.fire('files-selected', { + files: files, + to: $$('x-peer').id + }); + } + } +} + +class PeerUI { + + html() { + return ` + ` + } + + constructor(peer) { + this._peer = peer; + this._initDom(); + this._bindListeners(this.$el); + } + + _initDom() { + const el = document.createElement('x-peer'); + el.id = this._peer.id; + 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(); + this.$el = el; + this.$progress = el.querySelector('.progress'); + } + + _bindListeners(el) { + el.querySelector('input').addEventListener('change', e => this._onFilesSelected(e)); + el.addEventListener('drop', e => this._onDrop(e)); + el.addEventListener('dragend', e => this._onDragEnd(e)); + 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('touchend', e => this._onTouchEnd(e)); + // prevent browser's default file drop behavior + Events.on('dragover', e => e.preventDefault()); + Events.on('drop', e => e.preventDefault()); + } + + _displayName() { + return this._peer.name.displayName; + } + + _deviceName() { + return this._peer.name.deviceName; + } + + _icon() { + const device = this._peer.name.device || this._peer.name; + if (device.type === 'mobile') { + return '#phone-iphone'; + } + if (device.type === 'tablet') { + return '#tablet-mac'; + } + return '#desktop-mac'; + } + + _onFilesSelected(e) { + const $input = e.target; + const files = $input.files; + Events.fire('files-selected', { + files: files, + to: this._peer.id + }); + $input.value = null; // reset input + } + + setProgress(progress) { + if (progress > 0) { + this.$el.setAttribute('transfer', '1'); + } + if (progress > 0.5) { + this.$progress.classList.add('over50'); + } else { + this.$progress.classList.remove('over50'); + } + const degrees = `rotate(${360 * progress}deg)`; + this.$progress.style.setProperty('--progress', degrees); + if (progress >= 1) { + this.setProgress(0); + this.$el.removeAttribute('transfer'); + } + } + + _onDrop(e) { + e.preventDefault(); + const files = e.dataTransfer.files; + Events.fire('files-selected', { + files: files, + to: this._peer.id + }); + this._onDragEnd(); + } + + _onDragOver() { + this.$el.setAttribute('drop', 1); + } + + _onDragEnd() { + this.$el.removeAttribute('drop'); + } + + _onRightClick(e) { + e.preventDefault(); + Events.fire('text-recipient', this._peer.id); + } + + _onTouchStart(e) { + this._touchStart = Date.now(); + this._touchTimer = setTimeout(_ => this._onTouchEnd(), 610); + } + + _onTouchEnd(e) { + if (Date.now() - this._touchStart < 500) { + clearTimeout(this._touchTimer); + } else { // this was a long tap + if (e) e.preventDefault(); + Events.fire('text-recipient', this._peer.id); + } + } +} + + +class Dialog { + constructor(id) { + this.$el = $(id); + this.$el.querySelectorAll('[close]').forEach(el => el.addEventListener('click', e => this.hide())) + this.$autoFocus = this.$el.querySelector('[autofocus]'); + } + + show() { + this.$el.setAttribute('show', 1); + if (this.$autoFocus) this.$autoFocus.focus(); + } + + hide() { + this.$el.removeAttribute('show'); + document.activeElement.blur(); + window.blur(); + } +} + +class ReceiveDialog extends Dialog { + + constructor() { + super('receiveDialog'); + Events.on('file-received', e => { + this._nextFile(e.detail); + window.blop.play(); + }); + this._filesQueue = []; + } + + _nextFile(nextFile) { + if (nextFile) this._filesQueue.push(nextFile); + if (this._busy) return; + this._busy = true; + const file = this._filesQueue.shift(); + this._displayFile(file); + } + + _dequeueFile() { + if (!this._filesQueue.length) { // nothing to do + this._busy = false; + return; + } + // dequeue next file + setTimeout(_ => { + this._busy = false; + this._nextFile(); + }, 300); + } + + _displayFile(file) { + const $a = this.$el.querySelector('#download'); + const url = URL.createObjectURL(file.blob); + $a.href = url; + $a.download = file.name; + + if(this._autoDownload()){ + $a.click() + return + } + if(file.mime.split('/')[0] === 'image'){ + console.log('the file is image'); + this.$el.querySelector('.preview').style.visibility = 'inherit'; + this.$el.querySelector("#img-preview").src = url; + } + + this.$el.querySelector('#fileName').textContent = file.name; + this.$el.querySelector('#fileSize').textContent = this._formatFileSize(file.size); + this.show(); + + if (window.isDownloadSupported) return; + // fallback for iOS + $a.target = '_blank'; + const reader = new FileReader(); + reader.onload = e => $a.href = reader.result; + reader.readAsDataURL(file.blob); + } + + _formatFileSize(bytes) { + if (bytes >= 1e9) { + return (Math.round(bytes / 1e8) / 10) + ' GB'; + } else if (bytes >= 1e6) { + return (Math.round(bytes / 1e5) / 10) + ' MB'; + } else if (bytes > 1000) { + return Math.round(bytes / 1000) + ' KB'; + } else { + return bytes + ' Bytes'; + } + } + + hide() { + this.$el.querySelector('.preview').style.visibility = 'hidden'; + this.$el.querySelector("#img-preview").src = ""; + super.hide(); + this._dequeueFile(); + } + + + _autoDownload(){ + return !this.$el.querySelector('#autoDownload').checked + } +} + + +class SendTextDialog extends Dialog { + constructor() { + super('sendTextDialog'); + 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)); + } + + _onRecipient(recipient) { + this._recipient = recipient; + this._handleShareTargetText(); + this.show(); + + const range = document.createRange(); + const sel = window.getSelection(); + + range.selectNodeContents(this.$text); + sel.removeAllRanges(); + sel.addRange(range); + + } + + _handleShareTargetText() { + if (!window.shareTargetText) return; + this.$text.textContent = window.shareTargetText; + window.shareTargetText = ''; + } + + _send(e) { + e.preventDefault(); + Events.fire('send-text', { + to: this._recipient, + text: this.$text.innerText + }); + } +} + +class ReceiveTextDialog extends Dialog { + constructor() { + super('receiveTextDialog'); + Events.on('text-received', e => this._onText(e.detail)) + this.$text = this.$el.querySelector('#text'); + const $copy = this.$el.querySelector('#copy'); + copy.addEventListener('click', _ => this._onCopy()); + } + + _onText(e) { + this.$text.innerHTML = ''; + const text = e.text; + if (isURL(text)) { + const $a = document.createElement('a'); + $a.href = text; + $a.target = '_blank'; + $a.textContent = text; + this.$text.appendChild($a); + } else { + this.$text.textContent = text; + } + this.show(); + window.blop.play(); + } + + async _onCopy() { + await navigator.clipboard.writeText(this.$text.textContent); + Events.fire('notify-user', '复制到剪贴板!'); + } +} + +class Toast extends Dialog { + constructor() { + super('toast'); + Events.on('notify-user', e => this._onNotfiy(e.detail)); + } + + _onNotfiy(message) { + this.$el.textContent = message; + this.show(); + setTimeout(_ => this.hide(), 3000); + } +} + + +class Notifications { + + constructor() { + // Check if the browser supports notifications + if (!('Notification' in window)) return; + + // Check whether notification permissions have already been granted + if (Notification.permission !== 'granted') { + this.$button = $('notification'); + this.$button.removeAttribute('hidden'); + this.$button.addEventListener('click', e => this._requestPermission()); + } + Events.on('text-received', e => this._messageNotification(e.detail.text)); + Events.on('file-received', e => this._downloadNotification(e.detail.name)); + } + + _requestPermission() { + Notification.requestPermission(permission => { + if (permission !== 'granted') { + Events.fire('notify-user', Notifications.PERMISSION_ERROR || 'Error'); + return; + } + this._notify('更快速的分享!'); + this.$button.setAttribute('hidden', 1); + }); + } + + _notify(message, body) { + const config = { + body: body, + icon: '/images/logo_transparent_128x128.png', + } + let notification; + try { + notification = new Notification(message, config); + } catch (e) { + // Android doesn't support "new Notification" if service worker is installed + if (!serviceWorker || !serviceWorker.showNotification) return; + notification = serviceWorker.showNotification(message, config); + } + + // Notification is persistent on Android. We have to close it manually + const visibilitychangeHandler = () => { + if (document.visibilityState === 'visible') { + notification.close(); + Events.off('visibilitychange', visibilitychangeHandler); + } + }; + Events.on('visibilitychange', visibilitychangeHandler); + + return notification; + } + + _messageNotification(message) { + if (document.visibilityState !== 'visible') { + if (isURL(message)) { + const notification = this._notify(message, '点击打开链接'); + this._bind(notification, e => window.open(message, '_blank', null, true)); + } else { + const notification = this._notify(message, '点击复制文本'); + this._bind(notification, e => this._copyText(message, notification)); + } + } + } + + _downloadNotification(message) { + if (document.visibilityState !== 'visible') { + const notification = this._notify(message, '点击下载'); + if (!window.isDownloadSupported) return; + this._bind(notification, e => this._download(notification)); + } + } + + _download(notification) { + document.querySelector('x-dialog [download]').click(); + notification.close(); + } + + _copyText(message, notification) { + notification.close(); + if (!navigator.clipboard.writeText(message)) return; + this._notify('Copied text to clipboard'); + } + + _bind(notification, handler) { + if (notification.then) { + notification.then(e => serviceWorker.getNotifications().then(notifications => { + serviceWorker.addEventListener('notificationclick', handler); + })); + } else { + notification.onclick = handler; + } + } +} + + +class NetworkStatusUI { + + constructor() { + window.addEventListener('offline', e => this._showOfflineMessage(), false); + window.addEventListener('online', e => this._showOnlineMessage(), false); + if (!navigator.onLine) this._showOfflineMessage(); + } + + _showOfflineMessage() { + Events.fire('notify-user', '您掉线了'); + } + + _showOnlineMessage() { + Events.fire('notify-user', '您已重新连接'); + } +} + +class WebShareTargetUI { + constructor() { + const parsedUrl = new URL(window.location); + const title = parsedUrl.searchParams.get('title'); + const text = parsedUrl.searchParams.get('text'); + const url = parsedUrl.searchParams.get('url'); + + let shareTargetText = title ? title : ''; + shareTargetText += text ? shareTargetText ? ' ' + text : text : ''; + + if(url) shareTargetText = url; // We share only the Link - no text. Because link-only text becomes clickable. + + if (!shareTargetText) return; + window.shareTargetText = shareTargetText; + history.pushState({}, 'URL Rewrite', '/'); + console.log('Shared Target Text:', '"' + shareTargetText + '"'); + } +} + + +class Snapdrop { + constructor() { + const server = new ServerConnection(); + const peers = new PeersManager(server); + const peersUI = new PeersUI(); + Events.on('load', e => { + const receiveDialog = new ReceiveDialog(); + const sendTextDialog = new SendTextDialog(); + const receiveTextDialog = new ReceiveTextDialog(); + const toast = new Toast(); + const notifications = new Notifications(); + const networkStatusUI = new NetworkStatusUI(); + const webShareTargetUI = new WebShareTargetUI(); + }); + } +} + +const snapdrop = new Snapdrop(); + + + +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('service-worker.js') + .then(serviceWorker => { + console.log('服务人员已注册'); + window.serviceWorker = serviceWorker + }); +} + +window.addEventListener('beforeinstallprompt', e => { + if (window.matchMedia('(display-mode: standalone)').matches) { + // don't display install banner when installed + return e.preventDefault(); + } else { + const btn = document.querySelector('#install') + btn.hidden = false; + btn.onclick = _ => e.prompt(); + return e.preventDefault(); + } +}); + +// Background Animation +Events.on('load', () => { + let c = document.createElement('canvas'); + document.body.appendChild(c); + let style = c.style; + style.width = '100%'; + style.position = 'absolute'; + style.zIndex = -1; + style.top = 0; + style.left = 0; + let ctx = c.getContext('2d'); + let x0, y0, w, h, dw; + + function init() { + w = window.innerWidth; + h = window.innerHeight; + c.width = w; + c.height = h; + let offset = h > 380 ? 100 : 65; + offset = h > 800 ? 116 : offset; + x0 = w / 2; + y0 = h - offset; + dw = Math.max(w, h, 1000) / 13; + drawCircles(); + } + window.onresize = init; + + function drawCircle(radius) { + ctx.beginPath(); + let color = Math.round(197 * (1 - radius / Math.max(w, h))); + ctx.strokeStyle = 'rgba(' + color + ',' + color + ',' + color + ',0.1)'; + ctx.arc(x0, y0, radius, 0, 2 * Math.PI); + ctx.stroke(); + ctx.lineWidth = 2; + } + + let step = 0; + + function drawCircles() { + ctx.clearRect(0, 0, w, h); + for (let i = 0; i < 8; i++) { + drawCircle(dw * i + step % dw); + } + step += 1; + } + + let loading = true; + + function animate() { + if (loading || step % dw < dw - 5) { + requestAnimationFrame(function() { + drawCircles(); + animate(); + }); + } + } + window.animateBackground = function(l) { + loading = l; + animate(); + }; + init(); + animate(); +}); + +Notifications.PERMISSION_ERROR = ` +由于用户多次忽略权限提示,通知权限已被阻止。 +您可以在页面信息中重置此权限,点击 URL 旁边的锁形图标即可访问。`; + +document.body.onclick = e => { // safari hack to fix audio + document.body.onclick = null; + if (!(/.*Version.*Safari.*/.test(navigator.userAgent))) return; + blop.play(); +} diff --git a/client/sounds/blop.mp3 b/client/sounds/blop.mp3 new file mode 100755 index 0000000..28a6244 Binary files /dev/null and b/client/sounds/blop.mp3 differ diff --git a/client/sounds/blop.ogg b/client/sounds/blop.ogg new file mode 100755 index 0000000..d1ce0c2 Binary files /dev/null and b/client/sounds/blop.ogg differ diff --git a/server/index.js b/server/index.js new file mode 100755 index 0000000..3dc7ac1 --- /dev/null +++ b/server/index.js @@ -0,0 +1,298 @@ +var process = require('process') +// Handle SIGINT +process.on('SIGINT', () => { + console.info("SIGINT Received, exiting...") + process.exit(0) +}) + +// Handle SIGTERM +process.on('SIGTERM', () => { + console.info("SIGTERM Received, exiting...") + process.exit(0) +}) + +const parser = require('ua-parser-js'); +const { uniqueNamesGenerator, animals, colors } = require('unique-names-generator'); +// 自定义名称 +const jpFamilyNames = [ '藤原', '高桥', '佐藤', '泽村', '中村', '日向', '远藤', '山田', '神崎' ]; +const jpGivenNames = [ '千夏', '夏树', '结衣', '真由', '千早', '杏奈', '美咲', '明日香', '梓' ]; +const ageName = ['鸣人', '佐助', '樱', '艾伦', '三笠', '光彦', '阿良良木', '小松', '凉宫', '黑崎一护'] + +class SnapdropServer { + + constructor(port) { + const WebSocket = require('ws'); + this._wss = new WebSocket.Server({ port: port }); + this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request))); + this._wss.on('headers', (headers, response) => this._onHeaders(headers, response)); + + this._rooms = {}; + + console.log('Snapdrop is running on port', port); + } + + _onConnection(peer) { + this._joinRoom(peer); + peer.socket.on('message', message => this._onMessage(peer, message)); + peer.socket.on('error', console.error); + this._keepAlive(peer); + + // send displayName + this._send(peer, { + type: 'display-name', + message: { + displayName: peer.name.displayName, + deviceName: peer.name.deviceName + } + }); + } + + _onHeaders(headers, response) { + if (response.headers.cookie && response.headers.cookie.indexOf('peerid=') > -1) return; + response.peerId = Peer.uuid(); + headers.push('Set-Cookie: peerid=' + response.peerId + "; SameSite=Strict; Secure"); + } + + _onMessage(sender, message) { + // Try to parse message + try { + message = JSON.parse(message); + } catch (e) { + return; // TODO: handle malformed JSON + } + + switch (message.type) { + case 'disconnect': + this._leaveRoom(sender); + break; + case 'pong': + sender.lastBeat = Date.now(); + break; + } + + // relay message to recipient + if (message.to && this._rooms[sender.ip]) { + const recipientId = message.to; // TODO: sanitize + const recipient = this._rooms[sender.ip][recipientId]; + delete message.to; + // add sender id + message.sender = sender.id; + this._send(recipient, message); + return; + } + } + + _joinRoom(peer) { + // if room doesn't exist, create it + if (!this._rooms[peer.ip]) { + this._rooms[peer.ip] = {}; + } + + // notify all other peers + for (const otherPeerId in this._rooms[peer.ip]) { + const otherPeer = this._rooms[peer.ip][otherPeerId]; + this._send(otherPeer, { + type: 'peer-joined', + peer: peer.getInfo() + }); + } + + // notify peer about the other peers + const otherPeers = []; + for (const otherPeerId in this._rooms[peer.ip]) { + otherPeers.push(this._rooms[peer.ip][otherPeerId].getInfo()); + } + + this._send(peer, { + type: 'peers', + peers: otherPeers + }); + + // add peer to room + this._rooms[peer.ip][peer.id] = peer; + } + + _leaveRoom(peer) { + if (!this._rooms[peer.ip] || !this._rooms[peer.ip][peer.id]) return; + this._cancelKeepAlive(this._rooms[peer.ip][peer.id]); + + // delete the peer + delete this._rooms[peer.ip][peer.id]; + + 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 }); + } + } + } + + _send(peer, message) { + if (!peer) return; + if (this._wss.readyState !== this._wss.OPEN) return; + message = JSON.stringify(message); + peer.socket.send(message, error => ''); + } + + _keepAlive(peer) { + this._cancelKeepAlive(peer); + var timeout = 30000; + if (!peer.lastBeat) { + peer.lastBeat = Date.now(); + } + if (Date.now() - peer.lastBeat > 2 * timeout) { + this._leaveRoom(peer); + return; + } + + this._send(peer, { type: 'ping' }); + + peer.timerId = setTimeout(() => this._keepAlive(peer), timeout); + } + + _cancelKeepAlive(peer) { + if (peer && peer.timerId) { + clearTimeout(peer.timerId); + } + } +} + + + +class Peer { + + constructor(socket, request) { + // set socket + this.socket = socket; + + + // set remote ip + this._setIP(request); + + // set peer id + this._setPeerId(request) + // is WebRTC supported ? + this.rtcSupported = request.url.indexOf('webrtc') > -1; + // set name + this._setName(request); + // for keepalive + this.timerId = 0; + this.lastBeat = Date.now(); + } + + _setIP(request) { + if (request.headers['x-forwarded-for']) { + this.ip = request.headers['x-forwarded-for'].split(/\s*,\s*/)[0]; + } else { + this.ip = request.connection.remoteAddress; + } + // IPv4 and IPv6 use different values to refer to localhost + if (this.ip == '::1' || this.ip == '::ffff:127.0.0.1') { + this.ip = '127.0.0.1'; + } + } + + _setPeerId(request) { + if (request.peerId) { + this.id = request.peerId; + } else { + this.id = request.headers.cookie.replace('peerid=', ''); + } + } + + toString() { + return `` + } + + _setName(req) { + let ua = parser(req.headers['user-agent']); + + + let deviceName = ''; + + if (ua.os && ua.os.name) { + deviceName = ua.os.name.replace('Mac OS', 'Mac') + ' '; + } + + if (ua.device.model) { + deviceName += ua.device.model; + } else { + deviceName += ua.browser.name; + } + + if(!deviceName) + deviceName = 'Unknown Device'; + + const displayName = uniqueNamesGenerator({ + length: 1, // 几个词 + separator: '', // 拼接空格 + // dictionaries: [colors, animals], + // dictionaries: [jpFamilyNames, jpGivenNames], + dictionaries: [ageName], + style: 'capital', + seed: this.id.hashCode() + }) + + this.name = { + model: ua.device.model, + os: ua.os.name, + browser: ua.browser.name, + type: ua.device.type, + deviceName, + displayName + }; + } + + getInfo() { + return { + id: this.id, + name: this.name, + rtcSupported: this.rtcSupported + } + } + + // return uuid of form xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + static uuid() { + let uuid = '', + ii; + for (ii = 0; ii < 32; ii += 1) { + switch (ii) { + case 8: + case 20: + uuid += '-'; + uuid += (Math.random() * 16 | 0).toString(16); + break; + case 12: + uuid += '-'; + uuid += '4'; + break; + case 16: + uuid += '-'; + uuid += (Math.random() * 4 | 8).toString(16); + break; + default: + uuid += (Math.random() * 16 | 0).toString(16); + } + } + return uuid; + }; +} + +Object.defineProperty(String.prototype, 'hashCode', { + value: function() { + var hash = 0, i, chr; + for (i = 0; i < this.length; i++) { + chr = this.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; // Convert to 32bit integer + } + return hash; + } +}); + +const server = new SnapdropServer(process.env.PORT || 3000);