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/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..10b731c
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,5 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
diff --git a/.idea/drop.a-hxin.cn.iml b/.idea/drop.a-hxin.cn.iml
new file mode 100644
index 0000000..24643cc
--- /dev/null
+++ b/.idea/drop.a-hxin.cn.iml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..192ed91
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
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/404.html b/client/404.html
new file mode 100755
index 0000000..6096254
--- /dev/null
+++ b/client/404.html
@@ -0,0 +1,16 @@
+
+
+
+
+
404 Not Found
+
+404 Not Found
+
+
+
+
\ No newline at end of file
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/index.html b/client/index.html
new file mode 100755
index 0000000..f0adc37
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+
+ Snapdrop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 在其他设备上打开Snapdrop以发送文件
+
+
+
+
+
+
+
+
+ 文件已接收
+ 文件名
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 已收到消息
+
+
+
+
+
+
+
+
+
+
+ 文件传输完成!
+
+
+
+
+
+
+ Snapdrop
+ 在设备间传输文件的最简单方法
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/manifest.json b/client/manifest.json
new file mode 100755
index 0000000..9fbabfc
--- /dev/null
+++ b/client/manifest.json
@@ -0,0 +1,39 @@
+{
+ "name": "Snapdrop",
+ "short_name": "Snapdrop",
+ "icons": [{
+ "src": "images/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },{
+ "src": "images/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ },{
+ "src": "images/android-chrome-192x192-maskable.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },{
+ "src": "images/android-chrome-512x512-maskable.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ },{
+ "src": "images/favicon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ }],
+ "background_color": "#efefef",
+ "display": "minimal-ui",
+ "theme_color": "#3367d6",
+ "share_target": {
+ "method":"GET",
+ "action": "/?share_target",
+ "params": {
+ "title": "title",
+ "text": "text",
+ "url": "url"
+ }
+ }
+}
\ No newline at end of file
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/service-worker.js b/client/service-worker.js
new file mode 100755
index 0000000..f20e895
--- /dev/null
+++ b/client/service-worker.js
@@ -0,0 +1,56 @@
+var CACHE_NAME = 'snapdrop-cache-v2';
+var urlsToCache = [
+ 'index.html',
+ './',
+ 'styles.css',
+ 'scripts/network.js',
+ 'scripts/ui.js',
+ 'scripts/clipboard.js',
+ 'sounds/blop.mp3',
+ 'images/favicon-96x96.png'
+];
+
+self.addEventListener('install', function(event) {
+ // Perform install steps
+ event.waitUntil(
+ caches.open(CACHE_NAME)
+ .then(function(cache) {
+ console.log('Opened cache');
+ return cache.addAll(urlsToCache);
+ })
+ );
+});
+
+
+self.addEventListener('fetch', function(event) {
+ event.respondWith(
+ caches.match(event.request)
+ .then(function(response) {
+ // Cache hit - return response
+ if (response) {
+ return response;
+ }
+ return fetch(event.request);
+ }
+ )
+ );
+});
+
+
+self.addEventListener('activate', function(event) {
+ console.log('Updating Service Worker...')
+ event.waitUntil(
+ caches.keys().then(function(cacheNames) {
+ return Promise.all(
+ cacheNames.filter(function(cacheName) {
+ // Return true if you want to remove this cache,
+ // but remember that caches are shared across
+ // the whole origin
+ return true
+ }).map(function(cacheName) {
+ return caches.delete(cacheName);
+ })
+ );
+ })
+ );
+});
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/client/styles.css b/client/styles.css
new file mode 100755
index 0000000..0f90aae
--- /dev/null
+++ b/client/styles.css
@@ -0,0 +1,733 @@
+/* Constants */
+
+:root {
+ --icon-size: 24px;
+ --primary-color: #4285f4;
+ --peer-width: 120px;
+ --text-color: #333;
+ --bg-color: #fafafa;
+ --bg-color-secondary: #f1f3f4;
+}
+
+/* Layout */
+
+html {
+ height: 100%;
+}
+
+html,
+body {
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ overflow-x: hidden;
+}
+
+body {
+ flex-grow: 1;
+ align-items: center;
+ justify-content: center;
+ overflow-y: hidden;
+}
+
+.row-reverse {
+ display: flex;
+ flex-direction: row-reverse;
+}
+
+.row {
+ display: flex;
+ flex-direction: row;
+}
+
+.column {
+ display: flex;
+ flex-direction: column;
+}
+
+.center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.grow {
+ flex-grow: 1;
+}
+
+.full {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+header {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 56px;
+ align-items: center;
+ padding: 16px;
+ box-sizing: border-box;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+
+/* Typography */
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, Roboto, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+h1 {
+ font-size: 34px;
+ font-weight: 400;
+ letter-spacing: -.01em;
+ line-height: 40px;
+ margin: 8px 0 0;
+}
+
+h2 {
+ font-size: 24px;
+ font-weight: 400;
+ letter-spacing: -.012em;
+ line-height: 32px;
+}
+
+h3 {
+ font-size: 20px;
+ font-weight: 500;
+ margin: 16px 0;
+}
+
+.font-subheading {
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 24px;
+ word-break: break-all;
+}
+
+.font-body1,
+body {
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 20px;
+}
+
+.font-body2 {
+ font-size: 12px;
+ line-height: 18px;
+}
+
+a {
+ text-decoration: none;
+ color: currentColor;
+ cursor: pointer;
+}
+
+
+
+/* Icons */
+
+.icon {
+ width: var(--icon-size);
+ height: var(--icon-size);
+ fill: currentColor;
+}
+
+
+
+/* Shadows */
+
+[shadow="1"] {
+ box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.14),
+ 0 1px 8px 0 rgba(0, 0, 0, 0.12),
+ 0 3px 3px -2px rgba(0, 0, 0, 0.4);
+}
+
+[shadow="2"] {
+ box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14),
+ 0 1px 10px 0 rgba(0, 0, 0, 0.12),
+ 0 2px 4px -1px rgba(0, 0, 0, 0.4);
+}
+
+
+
+
+/* Animations */
+
+@keyframes fade-in {
+ 0% {
+ opacity: 0;
+ }
+}
+
+/* Main Header */
+
+body>header a {
+ margin-left: 8px;
+}
+
+/* Peers List */
+
+x-peers {
+ width: 100%;
+ overflow: hidden;
+ flex-flow: row wrap;
+ z-index: 2;
+}
+
+/* Empty Peers List */
+
+x-no-peers {
+ padding: 8px;
+ text-align: center;
+ /* prevent flickering on load */
+ animation: fade-in 300ms;
+ animation-delay: 500ms;
+ animation-fill-mode: backwards;
+}
+
+x-no-peers h2,
+x-no-peers a {
+ color: var(--primary-color);
+}
+
+x-peers:not(:empty)+x-no-peers {
+ display: none;
+}
+
+
+
+/* Peer */
+
+x-peer {
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+x-peer label {
+ width: var(--peer-width);
+ padding: 8px;
+ cursor: pointer;
+ touch-action: manipulation;
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+ position: relative;
+}
+
+x-peer .name {
+ width: var(--peer-width);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: center;
+}
+
+input[type="file"] {
+ visibility: hidden;
+ position: absolute;
+}
+
+x-peer x-icon {
+ --icon-size: 40px;
+ width: var(--icon-size);
+ padding: 12px;
+ border-radius: 50%;
+ background: var(--primary-color);
+ color: white;
+ display: flex;
+ margin-bottom: 8px;
+ transition: transform 150ms;
+ will-change: transform;
+}
+
+x-peer:not([transfer]):hover x-icon,
+x-peer:not([transfer]):focus x-icon {
+ transform: scale(1.05);
+}
+
+x-peer[transfer] x-icon {
+ box-shadow: none;
+ opacity: 0.8;
+ transform: scale(1);
+}
+
+.status,
+.device-name {
+ height: 18px;
+ opacity: 0.7;
+}
+
+x-peer[transfer] .status:before {
+ content: 'Transferring...';
+}
+
+x-peer:not([transfer]) .status,
+x-peer[transfer] .device-name {
+ display: none;
+}
+
+x-peer x-icon {
+ animation: pop 600ms ease-out 1;
+}
+
+@keyframes pop {
+ 0% {
+ transform: scale(0.7);
+ }
+
+ 40% {
+ transform: scale(1.2);
+ }
+}
+
+x-peer[drop] x-icon {
+ transform: scale(1.1);
+}
+
+
+
+/* Footer */
+
+footer {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ align-items: center;
+ padding: 0 0 16px 0;
+ text-align: center;
+}
+
+footer .logo {
+ --icon-size: 80px;
+ margin-bottom: 8px;
+ color: var(--primary-color);
+}
+
+footer .font-body2 {
+ color: var(--primary-color);
+}
+
+@media (min-height: 800px) {
+ footer {
+ margin-bottom: 16px;
+ }
+}
+
+
+/* Dialog */
+
+x-dialog x-background {
+ background: rgba(0, 0, 0, 0.61);
+ z-index: 10;
+ transition: opacity 300ms;
+ will-change: opacity;
+ padding: 16px;
+}
+
+x-dialog x-paper {
+ z-index: 3;
+ background: white;
+ border-radius: 8px;
+ padding: 16px 24px;
+ width: 100%;
+ max-width: 400px;
+ box-sizing: border-box;
+ transition: transform 300ms;
+ will-change: transform;
+}
+
+x-dialog:not([show]) {
+ pointer-events: none;
+}
+
+x-dialog:not([show]) x-paper {
+ transform: scale(0.1);
+}
+
+x-dialog:not([show]) x-background {
+ opacity: 0;
+}
+
+x-dialog .row-reverse>.button {
+ margin-top: 16px;
+ margin-left: 8px;
+}
+
+x-dialog a {
+ color: var(--primary-color);
+}
+
+/* Receive Dialog */
+#receiveDialog .row {
+ margin-top: 24px;
+ margin-bottom: 8px;
+}
+
+/* Receive Text Dialog */
+
+#receiveTextDialog #text {
+ width: 100%;
+ word-break: break-all;
+ max-height: 300px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ -webkit-user-select: all;
+ -moz-user-select: all;
+ user-select: all;
+ white-space: pre-wrap;
+}
+
+#receiveTextDialog #text a {
+ cursor: pointer;
+}
+
+#receiveTextDialog #text a:hover {
+ text-decoration: underline;
+}
+
+#receiveTextDialog h3 {
+ /* Select the received text when double-clicking the dialog */
+ user-select: none;
+ pointer-events: none;
+}
+
+/* Button */
+
+.button {
+ padding: 0 16px;
+ box-sizing: border-box;
+ min-height: 36px;
+ min-width: 100px;
+ font-size: 14px;
+ line-height: 24px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ cursor: pointer;
+ user-select: none;
+ background: inherit;
+ color: var(--primary-color);
+}
+
+.button,
+.icon-button {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+ touch-action: manipulation;
+ border: none;
+ outline: none;
+}
+
+.button:before,
+.icon-button:before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: currentColor;
+ opacity: 0;
+ transition: opacity 300ms;
+}
+
+.button:hover:before,
+.icon-button:hover:before {
+ opacity: 0.1;
+}
+
+.button:before {
+ border-radius: 8px;
+}
+
+.button:focus:before,
+.icon-button:focus:before {
+ opacity: 0.2;
+}
+
+
+
+button::-moz-focus-inner {
+ border: 0;
+}
+
+
+/* Icon Button */
+
+.icon-button {
+ width: 40px;
+ height: 40px;
+}
+
+.icon-button:before {
+ border-radius: 50%;
+}
+
+
+
+/* Text Input */
+
+.textarea {
+ box-sizing: border-box;
+ border: none;
+ outline: none;
+ padding: 16px 24px;
+ border-radius: 16px;
+ margin: 8px 0;
+ font-size: 14px;
+ font-family: inherit;
+ background: #f1f3f4;
+ display: block;
+ overflow: auto;
+ resize: none;
+ min-height: 40px;
+ line-height: 16px;
+ max-height: 300px;
+}
+
+
+/* Info Animation */
+
+#about {
+ color: white;
+ z-index: 11;
+ overflow: hidden;
+ pointer-events: none;
+ text-align: center;
+}
+
+#about .fade-in {
+ transition: opacity 300ms;
+ will-change: opacity;
+ transition-delay: 300ms;
+ z-index: 11;
+ pointer-events: all;
+}
+
+#about:not(:target) .fade-in {
+ opacity: 0;
+ pointer-events: none;
+ transition-delay: 0;
+}
+
+#about .logo {
+ --icon-size: 96px;
+}
+
+#about x-background {
+ position: absolute;
+ top: calc(32px - 200px);
+ right: calc(32px - 200px);
+ width: 400px;
+ height: 400px;
+ border-radius: 50%;
+ background: var(--primary-color);
+ transform: scale(0);
+ z-index: -1;
+}
+
+/* Hack such that initial scale(0) isn't animated */
+#about x-background {
+ will-change: transform;
+ transition: transform 800ms cubic-bezier(0.77, 0, 0.175, 1);
+}
+
+#about:target x-background {
+ transform: scale(12);
+}
+
+#about .row a {
+ margin: 8px 8px -16px;
+}
+
+
+/* Loading Indicator */
+
+.progress {
+ width: 80px;
+ height: 80px;
+ position: absolute;
+ top: 0px;
+ clip: rect(0px, 80px, 80px, 40px);
+ --progress: rotate(0deg);
+ transition: transform 200ms;
+}
+
+.circle {
+ width: 72px;
+ height: 72px;
+ border: 4px solid var(--primary-color);
+ border-radius: 40px;
+ position: absolute;
+ clip: rect(0px, 40px, 80px, 0px);
+ will-change: transform;
+ transform: var(--progress);
+}
+
+.over50 {
+ clip: rect(auto, auto, auto, auto);
+}
+
+.over50 .circle.right {
+ transform: rotate(180deg);
+}
+
+
+/* Generic placeholder */
+[placeholder]:empty:before {
+ content: attr(placeholder);
+}
+
+/* Toast */
+
+.toast-container {
+ padding: 0 8px 24px;
+ overflow: hidden;
+ pointer-events: none;
+}
+
+x-toast {
+ position: absolute;
+ min-height: 48px;
+ bottom: 24px;
+ width: 100%;
+ max-width: 344px;
+ background-color: #323232;
+ color: rgba(255, 255, 255, 0.95);
+ align-items: center;
+ box-sizing: border-box;
+ padding: 8px 24px;
+ z-index: 20;
+ transition: opacity 200ms, transform 300ms ease-out;
+ cursor: default;
+ line-height: 24px;
+ border-radius: 8px;
+ pointer-events: all;
+}
+
+x-toast:not([show]):not(:hover) {
+ opacity: 0;
+ transform: translateY(100px);
+}
+
+
+/* Instructions */
+
+x-instructions {
+ position: absolute;
+ top: 120px;
+ opacity: 0.5;
+ transition: opacity 300ms;
+ z-index: -1;
+ text-align: center;
+}
+
+x-instructions:before {
+ content: attr(mobile);
+}
+
+x-peers:empty~x-instructions {
+ opacity: 0;
+}
+
+
+/* Responsive Styles */
+
+@media (min-height: 800px) {
+ footer {
+ margin-bottom: 16px;
+ }
+}
+
+@media screen and (min-height: 800px),
+screen and (min-width: 1100px) {
+ x-instructions:before {
+ content: attr(desktop);
+ }
+}
+
+@media (max-height: 420px) {
+ x-instructions {
+ top: 24px;
+ }
+
+ footer .logo {
+ --icon-size: 40px;
+ }
+}
+
+/*
+ iOS specific styles
+*/
+@supports (-webkit-overflow-scrolling: touch) {
+
+
+ html {
+ position: fixed;
+ }
+
+ x-instructions:before {
+ content: attr(mobile);
+ }
+}
+
+/*
+ Color Themes
+*/
+
+/* Default colors */
+body {
+ --text-color: #333;
+ --bg-color: #fafafa;
+ --bg-color-secondary: #f1f3f4;
+}
+
+/* Colored Elements */
+body {
+ color: var(--text-color);
+ background-color: var(--bg-color);
+ transition: background-color 0.5s ease;
+}
+
+x-dialog x-paper {
+ background-color: var(--bg-color);
+}
+
+.textarea {
+ color: var(--text-color);
+ background-color: var(--bg-color-secondary);
+}
+/* Image Preview */
+#img-preview{
+ max-width: 100%;
+ max-height: 50vh;
+ margin: auto;
+ display: block;
+}
+
+/*
+ Edge specific styles
+*/
+@supports (-ms-ime-align: auto) {
+
+ html,
+ body {
+ overflow: hidden;
+ }
+}
+
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100755
index 0000000..1782926
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,25 @@
+version: "3"
+services:
+ node:
+ image: "node:lts-alpine"
+ user: "node"
+ working_dir: /home/node/app
+ volumes:
+ - ./server/:/home/node/app
+ command: ash -c "npm i && node index.js"
+ nginx:
+ build:
+ context: ./docker/
+ dockerfile: nginx-with-openssl.Dockerfile
+ image: "nginx-with-openssl"
+ volumes:
+ - ./client:/usr/share/nginx/html
+ - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
+ - ./docker/certs:/etc/ssl/certs
+ - ./docker/openssl:/mnt/openssl
+ ports:
+ - "8080:80"
+ - "443:443"
+ env_file: ./docker/fqdn.env
+ entrypoint: /mnt/openssl/create.sh
+ command: ["nginx", "-g", "daemon off;"]
\ No newline at end of file
diff --git a/docker/fqdn.env b/docker/fqdn.env
new file mode 100755
index 0000000..3302bc9
--- /dev/null
+++ b/docker/fqdn.env
@@ -0,0 +1 @@
+FQDN=localhost
\ No newline at end of file
diff --git a/docker/nginx-with-openssl.Dockerfile b/docker/nginx-with-openssl.Dockerfile
new file mode 100755
index 0000000..4752a53
--- /dev/null
+++ b/docker/nginx-with-openssl.Dockerfile
@@ -0,0 +1,3 @@
+FROM nginx:alpine
+
+RUN apk add --no-cache openssl
\ No newline at end of file
diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf
new file mode 100755
index 0000000..475d29a
--- /dev/null
+++ b/docker/nginx/default.conf
@@ -0,0 +1,75 @@
+server {
+ listen 80;
+ #server_name your.domain;
+
+ #charset koi8-r;
+ #access_log /var/log/nginx/host.access.log main;
+
+ expires epoch;
+
+ location / {
+ root /usr/share/nginx/html;
+ index index.html index.htm;
+ }
+
+ location /server {
+ proxy_connect_timeout 300;
+ proxy_pass http://node:3000;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header X-Forwarded-for $remote_addr;
+ }
+
+ location /ca.crt {
+ alias /etc/ssl/certs/snapdropCA.crt;
+ }
+
+ #error_page 404 /404.html;
+
+ # redirect server error pages to the static page /50x.html
+ #
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+}
+
+server {
+ listen 443 ssl http2;
+ ssl_certificate /etc/ssl/certs/snapdrop-dev.crt;
+ ssl_certificate_key /etc/ssl/certs/snapdrop-dev.key;
+
+ #server_name ;
+
+ #charset koi8-r;
+ #access_log /var/log/nginx/host.access.log main;
+
+ expires epoch;
+
+ location / {
+ root /usr/share/nginx/html;
+ index index.html index.htm;
+ }
+
+ location /server {
+ proxy_connect_timeout 300;
+ proxy_pass http://node:3000;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header X-Forwarded-for $remote_addr;
+ }
+
+ location /ca.crt {
+ alias /etc/ssl/certs/snapdropCA.crt;
+ }
+
+ #error_page 404 /404.html;
+
+ # redirect server error pages to the static page /50x.html
+ #
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+}
+
diff --git a/docker/openssl/create.sh b/docker/openssl/create.sh
new file mode 100755
index 0000000..7c081c3
--- /dev/null
+++ b/docker/openssl/create.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+
+cnf_dir='/mnt/openssl/'
+certs_dir='/etc/ssl/certs/'
+openssl req -config ${cnf_dir}snapdropCA.cnf -new -x509 -days 1 -keyout ${certs_dir}snapdropCA.key -out ${certs_dir}snapdropCA.crt
+openssl req -config ${cnf_dir}snapdropCert.cnf -new -out /tmp/snapdrop-dev.csr -keyout ${certs_dir}snapdrop-dev.key
+openssl x509 -req -in /tmp/snapdrop-dev.csr -CA ${certs_dir}snapdropCA.crt -CAkey ${certs_dir}snapdropCA.key -CAcreateserial -extensions req_ext -extfile ${cnf_dir}snapdropCert.cnf -sha512 -days 1 -out ${certs_dir}snapdrop-dev.crt
+
+exec "$@"
\ No newline at end of file
diff --git a/docker/openssl/snapdropCA.cnf b/docker/openssl/snapdropCA.cnf
new file mode 100755
index 0000000..d8502c3
--- /dev/null
+++ b/docker/openssl/snapdropCA.cnf
@@ -0,0 +1,26 @@
+[ req ]
+default_bits = 2048
+default_md = sha256
+default_days = 1
+encrypt_key = no
+distinguished_name = subject
+x509_extensions = x509_ext
+string_mask = utf8only
+prompt = no
+
+[ subject ]
+organizationName = Snapdrop
+OU = CA
+commonName = snapdrop-CA
+
+[ x509_ext ]
+subjectKeyIdentifier = hash
+authorityKeyIdentifier = keyid:always,issuer
+
+# You only need digitalSignature below. *If* you don't allow
+# RSA Key transport (i.e., you use ephemeral cipher suites), then
+# omit keyEncipherment because that's key transport.
+
+basicConstraints = critical, CA:TRUE, pathlen:0
+keyUsage = critical, digitalSignature, keyEncipherment, cRLSign, keyCertSign
+
diff --git a/docker/openssl/snapdropCert.cnf b/docker/openssl/snapdropCert.cnf
new file mode 100755
index 0000000..a98b70d
--- /dev/null
+++ b/docker/openssl/snapdropCert.cnf
@@ -0,0 +1,29 @@
+[ req ]
+default_bits = 2048
+default_md = sha256
+default_days = 1
+encrypt_key = no
+distinguished_name = subject
+req_extensions = req_ext
+string_mask = utf8only
+prompt = no
+
+[ subject ]
+organizationName = Snapdrop
+OU = Development
+
+# Use a friendly name here because it's presented to the user. The server's DNS
+# names are placed in Subject Alternate Names. Plus, DNS names here is deprecated
+# by both IETF and CA/Browser Forums. If you place a DNS name here, then you
+# must include the DNS name in the SAN too (otherwise, Chrome and others that
+# strictly follow the CA/Browser Baseline Requirements will fail).
+
+commonName = ${ENV::FQDN}
+
+[ req_ext ]
+subjectKeyIdentifier = hash
+basicConstraints = CA:FALSE
+keyUsage = digitalSignature, keyEncipherment
+subjectAltName = DNS:${ENV::FQDN}
+nsComment = "OpenSSL Generated Certificate"
+extendedKeyUsage = serverAuth
\ No newline at end of file
diff --git a/docs/faq.md b/docs/faq.md
new file mode 100755
index 0000000..4a5934e
--- /dev/null
+++ b/docs/faq.md
@@ -0,0 +1,76 @@
+# Frequently Asked Questions
+
+### Instructions / Discussions
+* [Video Instructions](https://www.youtube.com/watch?v=4XN02GkcHUM) (Big thanks to [TheiTeckHq](https://www.youtube.com/channel/UC_DUzWMb8gZZnAbISQjmAfQ))
+* [idownloadblog](http://www.idownloadblog.com/2015/12/29/snapdrop/)
+* [thenextweb](http://thenextweb.com/insider/2015/12/27/snapdrop-is-a-handy-web-based-replacement-for-apples-fiddly-airdrop-file-transfer-tool/)
+* [winboard](http://www.winboard.org/artikel-ratgeber/6253-dateien-vom-desktop-pc-mit-anderen-plattformen-teilen-mit-snapdrop.html)
+* [免費資源網路社群](https://free.com.tw/snapdrop/)
+* [Hackernews](https://news.ycombinator.com/front?day=2020-12-24)
+* [Reddit](https://www.reddit.com/r/Android/comments/et4qny/snapdrop_is_a_free_open_source_cross_platform/)
+* [Producthunt](https://www.producthunt.com/posts/snapdrop)
+
+### Help! I can't install the PWA!
+if you are using a Chromium-based browser (Chrome, Edge, Brave, etc.), you can easily install Snapdrop PWA on your desktop by clicking the install button in the top-right corner while on [snapdrop.net](https://snapdrop.net) (see below).
+
+
+### What about the connection? Is it a P2P-connection directly from device to device or is there any third-party-server?
+It uses a P2P connection if WebRTC is supported by the browser. WebRTC needs a Signaling Server, but it is only used to establish a connection and is not involved in the file transfer.
+
+### What about privacy? Will files be saved on third-party-servers?
+None of your files are ever sent to any server. Files are sent only between peers. Snapdrop doesn't even use a database. If you are curious have a look [at the Server](https://github.com/RobinLinus/snapdrop/blob/master/server/). Even if Snapdrop was able to view the files being transfered, WebRTC encrypts the files on transit, so the server would be unable to read them.
+
+### What about security? Are my files encrypted while being sent between the computers?
+Yes. Your files are sent using WebRTC, which encrypts them on transit.
+
+### Why don't you implement feature xyz?
+Snapdrop is a study in radical simplicity. The user interface is insanely simple. Features are chosen very carefully because complexity grows quadratically since every feature potentially interferes with each other feature. We focus very narrowly on a single use case: instant file transfer.
+We are not trying to optimize for some edge-cases. We are optimizing the user flow of the average users. Don't be sad if we decline your feature request for the sake of simplicity.
+
+If you want to learn more about simplicity you can read [Insanely Simple: The Obsession that Drives Apple's Success](https://www.amazon.com/Insanely-Simple-Ken-Segall-audiobook/dp/B007Z9686O) or [Thinking, Fast and Slow](https://www.amazon.com/Thinking-Fast-Slow-Daniel-Kahneman/dp/0374533555).
+
+
+### Snapdrop is awesome! How can I support it?
+* [Donate via PayPal to help cover the server costs](https://www.paypal.com/donate/?hosted_button_id=FTP9DXUR7LA7Q)
+* [File bugs, give feedback, submit suggestions](https://github.com/RobinLinus/snapdrop/issues)
+* Share Snapdrop on your social media.
+* Fix bugs and make a pull request.
+* Do security analysis and suggestions
+
+
+## "Inofficial" Instances
+Here's a list of other people hosting inofficial instances of Snapdrop:
+- https://pairdrop.net/
+- https://snapdrop.k26.ch/
+- https://snapdrop.9pfs.repl.co/
+- https://filedrop.codext.de/
+- https://s.hoothin.com/
+- https://www.wulingate.com/
+- https://snapdrop.fairysoft.net/
+- https://airtransferer.web.app/
+- https://drop.wuyuan.dev
+- https://share.jck.cx
+
+DISCLAIMER: WE ARE NOT IN ANY WAY AFFILIATED WITH THE PEOPLE WHO RUN THESE INSTANCES. WE DO NOT KNOW THEM. WE CANNOT VERIFY THE CODE THEY ARE RUNNING!
+
+
+## Third-Party Apps
+Here's a list of some third-party Snapdrop apps:
+
+1. [Snapdrop Desktop App](https://github.com/alextwothousand/snapdrop-desktop) built on top of Electron. (Thanks to [alextwothousand!](https://github.com/alextwothousand/)).
+
+1. [Snapdrop Android App](https://github.com/fm-sys/snapdrop-android) allows you to also send files directly from other apps via the share action.
+
+1. [Snapdrop Flutter App](https://github.com/congnguyendinh0/snapdrop_flutter)
+
+1. [Snapdrop iOS App](https://github.com/CDsigma/Snapdrop-iOS-App)
+
+1. [Snapdrop Node App (with completely Node server)](https://github.com/Bellisario/node-snapdrop)
+
+1. [SnapDrop VSCode Extension](https://github.com/Yash-Garg/snapdrop-vsc)
+
+1. Feel free to make one :)
+
+
+
+[< Back](/README.md)
diff --git a/docs/local-dev.md b/docs/local-dev.md
new file mode 100755
index 0000000..39f19d9
--- /dev/null
+++ b/docs/local-dev.md
@@ -0,0 +1,61 @@
+# Local Development
+## Install
+
+First, [Install docker with docker-compose.](https://docs.docker.com/compose/install/)
+
+Then, clone the repository:
+```
+ git clone https://github.com/RobinLinus/snapdrop.git
+ cd snapdrop
+ docker-compose up -d
+```
+Now point your browser to `http://localhost:8080`.
+
+- To restart the containers run `docker-compose restart`.
+- To stop the containers run `docker-compose stop`.
+- To debug the NodeJS server run `docker logs snapdrop_node_1`.
+
+
+## Run locally by pulling image from Docker Hub
+
+Have docker installed, then use the command:
+```
+ docker pull linuxserver/snapdrop
+```
+
+To run the image, type (if port 8080 is occupied by host use another random port :80):
+```
+ docker run -d -p 8080:80 linuxserver/snapdrop
+```
+
+
+
+
+
+
+## Testing PWA related features
+PWAs require that the app is served under a correctly set up and trusted TLS endpoint.
+
+The nginx container creates a CA certificate and a website certificate for you. To correctly set the common name of the certificate, you need to change the FQDN environment variable in `docker/fqdn.env` to the fully qualified domain name of your workstation.
+
+If you want to test PWA features, you need to trust the CA of the certificate for your local deployment. For your convenience, you can download the crt file from `http://:8080/ca.crt`. Install that certificate to the trust store of your operating system.
+- On Windows, make sure to install it to the `Trusted Root Certification Authorities` store.
+- On MacOS, double click the installed CA certificate in `Keychain Access`, expand `Trust`, and select `Always Trust` for SSL.
+- Firefox uses its own trust store. To install the CA, point Firefox at `http://:8080/ca.crt`. When prompted, select `Trust this CA to identify websites` and click OK.
+- When using Chrome, you need to restart Chrome so it reloads the trust store (`chrome://restart`). Additionally, after installing a new cert, you need to clear the Storage (DevTools -> Application -> Clear storage -> Clear site data).
+
+Please note that the certificates (CA and webserver cert) expire after a day.
+Also, whenever you restart the nginx docker, container new certificates are created.
+
+The site is served on `https://:443`.
+
+## Deployment Notes
+The client expects the server at http(s)://your.domain/server.
+
+When serving the node server behind a proxy, the `X-Forwarded-For` header has to be set by the proxy. Otherwise, all clients that are served by the proxy will be mutually visible.
+
+By default, the server listens on port 3000.
+
+For an nginx configuration example, see `docker/nginx/default.conf`.
+
+[< Back](/README.md)
diff --git a/docs/pwa-install.png b/docs/pwa-install.png
new file mode 100755
index 0000000..f53d8a8
Binary files /dev/null and b/docs/pwa-install.png 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);
diff --git a/server/package.json b/server/package.json
new file mode 100755
index 0000000..94ec33e
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "snapdrop",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "ua-parser-js": "^0.7.24",
+ "unique-names-generator": "^4.3.0",
+ "ws": "^7.4.6"
+ }
+}
diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml
new file mode 100644
index 0000000..1b0e995
--- /dev/null
+++ b/server/pnpm-lock.yaml
@@ -0,0 +1,41 @@
+lockfileVersion: '6.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+dependencies:
+ ua-parser-js:
+ specifier: ^0.7.24
+ version: 0.7.40
+ unique-names-generator:
+ specifier: ^4.3.0
+ version: 4.7.1
+ ws:
+ specifier: ^7.4.6
+ version: 7.5.10
+
+packages:
+
+ /ua-parser-js@0.7.40:
+ resolution: {integrity: sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ==}
+ hasBin: true
+ dev: false
+
+ /unique-names-generator@4.7.1:
+ resolution: {integrity: sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==}
+ engines: {node: '>=8'}
+ dev: false
+
+ /ws@7.5.10:
+ resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
+ engines: {node: '>=8.3.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ^5.0.2
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ dev: false