原版提交
44
.gitignore
vendored
Normal file
@ -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
|
||||||
5
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
12
.idea/drop.a-hxin.cn.iml
generated
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/drop.a-hxin.cn.iml" filepath="$PROJECT_DIR$/.idea/drop.a-hxin.cn.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
37
README.md
@ -1,3 +1,38 @@
|
|||||||
# drop.a-hxin.cn
|
# drop.a-hxin.cn
|
||||||
|
|
||||||
一个局域网传输网站,原地址:github.com/SnapDrop
|
一个局域网传输网站,原地址:github.com/SnapDrop
|
||||||
|
# Snapdrop 局域网分享网站搭建
|
||||||
|
|
||||||
|
## 下载安装
|
||||||
|
|
||||||
|
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`中修改;
|
||||||
|
|||||||
16
client/404.html
Executable file
@ -0,0 +1,16 @@
|
|||||||
|
<html>
|
||||||
|
<style>
|
||||||
|
.btlink {
|
||||||
|
color: #20a53a;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<html>
|
||||||
|
<head><title>404 Not Found</title></head>
|
||||||
|
<body>
|
||||||
|
<center><h1>404 Not Found</h1></center>
|
||||||
|
<hr>
|
||||||
|
<div style="text-align: center;font-size: 15px" >Power by <a class="btlink" href="https://www.bt.cn/?from=404" target="_blank">堡塔 (免费,高效和安全的托管控制面板)</a></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
client/images/android-chrome-192x192-maskable.png
Executable file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
client/images/android-chrome-192x192.png
Executable file
|
After Width: | Height: | Size: 16 KiB |
BIN
client/images/android-chrome-512x512-maskable.png
Executable file
|
After Width: | Height: | Size: 30 KiB |
BIN
client/images/android-chrome-512x512.png
Executable file
|
After Width: | Height: | Size: 52 KiB |
BIN
client/images/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 13 KiB |
BIN
client/images/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
client/images/logo_blue_512x512.png
Executable file
|
After Width: | Height: | Size: 33 KiB |
BIN
client/images/logo_transparent_128x128.png
Executable file
|
After Width: | Height: | Size: 10 KiB |
BIN
client/images/logo_transparent_512x512.png
Executable file
|
After Width: | Height: | Size: 52 KiB |
BIN
client/images/logo_transparent_white_512x512.png
Executable file
|
After Width: | Height: | Size: 25 KiB |
BIN
client/images/logo_white_512x512.png
Executable file
|
After Width: | Height: | Size: 31 KiB |
BIN
client/images/mstile-150x150.png
Executable file
|
After Width: | Height: | Size: 3.5 KiB |
251
client/images/safari-pinned-tab.svg
Executable file
@ -0,0 +1,251 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
<metadata>
|
||||||
|
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||||
|
</metadata>
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M2325 4214 c-225 -34 -366 -76 -544 -161 -361 -172 -651 -455 -830
|
||||||
|
-809 -135 -268 -194 -532 -186 -839 2 -88 6 -173 9 -190 3 -16 8 -43 11 -60 3
|
||||||
|
-16 7 -41 10 -55 3 -14 7 -36 10 -50 9 -51 54 -188 86 -265 94 -229 217 -413
|
||||||
|
389 -585 111 -111 139 -136 212 -186 29 -20 60 -42 68 -49 8 -7 34 -22 57 -35
|
||||||
|
36 -20 43 -21 55 -9 7 8 14 20 15 26 2 7 20 38 40 70 21 32 38 63 38 68 0 6
|
||||||
|
10 9 22 9 12 -1 21 4 20 10 -1 6 3 10 8 9 6 -1 12 7 12 18 2 18 -1 17 -19 -6
|
||||||
|
-29 -38 -32 -25 -5 24 13 22 26 38 30 35 3 -3 4 2 2 13 -2 10 -4 20 -4 23 -1
|
||||||
|
3 -21 16 -46 29 -28 15 -44 29 -41 37 3 8 0 11 -10 7 -18 -7 -46 16 -37 31 3
|
||||||
|
6 3 8 -2 4 -5 -5 -24 4 -44 19 -55 40 -180 166 -181 181 0 7 -4 10 -10 7 -5
|
||||||
|
-3 -10 1 -10 9 0 9 -4 16 -9 16 -4 0 -27 26 -49 58 -29 42 -38 62 -30 70 7 7
|
||||||
|
5 10 -7 9 -10 -1 -20 4 -23 12 -3 9 0 11 9 6 8 -5 11 -4 6 1 -5 5 -14 9 -20 9
|
||||||
|
-7 0 -13 9 -15 19 -2 11 -10 26 -18 34 -8 7 -12 18 -9 23 4 5 2 9 -2 9 -5 0
|
||||||
|
-14 9 -21 21 -10 15 -10 24 -2 34 8 10 8 15 -1 21 -8 4 -9 3 -5 -4 4 -7 3 -12
|
||||||
|
-2 -12 -9 0 -39 69 -47 108 -2 11 -8 29 -13 39 -5 10 -8 28 -7 40 1 12 -2 20
|
||||||
|
-7 17 -4 -3 -9 4 -9 15 -2 23 -3 29 -12 51 -11 29 -29 161 -23 175 3 8 2 15
|
||||||
|
-2 15 -4 0 -7 24 -8 54 -1 48 1 54 17 51 10 -2 21 0 24 4 2 5 -5 8 -18 7 -19
|
||||||
|
-1 -22 4 -22 34 0 27 4 35 17 34 10 -1 15 3 12 8 -3 5 -12 7 -20 4 -17 -7 -18
|
||||||
|
6 -1 23 6 8 7 11 2 8 -7 -3 -9 9 -7 34 3 25 8 37 16 32 8 -4 8 -3 0 6 -8 9 -9
|
||||||
|
25 -2 54 5 23 12 51 14 62 2 11 6 30 9 43 3 12 7 32 10 43 11 53 28 95 43 107
|
||||||
|
10 7 11 12 4 12 -9 0 -9 5 -3 18 5 9 22 45 38 80 19 42 32 61 42 57 11 -4 11
|
||||||
|
-2 2 9 -7 8 -8 16 -4 18 4 1 18 23 31 48 24 47 50 85 92 130 14 15 30 36 36
|
||||||
|
46 6 11 16 19 22 19 6 0 14 4 19 9 5 5 3 6 -4 2 -20 -11 -15 1 11 29 14 15 29
|
||||||
|
24 34 21 6 -3 9 2 8 12 0 10 6 16 16 16 10 -1 15 3 12 8 -7 10 36 45 49 40 5
|
||||||
|
-1 6 2 3 7 -8 12 21 34 34 26 6 -4 9 -2 8 3 -4 12 52 62 70 62 6 0 12 4 12 8
|
||||||
|
0 4 15 16 33 27 17 10 34 21 37 25 12 16 101 51 111 44 8 -5 9 -3 4 6 -6 10
|
||||||
|
-4 12 9 7 10 -4 15 -3 11 3 -6 10 56 40 88 43 9 1 17 5 17 10 0 4 4 6 8 3 4
|
||||||
|
-2 14 -1 22 4 9 5 19 5 27 -2 11 -8 12 -7 7 7 -5 12 -4 16 4 11 6 -3 13 -2 16
|
||||||
|
3 4 5 14 8 24 7 9 -2 19 0 22 5 3 4 21 10 40 12 19 3 35 6 35 7 0 3 42 10 87
|
||||||
|
14 26 2 51 7 56 10 6 3 13 0 15 -6 4 -10 6 -10 6 0 1 16 151 17 151 1 0 -6 6
|
||||||
|
-4 14 3 17 17 89 12 79 -6 -4 -6 1 -5 10 3 9 7 17 10 17 5 0 -5 19 -9 43 -10
|
||||||
|
43 -1 91 -7 137 -19 14 -4 32 -8 40 -9 8 -1 22 -5 30 -8 8 -3 51 -17 95 -32
|
||||||
|
43 -15 82 -30 85 -34 3 -4 12 -8 20 -10 16 -3 121 -53 130 -62 3 -3 17 -11 32
|
||||||
|
-19 15 -8 36 -22 47 -32 10 -10 21 -15 25 -12 3 3 8 -2 12 -11 3 -9 11 -16 17
|
||||||
|
-16 33 0 237 -202 320 -317 180 -253 268 -536 262 -847 -1 -83 -5 -162 -9
|
||||||
|
-176 -3 -13 -8 -41 -11 -61 -3 -20 -10 -50 -15 -68 -5 -17 -9 -35 -9 -39 -1
|
||||||
|
-4 -13 -43 -27 -87 -35 -107 -110 -257 -180 -357 -78 -112 -258 -290 -366
|
||||||
|
-362 -49 -32 -88 -62 -88 -67 0 -4 10 -25 21 -46 33 -60 142 -247 146 -253 3
|
||||||
|
-2 18 3 34 12 16 10 37 15 47 12 14 -5 15 -4 2 5 -8 6 -12 11 -7 12 4 1 10 2
|
||||||
|
15 3 4 0 18 11 31 23 13 12 27 20 31 18 5 -3 11 2 14 11 4 9 13 14 22 10 8 -3
|
||||||
|
12 -2 9 3 -7 12 84 83 96 75 5 -3 6 2 3 10 -4 11 0 16 11 16 9 0 13 5 10 10
|
||||||
|
-6 10 9 15 30 10 5 -1 7 2 3 6 -11 10 12 35 25 27 5 -3 7 -2 4 4 -4 5 5 20 18
|
||||||
|
31 14 12 25 27 25 34 0 6 3 9 6 6 6 -7 33 18 51 48 6 10 16 18 23 16 6 -1 9 2
|
||||||
|
5 7 -3 6 2 18 12 28 10 10 30 38 46 61 15 23 32 42 37 42 6 0 9 6 8 13 -2 6 3
|
||||||
|
11 11 9 8 -2 11 3 8 11 -7 19 21 59 35 51 6 -4 8 1 3 15 -4 15 -2 21 9 21 9 0
|
||||||
|
13 6 10 14 -3 8 0 17 5 21 6 3 9 11 6 16 -4 5 -2 9 3 9 4 0 15 16 22 35 9 24
|
||||||
|
19 34 28 30 12 -4 13 -3 2 10 -9 11 -9 15 -1 15 7 0 10 4 7 9 -3 5 3 28 13 52
|
||||||
|
17 40 22 54 32 84 1 6 10 17 19 25 9 8 10 11 3 6 -10 -6 -10 1 1 32 8 23 20
|
||||||
|
68 27 101 6 34 15 59 18 56 4 -2 5 10 3 27 -3 17 -1 28 4 25 5 -3 9 11 10 31
|
||||||
|
1 97 5 133 12 129 4 -3 7 6 7 19 0 13 -3 24 -6 24 -4 0 -3 17 0 38 4 20 6 61
|
||||||
|
4 90 -1 29 2 50 7 47 5 -3 12 0 16 6 4 8 3 9 -4 5 -16 -10 -34 28 -20 42 9 9
|
||||||
|
8 12 -2 12 -13 0 -13 2 0 10 12 8 12 10 1 10 -11 0 -11 3 0 17 8 9 9 14 3 10
|
||||||
|
-12 -7 -29 97 -19 114 4 5 2 9 -2 9 -5 0 -10 14 -11 31 -1 17 -7 39 -12 49 -6
|
||||||
|
10 -5 21 1 28 5 7 6 10 2 7 -8 -6 -65 168 -67 202 -1 11 -5 19 -10 15 -5 -3
|
||||||
|
-7 2 -3 11 4 10 2 17 -4 17 -6 0 -8 7 -5 16 3 8 2 12 -4 9 -9 -6 -14 8 -11 28
|
||||||
|
0 4 -4 7 -10 7 -5 0 -8 4 -5 9 4 5 1 11 -4 13 -6 2 -12 10 -14 18 -1 8 -8 25
|
||||||
|
-14 38 -7 12 -9 22 -5 22 4 0 -1 6 -12 13 -11 9 -15 19 -10 27 5 9 4 11 -3 6
|
||||||
|
-7 -4 -12 -2 -12 4 0 6 -11 27 -25 47 -13 20 -21 41 -18 47 3 6 2 8 -2 3 -11
|
||||||
|
-9 -46 43 -38 57 3 6 3 8 -2 4 -4 -4 -24 14 -44 40 -45 59 -49 64 -89 107 -19
|
||||||
|
20 -30 40 -26 47 4 6 3 8 -4 5 -11 -8 -112 85 -112 103 0 6 -4 9 -9 5 -5 -3
|
||||||
|
-17 5 -25 17 -9 12 -16 18 -16 13 0 -6 -3 -6 -8 0 -11 16 -111 89 -182 134
|
||||||
|
-36 22 -69 44 -75 48 -5 5 -45 24 -87 44 -43 20 -74 40 -70 44 4 5 2 5 -4 2
|
||||||
|
-11 -6 -54 7 -109 33 -23 11 -128 43 -195 58 -84 20 -104 24 -230 41 -91 13
|
||||||
|
-339 10 -435 -5z"/>
|
||||||
|
<path d="M2470 3856 c0 -2 8 -10 18 -17 15 -13 16 -12 3 4 -13 16 -21 21 -21
|
||||||
|
13z"/>
|
||||||
|
<path d="M2333 3825 c0 -8 4 -12 9 -9 5 3 6 10 3 15 -9 13 -12 11 -12 -6z"/>
|
||||||
|
<path d="M2365 3820 c-3 -6 1 -7 9 -4 18 7 21 14 7 14 -6 0 -13 -4 -16 -10z"/>
|
||||||
|
<path d="M1993 3715 c0 -8 4 -12 9 -9 5 3 6 10 3 15 -9 13 -12 11 -12 -6z"/>
|
||||||
|
<path d="M1936 3658 c3 -5 10 -6 15 -3 13 9 11 12 -6 12 -8 0 -12 -4 -9 -9z"/>
|
||||||
|
<path d="M1710 3555 c5 -7 6 -16 2 -21 -4 -5 -3 -5 2 -1 15 12 15 34 0 34 -9
|
||||||
|
0 -11 -4 -4 -12z"/>
|
||||||
|
<path d="M1650 3515 c5 -7 6 -16 2 -21 -4 -5 -3 -5 2 -1 15 12 15 34 0 34 -9
|
||||||
|
0 -11 -4 -4 -12z"/>
|
||||||
|
<path d="M2425 3508 c-152 -17 -331 -84 -468 -175 -88 -57 -238 -206 -292
|
||||||
|
-288 -86 -132 -146 -280 -170 -423 -16 -99 -14 -299 5 -377 6 -27 14 -61 16
|
||||||
|
-74 10 -48 65 -178 108 -254 48 -84 140 -202 181 -233 14 -10 25 -25 25 -32 0
|
||||||
|
-7 4 -11 8 -8 4 2 23 -9 42 -26 31 -27 94 -72 127 -91 7 -5 24 13 44 47 18 30
|
||||||
|
36 53 40 50 4 -2 6 4 5 14 0 9 4 16 11 15 7 -2 10 3 7 11 -3 7 2 21 11 30 9 9
|
||||||
|
13 16 9 16 -3 0 -2 6 3 13 22 28 75 130 65 123 -13 -8 -100 48 -109 70 -3 8
|
||||||
|
-9 14 -14 14 -17 0 -99 95 -94 108 3 8 0 10 -7 6 -8 -5 -9 -2 -5 10 4 10 3 15
|
||||||
|
-3 11 -12 -7 -44 41 -35 55 4 6 1 9 -5 8 -12 -3 -51 87 -42 96 3 4 1 6 -5 6
|
||||||
|
-18 0 -37 121 -36 225 2 84 7 140 13 140 2 0 5 16 14 59 3 16 13 38 23 49 10
|
||||||
|
11 13 17 6 14 -8 -5 -8 -1 0 18 8 16 17 23 27 19 11 -4 12 -2 3 7 -9 9 -6 20
|
||||||
|
10 49 34 58 79 110 90 103 6 -3 8 -2 4 3 -3 5 1 18 9 29 8 10 14 16 14 12 0
|
||||||
|
-4 13 7 29 24 17 17 35 28 41 24 6 -3 9 -1 8 7 -2 7 5 12 14 12 10 -1 16 3 15
|
||||||
|
9 -2 11 124 77 136 70 4 -2 7 1 7 7 0 7 6 10 14 7 8 -3 16 -2 18 3 2 4 19 11
|
||||||
|
38 15 19 3 51 9 70 12 46 9 186 9 230 0 19 -4 50 -10 67 -13 19 -4 30 -11 26
|
||||||
|
-17 -3 -6 -1 -7 6 -3 16 10 53 -3 45 -16 -4 -7 -2 -8 5 -4 16 11 83 -22 75
|
||||||
|
-36 -4 -6 -3 -8 4 -5 14 9 43 -3 36 -14 -3 -5 1 -7 8 -4 7 3 27 -8 44 -23 17
|
||||||
|
-15 47 -41 66 -58 53 -47 114 -133 152 -215 96 -207 83 -452 -34 -649 -43 -74
|
||||||
|
-145 -181 -213 -227 -53 -35 -60 -12 57 -210 l78 -132 24 15 c13 9 24 13 24 9
|
||||||
|
0 -5 8 2 19 13 10 12 21 19 25 15 3 -3 6 -1 6 6 0 8 6 11 16 7 8 -3 12 -2 9 4
|
||||||
|
-3 6 -1 10 6 10 7 0 9 3 6 7 -4 3 9 17 28 30 19 13 35 29 35 35 0 6 10 3 22
|
||||||
|
-8 16 -14 19 -14 10 -2 -11 14 -9 21 18 50 18 18 28 26 24 18 -4 -9 -3 -12 2
|
||||||
|
-7 5 5 9 16 9 25 0 9 11 22 25 29 14 7 19 13 12 13 -10 0 -9 4 2 16 9 8 21 24
|
||||||
|
27 35 9 17 13 18 27 7 15 -12 16 -11 4 4 -12 15 -11 22 7 47 12 16 21 32 21
|
||||||
|
36 0 4 9 20 21 36 11 16 17 29 13 29 -4 0 1 7 12 16 10 8 12 12 4 8 -12 -6
|
||||||
|
-13 -5 -4 7 6 8 17 35 25 62 7 26 16 47 20 47 4 0 6 6 6 13 -3 34 28 165 37
|
||||||
|
160 8 -5 8 -3 0 8 -9 13 -10 25 -4 47 6 21 4 172 -3 172 -4 0 -3 10 4 21 6 12
|
||||||
|
7 19 1 15 -5 -3 -12 15 -16 42 -3 26 -9 61 -12 77 -4 17 -7 36 -7 43 0 6 -4
|
||||||
|
12 -9 12 -5 0 -7 4 -3 9 3 5 1 12 -4 15 -5 4 -12 24 -16 46 -4 22 -10 40 -14
|
||||||
|
40 -5 0 -15 18 -23 39 -15 36 -14 39 1 35 10 -4 9 -1 -4 7 -29 18 -45 42 -39
|
||||||
|
59 3 8 2 11 -2 7 -4 -4 -16 5 -25 20 -14 21 -15 29 -6 36 8 6 6 7 -6 3 -11 -4
|
||||||
|
-20 1 -24 12 -4 9 -13 22 -21 28 -8 6 -12 16 -8 23 4 6 4 10 -1 9 -5 -2 -38
|
||||||
|
25 -73 59 -36 34 -89 80 -117 101 -29 21 -49 43 -45 47 4 5 2 5 -5 2 -6 -4
|
||||||
|
-19 0 -27 9 -9 8 -16 13 -16 10 0 -4 -17 4 -37 17 -162 99 -432 151 -658 125z"/>
|
||||||
|
<path d="M1435 3301 c-3 -5 -2 -12 3 -15 5 -3 9 1 9 9 0 17 -3 19 -12 6z"/>
|
||||||
|
<path d="M1461 3276 c-9 -11 -9 -16 1 -22 7 -4 10 -4 6 1 -4 4 -3 14 3 22 6 7
|
||||||
|
9 13 6 13 -2 0 -10 -6 -16 -14z"/>
|
||||||
|
<path d="M1389 3217 c6 -8 7 -18 3 -22 -4 -5 -1 -5 6 -1 10 6 10 11 1 22 -6 8
|
||||||
|
-14 14 -16 14 -3 0 0 -6 6 -13z"/>
|
||||||
|
<path d="M1350 3200 c0 -5 5 -10 11 -10 5 0 7 5 4 10 -3 6 -8 10 -11 10 -2 0
|
||||||
|
-4 -4 -4 -10z"/>
|
||||||
|
<path d="M2520 3140 c-9 -6 -10 -10 -3 -10 6 0 15 5 18 10 8 12 4 12 -15 0z"/>
|
||||||
|
<path d="M1339 3113 c-13 -16 -12 -17 4 -4 16 13 21 21 13 21 -2 0 -10 -8 -17
|
||||||
|
-17z"/>
|
||||||
|
<path d="M2595 3120 c-3 -5 -1 -10 4 -10 6 0 11 5 11 10 0 6 -2 10 -4 10 -3 0
|
||||||
|
-8 -4 -11 -10z"/>
|
||||||
|
<path d="M1336 3075 c-9 -26 -7 -32 5 -12 6 10 9 21 6 23 -2 3 -7 -2 -11 -11z"/>
|
||||||
|
<path d="M2330 3079 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||||
|
-5 -10 -11z"/>
|
||||||
|
<path d="M2239 3068 c-5 -18 -6 -38 -1 -34 7 8 12 36 6 36 -2 0 -4 -1 -5 -2z"/>
|
||||||
|
<path d="M1274 3049 c-3 -6 -2 -15 3 -20 5 -5 9 -1 9 11 0 23 -2 24 -12 9z"/>
|
||||||
|
<path d="M2185 3020 c-3 -6 1 -7 9 -4 18 7 21 14 7 14 -6 0 -13 -4 -16 -10z"/>
|
||||||
|
<path d="M2255 3019 c-3 -4 2 -6 10 -5 21 3 28 13 10 13 -9 0 -18 -4 -20 -8z"/>
|
||||||
|
<path d="M2153 2995 c0 -8 4 -12 9 -9 5 3 6 10 3 15 -9 13 -12 11 -12 -6z"/>
|
||||||
|
<path d="M2116 2982 c-3 -5 1 -9 9 -9 8 0 12 4 9 9 -3 4 -7 8 -9 8 -2 0 -6 -4
|
||||||
|
-9 -8z"/>
|
||||||
|
<path d="M2086 2962 c-3 -5 1 -9 9 -9 8 0 12 4 9 9 -3 4 -7 8 -9 8 -2 0 -6 -4
|
||||||
|
-9 -8z"/>
|
||||||
|
<path d="M1216 2922 c-3 -5 1 -9 9 -9 8 0 12 4 9 9 -3 4 -7 8 -9 8 -2 0 -6 -4
|
||||||
|
-9 -8z"/>
|
||||||
|
<path d="M2070 2919 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||||
|
-5 -10 -11z"/>
|
||||||
|
<path d="M1210 2896 c0 -2 8 -10 18 -17 15 -13 16 -12 3 4 -13 16 -21 21 -21
|
||||||
|
13z"/>
|
||||||
|
<path d="M1195 2860 c-3 -5 -1 -10 4 -10 6 0 11 5 11 10 0 6 -2 10 -4 10 -3 0
|
||||||
|
-8 -4 -11 -10z"/>
|
||||||
|
<path d="M1256 2858 c3 -5 10 -6 15 -3 13 9 11 12 -6 12 -8 0 -12 -4 -9 -9z"/>
|
||||||
|
<path d="M1993 2835 c0 -8 4 -12 9 -9 4 3 8 9 8 15 0 5 -4 9 -8 9 -5 0 -9 -7
|
||||||
|
-9 -15z"/>
|
||||||
|
<path d="M2030 2839 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||||
|
-5 -10 -11z"/>
|
||||||
|
<path d="M1190 2825 c0 -7 30 -13 34 -7 3 4 -4 9 -15 9 -10 1 -19 0 -19 -2z"/>
|
||||||
|
<path d="M1193 2803 c4 -3 1 -13 -6 -22 -11 -14 -10 -14 5 -2 16 12 16 31 1
|
||||||
|
31 -4 0 -3 -3 0 -7z"/>
|
||||||
|
<path d="M2493 2795 c-122 -27 -209 -94 -260 -202 -64 -138 -24 -318 92 -415
|
||||||
|
39 -33 101 -68 120 -68 8 0 15 -4 15 -8 0 -13 143 -14 196 -1 27 7 68 25 91
|
||||||
|
41 23 15 47 28 53 28 6 0 9 3 7 8 -3 4 1 13 9 20 8 7 14 9 14 5 1 -4 10 9 21
|
||||||
|
30 11 20 24 38 29 38 6 1 14 2 19 3 5 0 13 4 17 8 3 4 -3 6 -15 3 -23 -4 -29
|
||||||
|
10 -7 18 7 3 14 16 14 29 2 41 9 63 20 63 6 0 14 4 19 8 4 5 1 7 -7 5 -12 -2
|
||||||
|
-15 7 -15 45 0 26 -4 47 -9 47 -5 0 -2 8 5 17 10 11 10 14 2 9 -8 -4 -13 -2
|
||||||
|
-13 8 0 8 -5 27 -11 43 -6 15 -11 30 -12 33 -2 7 -24 34 -64 80 -40 45 -132
|
||||||
|
96 -193 105 -25 3 -52 8 -60 10 -8 2 -43 -3 -77 -10z"/>
|
||||||
|
<path d="M1953 2775 c0 -8 4 -12 9 -9 5 3 6 10 3 15 -9 13 -12 11 -12 -6z"/>
|
||||||
|
<path d="M1987 2779 c7 -7 15 -10 18 -7 3 3 -2 9 -12 12 -14 6 -15 5 -6 -5z"/>
|
||||||
|
<path d="M4375 2761 c-3 -5 -2 -12 3 -15 5 -3 9 1 9 9 0 17 -3 19 -12 6z"/>
|
||||||
|
<path d="M3630 2739 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||||
|
-5 -10 -11z"/>
|
||||||
|
<path d="M1929 2723 c-13 -16 -12 -17 4 -4 16 13 21 21 13 21 -2 0 -10 -8 -17
|
||||||
|
-17z"/>
|
||||||
|
<path d="M1987 2719 c7 -7 15 -10 18 -7 3 3 -2 9 -12 12 -14 6 -15 5 -6 -5z"/>
|
||||||
|
<path d="M1949 2683 c-13 -16 -12 -17 4 -4 16 13 21 21 13 21 -2 0 -10 -8 -17
|
||||||
|
-17z"/>
|
||||||
|
<path d="M1910 2681 c0 -6 4 -12 8 -15 5 -3 9 1 9 9 0 8 -4 15 -9 15 -4 0 -8
|
||||||
|
-4 -8 -9z"/>
|
||||||
|
<path d="M1155 2661 c-3 -5 -2 -12 3 -15 5 -3 9 1 9 9 0 17 -3 19 -12 6z"/>
|
||||||
|
<path d="M1894 2640 c0 -13 4 -16 10 -10 7 7 7 13 0 20 -6 6 -10 3 -10 -10z"/>
|
||||||
|
<path d="M3667 2639 c7 -7 15 -10 18 -7 3 3 -2 9 -12 12 -14 6 -15 5 -6 -5z"/>
|
||||||
|
<path d="M1879 2593 c-13 -16 -12 -17 4 -4 16 13 21 21 13 21 -2 0 -10 -8 -17
|
||||||
|
-17z"/>
|
||||||
|
<path d="M4416 2542 c-3 -5 1 -9 9 -9 8 0 12 4 9 9 -3 4 -7 8 -9 8 -2 0 -6 -4
|
||||||
|
-9 -8z"/>
|
||||||
|
<path d="M1894 2476 c1 -8 5 -18 8 -22 4 -3 5 1 4 10 -1 8 -5 18 -8 22 -4 3
|
||||||
|
-5 -1 -4 -10z"/>
|
||||||
|
<path d="M2936 2447 c3 -10 9 -15 12 -12 3 3 0 11 -7 18 -10 9 -11 8 -5 -6z"/>
|
||||||
|
<path d="M4415 2441 c-3 -5 -2 -12 3 -15 5 -3 9 1 9 9 0 17 -3 19 -12 6z"/>
|
||||||
|
<path d="M1890 2421 c0 -6 4 -13 10 -16 6 -3 7 1 4 9 -7 18 -14 21 -14 7z"/>
|
||||||
|
<path d="M1186 2358 c3 -5 10 -6 15 -3 13 9 11 12 -6 12 -8 0 -12 -4 -9 -9z"/>
|
||||||
|
<path d="M1865 2360 c-3 -6 1 -7 9 -4 18 7 21 14 7 14 -6 0 -13 -4 -16 -10z"/>
|
||||||
|
<path d="M2926 2258 c3 -5 10 -6 15 -3 13 9 11 12 -6 12 -8 0 -12 -4 -9 -9z"/>
|
||||||
|
<path d="M1153 2235 c0 -8 4 -12 9 -9 5 3 6 10 3 15 -9 13 -12 11 -12 -6z"/>
|
||||||
|
<path d="M3633 2215 c0 -8 4 -12 9 -9 5 3 6 10 3 15 -9 13 -12 11 -12 -6z"/>
|
||||||
|
<path d="M2873 2175 c0 -8 4 -12 9 -9 5 3 6 10 3 15 -9 13 -12 11 -12 -6z"/>
|
||||||
|
<path d="M1186 2158 c3 -5 10 -6 15 -3 13 9 11 12 -6 12 -8 0 -12 -4 -9 -9z"/>
|
||||||
|
<path d="M3653 2149 c-2 -23 3 -25 10 -4 4 8 3 16 -1 19 -4 3 -9 -4 -9 -15z"/>
|
||||||
|
<path d="M4385 2120 c-3 -6 1 -7 9 -4 18 7 21 14 7 14 -6 0 -13 -4 -16 -10z"/>
|
||||||
|
<path d="M2770 2099 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||||
|
-5 -10 -11z"/>
|
||||||
|
<path d="M1230 1939 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||||
|
-5 -10 -11z"/>
|
||||||
|
<path d="M3519 1833 c-13 -16 -12 -17 4 -4 16 13 21 21 13 21 -2 0 -10 -8 -17
|
||||||
|
-17z"/>
|
||||||
|
<path d="M4260 1846 c0 -2 8 -10 18 -17 15 -13 16 -12 3 4 -13 16 -21 21 -21
|
||||||
|
13z"/>
|
||||||
|
<path d="M4251 1804 c0 -11 3 -14 6 -6 3 7 2 16 -1 19 -3 4 -6 -2 -5 -13z"/>
|
||||||
|
<path d="M2210 1779 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||||
|
-5 -10 -11z"/>
|
||||||
|
<path d="M3467 1779 c7 -7 15 -10 18 -7 3 3 -2 9 -12 12 -14 6 -15 5 -6 -5z"/>
|
||||||
|
<path d="M3430 1739 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||||
|
-5 -10 -11z"/>
|
||||||
|
<path d="M4230 1746 c0 -2 7 -7 16 -10 8 -3 12 -2 9 4 -6 10 -25 14 -25 6z"/>
|
||||||
|
<path d="M3396 1713 c-6 -14 -5 -15 5 -6 7 7 10 15 7 18 -3 3 -9 -2 -12 -12z"/>
|
||||||
|
<path d="M2136 1679 c4 -8 30 -6 38 2 3 3 -5 5 -19 5 -13 0 -22 -3 -19 -7z"/>
|
||||||
|
<path d="M4255 1680 c-3 -5 -1 -10 4 -10 6 0 11 5 11 10 0 6 -2 10 -4 10 -3 0
|
||||||
|
-8 -4 -11 -10z"/>
|
||||||
|
<path d="M3354 1662 c4 -3 14 -7 22 -8 9 -1 13 0 10 4 -4 3 -14 7 -22 8 -9 1
|
||||||
|
-13 0 -10 -4z"/>
|
||||||
|
<path d="M3296 1638 c3 -5 10 -6 15 -3 13 9 11 12 -6 12 -8 0 -12 -4 -9 -9z"/>
|
||||||
|
<path d="M4236 1638 c3 -5 10 -6 15 -3 13 9 11 12 -6 12 -8 0 -12 -4 -9 -9z"/>
|
||||||
|
<path d="M3294 1595 c0 -13 3 -22 7 -19 8 4 6 30 -2 38 -3 3 -5 -5 -5 -19z"/>
|
||||||
|
<path d="M1435 1600 c-3 -5 -1 -10 4 -10 6 0 11 5 11 10 0 6 -2 10 -4 10 -3 0
|
||||||
|
-8 -4 -11 -10z"/>
|
||||||
|
<path d="M2076 1601 c-3 -5 2 -15 12 -22 15 -12 16 -12 5 2 -7 9 -10 19 -6 22
|
||||||
|
3 4 4 7 0 7 -3 0 -8 -4 -11 -9z"/>
|
||||||
|
<path d="M3253 1595 c0 -8 4 -12 9 -9 5 3 6 10 3 15 -9 13 -12 11 -12 -6z"/>
|
||||||
|
<path d="M2115 1580 c3 -5 8 -10 11 -10 2 0 4 5 4 10 0 6 -5 10 -11 10 -5 0
|
||||||
|
-7 -4 -4 -10z"/>
|
||||||
|
<path d="M3199 1553 c-13 -16 -12 -17 4 -4 9 7 17 15 17 17 0 8 -8 3 -21 -13z"/>
|
||||||
|
<path d="M3240 1520 c-9 -6 -10 -10 -3 -10 6 0 15 5 18 10 8 12 4 12 -15 0z"/>
|
||||||
|
<path d="M4105 1479 c-3 -4 2 -6 10 -5 21 3 28 13 10 13 -9 0 -18 -4 -20 -8z"/>
|
||||||
|
<path d="M4033 1355 c0 -8 4 -12 9 -9 5 3 6 10 3 15 -9 13 -12 11 -12 -6z"/>
|
||||||
|
<path d="M4006 1298 c3 -5 10 -6 15 -3 13 9 11 12 -6 12 -8 0 -12 -4 -9 -9z"/>
|
||||||
|
<path d="M3940 1285 c0 -2 6 -8 13 -14 10 -8 14 -7 14 2 0 8 -6 14 -14 14 -7
|
||||||
|
0 -13 -1 -13 -2z"/>
|
||||||
|
<path d="M3827 1139 c7 -9 10 -19 6 -22 -3 -4 -1 -7 5 -7 17 0 15 16 -5 31
|
||||||
|
-16 12 -17 12 -6 -2z"/>
|
||||||
|
<path d="M1810 1059 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||||
|
-5 -10 -11z"/>
|
||||||
|
<path d="M3719 1053 c-13 -16 -12 -17 4 -4 16 13 21 21 13 21 -2 0 -10 -8 -17
|
||||||
|
-17z"/>
|
||||||
|
<path d="M1774 1049 c-3 -6 -2 -15 3 -20 5 -5 9 -1 9 11 0 23 -2 24 -12 9z"/>
|
||||||
|
<path d="M3679 1028 c-5 -16 -4 -46 2 -42 4 2 7 13 6 24 -1 17 -5 26 -8 18z"/>
|
||||||
|
<path d="M1710 965 c0 -7 30 -13 34 -7 3 4 -4 9 -15 9 -10 1 -19 0 -19 -2z"/>
|
||||||
|
<path d="M3610 939 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||||
|
-5 -10 -11z"/>
|
||||||
|
<path d="M3570 879 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||||
|
-5 -10 -11z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 16 KiB |
BIN
client/images/snapdrop-graphics.sketch
Executable file
BIN
client/images/twitter-stream.jpg
Executable file
|
After Width: | Height: | Size: 38 KiB |
233
client/index.html
Executable file
@ -0,0 +1,233 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<!-- Web App Config -->
|
||||||
|
<title>Snapdrop</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#3367d6">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="no">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Snapdrop">
|
||||||
|
<!-- Descriptions -->
|
||||||
|
<meta name="description" content="Instantly share images, videos, PDFs, and links with people nearby. Peer2Peer and Open Source. No Setup, No Signup.">
|
||||||
|
<meta name="keywords" content="File, Transfer, Share, Peer2Peer">
|
||||||
|
<meta name="author" content="RobinLinus">
|
||||||
|
<meta property="og:title" content="Snapdrop">
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:url" content="https://snapdrop.net/">
|
||||||
|
<meta property="og:author" content="https://facebook.com/RobinLinus">
|
||||||
|
<meta name="twitter:author" content="@RobinLinus">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:description" content="Instantly share images, videos, PDFs, and links with people nearby. Peer2Peer and Open Source. No Setup, No Signup.">
|
||||||
|
<meta name="og:description" content="Instantly share images, videos, PDFs, and links with people nearby. Peer2Peer and Open Source. No Setup, No Signup.">
|
||||||
|
<!-- Icons -->
|
||||||
|
<link rel="icon" sizes="96x96" href="images/favicon-96x96.png">
|
||||||
|
<link rel="shortcut icon" href="images/favicon-96x96.png">
|
||||||
|
<link rel="apple-touch-icon" href="images/apple-touch-icon.png">
|
||||||
|
<meta name="msapplication-TileImage" content="images/mstile-150x150.png">
|
||||||
|
<link rel="fluid-icon" type="image/png" href="images/android-chrome-192x192.png">
|
||||||
|
<meta name="twitter:image" content="https://snapdrop.net/images/twitter-stream.jpg">
|
||||||
|
<meta property="og:image" content="https://snapdrop.net/images/twitter-stream.jpg">
|
||||||
|
<!-- Resources -->
|
||||||
|
<link rel="stylesheet" type="text/css" href="styles.css">
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body translate="no">
|
||||||
|
<header class="row-reverse">
|
||||||
|
<a href="#about" class="icon-button" title="About Snapdrop">
|
||||||
|
<svg class="icon">
|
||||||
|
<use xlink:href="#info-outline" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="#" id="notification" class="icon-button" title="Enable Notifications" hidden>
|
||||||
|
<svg class="icon">
|
||||||
|
<use xlink:href="#notifications" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="#" id="install" class="icon-button" title="Install Snapdrop" hidden>
|
||||||
|
<svg class="icon">
|
||||||
|
<use xlink:href="#homescreen" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
<!-- Peers -->
|
||||||
|
<x-peers class="center"></x-peers>
|
||||||
|
<x-no-peers>
|
||||||
|
<h2>在其他设备上打开Snapdrop以发送文件</h2>
|
||||||
|
</x-no-peers>
|
||||||
|
<x-instructions desktop="点击发送文件或右键发送消息" mobile="轻触发送文件或长按发送消息"></x-instructions>
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="column">
|
||||||
|
<svg class="icon logo">
|
||||||
|
<use xlink:href="#wifi-tethering" />
|
||||||
|
</svg>
|
||||||
|
<div id="displayName" placeholder="在设备间传输文件的最简单方法"></div>
|
||||||
|
<div class="font-body2">您可以被此网络上的每个人发现</div>
|
||||||
|
</footer>
|
||||||
|
<!-- Receive Dialog -->
|
||||||
|
<x-dialog id="receiveDialog">
|
||||||
|
<x-background class="full center">
|
||||||
|
<x-paper shadow="2">
|
||||||
|
<h3>文件已接收</h3>
|
||||||
|
<div class="font-subheading" id="fileName">文件名</div>
|
||||||
|
<div class="font-body2" id="fileSize"></div>
|
||||||
|
<div class='preview' style="visibility: hidden;">
|
||||||
|
<img id='img-preview' src="">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="autoDownload" class="grow">全选所有</label>
|
||||||
|
<input type="checkbox" id="autoDownload" checked="">
|
||||||
|
</div>
|
||||||
|
<div class="row-reverse">
|
||||||
|
<a class="button" close id="download" title="Download File" autofocus>保存</a>
|
||||||
|
<button class="button" close>忽视</button>
|
||||||
|
</div>
|
||||||
|
</x-paper>
|
||||||
|
</x-background>
|
||||||
|
</x-dialog>
|
||||||
|
<!-- Send Text Dialog -->
|
||||||
|
<x-dialog id="sendTextDialog">
|
||||||
|
<form action="#">
|
||||||
|
<x-background class="full center">
|
||||||
|
<x-paper shadow="2">
|
||||||
|
<h3>发送信息</h3>
|
||||||
|
<div id="textInput" class="textarea" role="textbox" placeholder="发送消息" autocomplete="off" autofocus contenteditable></div>
|
||||||
|
<div class="row-reverse">
|
||||||
|
<button class="button" type="submit" close>发送</button>
|
||||||
|
<a class="button" close>取消</a>
|
||||||
|
</div>
|
||||||
|
</x-paper>
|
||||||
|
</x-background>
|
||||||
|
</form>
|
||||||
|
</x-dialog>
|
||||||
|
<!-- Receive Text Dialog -->
|
||||||
|
<x-dialog id="receiveTextDialog">
|
||||||
|
<x-background class="full center">
|
||||||
|
<x-paper shadow="2">
|
||||||
|
<h3>已收到消息</h3>
|
||||||
|
<div class="font-subheading" id="text"></div>
|
||||||
|
<div class="row-reverse">
|
||||||
|
<button class="button" id="copy" close autofocus>复制</button>
|
||||||
|
<button class="button" close>关闭</button>
|
||||||
|
</div>
|
||||||
|
</x-paper>
|
||||||
|
</x-background>
|
||||||
|
</x-dialog>
|
||||||
|
<!-- Toast -->
|
||||||
|
<div class="toast-container full center">
|
||||||
|
<x-toast class="row" shadow="1" id="toast">文件传输完成!</x-toast>
|
||||||
|
</div>
|
||||||
|
<!-- About Page -->
|
||||||
|
<x-about id="about" class="full center column">
|
||||||
|
<section class="center column fade-in">
|
||||||
|
<header class="row-reverse">
|
||||||
|
<a href="#" class="close icon-button">
|
||||||
|
<svg class="icon">
|
||||||
|
<use xlink:href="#close" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
<svg class="icon logo">
|
||||||
|
<use xlink:href="#wifi-tethering" />
|
||||||
|
</svg>
|
||||||
|
<h1>Snapdrop</h1>
|
||||||
|
<div class="font-subheading">在设备间传输文件的最简单方法</div>
|
||||||
|
<div class="row">
|
||||||
|
<a class="icon-button" target="_blank" href="https://github.com/RobinLinus/snapdrop" title="Snapdrop on Github" rel="noreferrer">
|
||||||
|
<svg class="icon">
|
||||||
|
<use xlink:href="#github" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<!--<a class="icon-button" target="_blank" href="https://www.paypal.com/donate?hosted_button_id=FTP9DXUR7LA7Q" title="Help cover the server costs!" rel="noreferrer">-->
|
||||||
|
<!-- <svg class="icon">-->
|
||||||
|
<!-- <use xlink:href="#monetarization" />-->
|
||||||
|
<!-- </svg>-->
|
||||||
|
<!--</a>-->
|
||||||
|
<!--<a class="icon-button" target="_blank" href="https://twitter.com/intent/tweet?text=https://snapdrop.net%20by%20@robin_linus%20&" title="Tweet about Snapdrop" rel="noreferrer">-->
|
||||||
|
<!-- <svg class="icon">-->
|
||||||
|
<!-- <use xlink:href="#twitter" />-->
|
||||||
|
<!-- </svg>-->
|
||||||
|
<!--</a>-->
|
||||||
|
<!--<a class="icon-button" target="_blank" href="https://github.com/RobinLinus/snapdrop/blob/master/docs/faq.md" title="Frequently asked questions" rel="noreferrer">-->
|
||||||
|
<!-- <svg class="icon">-->
|
||||||
|
<!-- <use xlink:href="#help-outline" />-->
|
||||||
|
<!-- </svg>-->
|
||||||
|
<!--</a>-->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<x-background></x-background>
|
||||||
|
</x-about>
|
||||||
|
<!-- SVG Icon Library -->
|
||||||
|
<svg style="display: none;">
|
||||||
|
<symbol id=wifi-tethering viewBox="0 0 24 24">
|
||||||
|
<path d="M12 11c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 2c0-3.31-2.69-6-6-6s-6 2.69-6 6c0 2.22 1.21 4.15 3 5.19l1-1.74c-1.19-.7-2-1.97-2-3.45 0-2.21 1.79-4 4-4s4 1.79 4 4c0 1.48-.81 2.75-2 3.45l1 1.74c1.79-1.04 3-2.97 3-5.19zM12 3C6.48 3 2 7.48 2 13c0 3.7 2.01 6.92 4.99 8.65l1-1.73C5.61 18.53 4 15.96 4 13c0-4.42 3.58-8 8-8s8 3.58 8 8c0 2.96-1.61 5.53-4 6.92l1 1.73c2.99-1.73 5-4.95 5-8.65 0-5.52-4.48-10-10-10z"></path>
|
||||||
|
</symbol>
|
||||||
|
<symbol id=desktop-mac viewBox="0 0 24 24">
|
||||||
|
<path d="M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7l-2 3v1h8v-1l-2-3h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 12H3V4h18v10z"></path>
|
||||||
|
</symbol>
|
||||||
|
<symbol id=phone-iphone viewBox="0 0 24 24">
|
||||||
|
<path d="M15.5 1h-8C6.12 1 5 2.12 5 3.5v17C5 21.88 6.12 23 7.5 23h8c1.38 0 2.5-1.12 2.5-2.5v-17C18 2.12 16.88 1 15.5 1zm-4 21c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5-4H7V4h9v14z"></path>
|
||||||
|
</symbol>
|
||||||
|
<symbol id=tablet-mac viewBox="0 0 24 24">
|
||||||
|
<path d="M18.5 0h-14C3.12 0 2 1.12 2 2.5v19C2 22.88 3.12 24 4.5 24h14c1.38 0 2.5-1.12 2.5-2.5v-19C21 1.12 19.88 0 18.5 0zm-7 23c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm7.5-4H4V3h15v16z"></path>
|
||||||
|
</symbol>
|
||||||
|
<symbol id=info-outline viewBox="0 0 24 24">
|
||||||
|
<path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"></path>
|
||||||
|
</symbol>
|
||||||
|
<symbol id=close viewBox="0 0 24 24">
|
||||||
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path>
|
||||||
|
</symbol>
|
||||||
|
<symbol id=help-outline viewBox="0 0 24 24">
|
||||||
|
<path d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"></path>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="twitter">
|
||||||
|
<path d="M23.954 4.569c-.885.389-1.83.654-2.825.775 1.014-.611 1.794-1.574 2.163-2.723-.951.555-2.005.959-3.127 1.184-.896-.959-2.173-1.559-3.591-1.559-2.717 0-4.92 2.203-4.92 4.917 0 .39.045.765.127 1.124C7.691 8.094 4.066 6.13 1.64 3.161c-.427.722-.666 1.561-.666 2.475 0 1.71.87 3.213 2.188 4.096-.807-.026-1.566-.248-2.228-.616v.061c0 2.385 1.693 4.374 3.946 4.827-.413.111-.849.171-1.296.171-.314 0-.615-.03-.916-.086.631 1.953 2.445 3.377 4.604 3.417-1.68 1.319-3.809 2.105-6.102 2.105-.39 0-.779-.023-1.17-.067 2.189 1.394 4.768 2.209 7.557 2.209 9.054 0 13.999-7.496 13.999-13.986 0-.209 0-.42-.015-.63.961-.689 1.8-1.56 2.46-2.548l-.047-.02z" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github">
|
||||||
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||||
|
</symbol>
|
||||||
|
<g id="notifications">
|
||||||
|
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
|
||||||
|
</g>
|
||||||
|
<symbol id="homescreen">
|
||||||
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
|
<path d="M18 1.01L8 1c-1.1 0-2 .9-2 2v3h2V5h10v14H8v-1H6v3c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM10 15h2V8H5v2h3.59L3 15.59 4.41 17 10 11.41z" />
|
||||||
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||||
|
</symbol>
|
||||||
|
<symbol id="monetarization">
|
||||||
|
<path d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1.41 16.09V20h-2.67v-1.93c-1.71-.36-3.16-1.46-3.27-3.4h1.96c.1 1.05.82 1.87 2.65 1.87 1.96 0 2.4-.98 2.4-1.59 0-.83-.44-1.61-2.67-2.14-2.48-.6-4.18-1.62-4.18-3.67 0-1.72 1.39-2.84 3.11-3.21V4h2.67v1.95c1.86.45 2.79 1.86 2.85 3.39H14.3c-.05-1.11-.64-1.87-2.22-1.87-1.5 0-2.4.68-2.4 1.64 0 .84.65 1.39 2.67 1.91s4.18 1.39 4.18 3.91c-.01 1.83-1.38 2.83-3.12 3.16z" />
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="scripts/network.js"></script>
|
||||||
|
<script src="scripts/ui.js"></script>
|
||||||
|
<script src="scripts/clipboard.js" async></script>
|
||||||
|
<!-- Sounds -->
|
||||||
|
<audio id="blop" autobuffer="true">
|
||||||
|
<source src="sounds/blop.mp3" type="audio/mpeg">
|
||||||
|
<source src="sounds/blop.ogg" type="audio/ogg">
|
||||||
|
</audio>
|
||||||
|
<!-- no script -->
|
||||||
|
<noscript>
|
||||||
|
<x-noscript class="full center column">
|
||||||
|
<h1>启用 JS</h1>
|
||||||
|
<h3>Snardrep 只能使用 JavaScript</h3>
|
||||||
|
</x-noscript>
|
||||||
|
<style>
|
||||||
|
x-noscript {
|
||||||
|
background: #599cfc;
|
||||||
|
color: white;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
a[href="#info"] {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</noscript>
|
||||||
|
</body>
|
||||||
39
client/manifest.json
Executable file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
client/scripts/clipboard.js
Executable file
@ -0,0 +1,38 @@
|
|||||||
|
// Polyfill for Navigator.clipboard.writeText
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
navigator.clipboard = {
|
||||||
|
writeText: text => {
|
||||||
|
|
||||||
|
// A <span> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
528
client/scripts/network.js
Executable file
@ -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'
|
||||||
|
}]
|
||||||
|
}
|
||||||
642
client/scripts/ui.js
Executable file
@ -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 `
|
||||||
|
<label class="column center" title="单击发送文件或右键单击发送文本">
|
||||||
|
<input type="file" multiple>
|
||||||
|
<x-icon shadow="1">
|
||||||
|
<svg class="icon"><use xlink:href="#"/></svg>
|
||||||
|
</x-icon>
|
||||||
|
<div class="progress">
|
||||||
|
<div class="circle"></div>
|
||||||
|
<div class="circle right"></div>
|
||||||
|
</div>
|
||||||
|
<div class="name font-subheading"></div>
|
||||||
|
<div class="device-name font-body2"></div>
|
||||||
|
<div class="status font-body2"></div>
|
||||||
|
</label>`
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
56
client/service-worker.js
Executable file
@ -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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
BIN
client/sounds/blop.mp3
Executable file
BIN
client/sounds/blop.ogg
Executable file
733
client/styles.css
Executable file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
25
docker-compose.yml
Executable file
@ -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;"]
|
||||||
1
docker/fqdn.env
Executable file
@ -0,0 +1 @@
|
|||||||
|
FQDN=localhost
|
||||||
3
docker/nginx-with-openssl.Dockerfile
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
75
docker/nginx/default.conf
Executable file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
9
docker/openssl/create.sh
Executable file
@ -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 "$@"
|
||||||
26
docker/openssl/snapdropCA.cnf
Executable file
@ -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
|
||||||
|
|
||||||
29
docker/openssl/snapdropCert.cnf
Executable file
@ -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
|
||||||
76
docs/faq.md
Executable file
@ -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).
|
||||||
|
<img src="pwa-install.png">
|
||||||
|
|
||||||
|
### 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)
|
||||||
61
docs/local-dev.md
Executable file
@ -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 <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://<Your FQDN>: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://<Your FQDN>: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://<Your FQDN>: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)
|
||||||
BIN
docs/pwa-install.png
Executable file
|
After Width: | Height: | Size: 36 KiB |
298
server/index.js
Executable file
@ -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 `<Peer id=${this.id} ip=${this.ip} rtcSupported=${this.rtcSupported}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
_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);
|
||||||
16
server/package.json
Executable file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
server/pnpm-lock.yaml
generated
Normal file
@ -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
|
||||||