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 @@
+
+
+
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);