Merge branch 'next' into translate
2
.github/ISSUE_TEMPLATE/bug-report.md
vendored
|
@ -43,7 +43,7 @@ No | Yes
|
||||||
|
|
||||||
**Self-Hosted Setup**
|
**Self-Hosted Setup**
|
||||||
Proxy: Nginx | Apache2
|
Proxy: Nginx | Apache2
|
||||||
Deployment: docker run | docker-compose | npm run start:prod
|
Deployment: docker run | docker compose | npm run start:prod
|
||||||
Version: v1.9.4
|
Version: v1.9.4
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
|
|
3
.gitignore
vendored
|
@ -3,3 +3,6 @@ node_modules
|
||||||
fqdn.env
|
fqdn.env
|
||||||
/docker/certs
|
/docker/certs
|
||||||
qrcode-svg/
|
qrcode-svg/
|
||||||
|
turnserver.conf
|
||||||
|
rtc_config.json
|
||||||
|
ssl/
|
||||||
|
|
|
@ -8,7 +8,12 @@ RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# environment settings
|
||||||
|
ENV NODE_ENV="production"
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
CMD wget --quiet --tries=1 --spider http://localhost:3000 || exit 1
|
CMD wget --quiet --tries=1 --spider http://localhost:3000 || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["npm", "start"]
|
|
@ -85,7 +85,8 @@ Developed based on [Snapdrop](https://github.com/RobinLinus/snapdrop)
|
||||||
* Lots of stability fixes (Thanks [@MWY001](https://github.com/MWY001) [@skiby7](https://github.com/skiby7) and [@willstott101](https://github.com/willstott101))
|
* Lots of stability fixes (Thanks [@MWY001](https://github.com/MWY001) [@skiby7](https://github.com/skiby7) and [@willstott101](https://github.com/willstott101))
|
||||||
* To host PairDrop on your local network (e.g. on Raspberry Pi): [All peers connected with private IPs are discoverable by each other](https://github.com/RobinLinus/snapdrop/pull/558)
|
* To host PairDrop on your local network (e.g. on Raspberry Pi): [All peers connected with private IPs are discoverable by each other](https://github.com/RobinLinus/snapdrop/pull/558)
|
||||||
* When hosting PairDrop yourself you can [set your own STUN/TURN servers](/docs/host-your-own.md#specify-stunturn-servers)
|
* When hosting PairDrop yourself you can [set your own STUN/TURN servers](/docs/host-your-own.md#specify-stunturn-servers)
|
||||||
* Built-in translations
|
* Built-in translations via [Weblate](https://hosted.weblate.org/engage/pairdrop/)
|
||||||
|
* Airy design (Thanks [@Avieshek](https://linktr.ee/avieshek/))
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
|
@ -1,19 +1,31 @@
|
||||||
version: "3"
|
version: "3"
|
||||||
services:
|
services:
|
||||||
node:
|
pairdrop:
|
||||||
image: "node:lts-alpine"
|
image: "lscr.io/linuxserver/pairdrop:latest"
|
||||||
user: "node"
|
container_name: pairdrop
|
||||||
working_dir: /home/node/app
|
|
||||||
volumes:
|
|
||||||
- ./:/home/node/app
|
|
||||||
command: ash -c "npm i && npm run start:prod"
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./rtc_config.json:/home/node/app/rtc_config.json
|
||||||
|
environment:
|
||||||
|
- PUID=1000 # UID to run the application as
|
||||||
|
- PGID=1000 # GID to run the application as
|
||||||
|
- WS_FALLBACK=false # Set to true to enable websocket fallback if the peer to peer WebRTC connection is not available to the client.
|
||||||
|
- RATE_LIMIT=false # Set to true to limit clients to 1000 requests per 5 min.
|
||||||
|
- RTC_CONFIG=/home/node/app/rtc_config.json # Set to the path of a file that specifies the STUN/TURN servers.
|
||||||
|
- DEBUG_MODE=false # Set to true to debug container and peer connections.
|
||||||
|
- TZ=Etc/UTC # Time Zone
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "127.0.0.1:3000:3000" # Web UI. Change the port number before the last colon e.g. `127.0.0.1:9000:3000`
|
||||||
coturn_server:
|
coturn_server:
|
||||||
image: "coturn/coturn"
|
image: "coturn/coturn"
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
network_mode: "host"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./turnserver.conf:/etc/coturn/turnserver.conf
|
- ./turnserver.conf:/etc/coturn/turnserver.conf
|
||||||
#you need to copy turnserver_example.conf to turnserver.conf and specify domain, IP address, user and password
|
- ./ssl/:/etc/coturn/ssl/
|
||||||
|
ports:
|
||||||
|
- "3478:3478"
|
||||||
|
- "3478:3478/udp"
|
||||||
|
- "5349:5349"
|
||||||
|
- "5349:5349/udp"
|
||||||
|
- "10000-20000:10000-20000/udp"
|
||||||
|
# see guide at docs/host-your-own.md#coturn-and-pairdrop-via-docker-compose
|
|
@ -1,12 +1,16 @@
|
||||||
version: "3"
|
version: "3"
|
||||||
services:
|
services:
|
||||||
node:
|
pairdrop:
|
||||||
image: "node:lts-alpine"
|
image: "lscr.io/linuxserver/pairdrop:latest"
|
||||||
user: "node"
|
container_name: pairdrop
|
||||||
working_dir: /home/node/app
|
|
||||||
volumes:
|
|
||||||
- ./:/home/node/app
|
|
||||||
command: ash -c "npm i && npm run start:prod"
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PUID=1000 # UID to run the application as
|
||||||
|
- PGID=1000 # GID to run the application as
|
||||||
|
- WS_FALLBACK=false # Set to true to enable websocket fallback if the peer to peer WebRTC connection is not available to the client.
|
||||||
|
- RATE_LIMIT=false # Set to true to limit clients to 1000 requests per 5 min.
|
||||||
|
- RTC_CONFIG=false # Set to the path of a file that specifies the STUN/TURN servers.
|
||||||
|
- DEBUG_MODE=false # Set to true to debug container and peer connections.
|
||||||
|
- TZ=Etc/UTC # Time Zone
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "127.0.0.1:3000:3000" # Web UI. Change the port number before the last colon e.g. `127.0.0.1:9000:3000`
|
||||||
|
|
|
@ -1,181 +1,132 @@
|
||||||
# Deployment Notes
|
# Deployment Notes
|
||||||
The easiest way to get PairDrop up and running is by using Docker.
|
|
||||||
|
|
||||||
> <b>TURN server for Internet Transfer</b>
|
## TURN server for Internet Transfer
|
||||||
>
|
|
||||||
> Beware that you have to host your own TURN server to enable transfers between different networks.
|
|
||||||
>
|
|
||||||
> Follow [this guide](https://gabrieltanner.org/blog/turn-server/) to either install coturn directly on your system (Step 1) \
|
|
||||||
> or deploy it via docker-compose (Step 5).
|
|
||||||
|
|
||||||
> <b>PairDrop via HTTPS</b>
|
Beware that you have to host your own TURN server to enable transfers between different networks.
|
||||||
>
|
|
||||||
> On some browsers PairDrop must be served over TLS in order for some feautures to work properly. These may include copying an incoming message via the 'copy' button, installing PairDrop as PWA, persistent pairing of devices and changing of the display name, and notifications. Naturally, this is also recommended to increase security.
|
Follow [this guide](https://gabrieltanner.org/blog/turn-server/) to either install coturn directly on your system (Step 1)
|
||||||
|
or deploy it via Docker (Step 5).
|
||||||
|
|
||||||
|
You can use the `docker-compose-coturn.yml` in this repository. See [Coturn and PairDrop via Docker Compose](#coturn-and-pairdrop-via-docker-compose).
|
||||||
|
|
||||||
|
Alternatively, use a free, pre-configured TURN server like [OpenRelay](https://www.metered.ca/tools/openrelay/)
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## PairDrop via HTTPS
|
||||||
|
|
||||||
|
On some browsers PairDrop must be served over TLS in order for some features to work properly.
|
||||||
|
These may include:
|
||||||
|
- Copying an incoming message via the 'copy' button
|
||||||
|
- Installing PairDrop as PWA
|
||||||
|
- Persistent pairing of devices
|
||||||
|
- Changing of the display name
|
||||||
|
- Notifications
|
||||||
|
|
||||||
|
Naturally, this is also recommended to increase security.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
## Deployment with Docker
|
## Deployment with Docker
|
||||||
|
|
||||||
|
The easiest way to get PairDrop up and running is by using Docker.
|
||||||
|
|
||||||
### Docker Image from Docker Hub
|
### Docker Image from Docker Hub
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --restart=unless-stopped --name=pairdrop -p 127.0.0.1:3000:3000 lscr.io/linuxserver/pairdrop
|
docker run -d --restart=unless-stopped --name=pairdrop -p 127.0.0.1:3000:3000 lscr.io/linuxserver/pairdrop
|
||||||
```
|
```
|
||||||
|
> This image is hosted by [linuxserver.io](https://linuxserver.io). For more information visit https://hub.docker.com/r/linuxserver/pairdrop
|
||||||
> You must use a server proxy to set the X-Forwarded-For \
|
|
||||||
> to prevent all clients from discovering each other (See [#HTTP-Server](#http-server)).
|
|
||||||
>
|
|
||||||
> To prevent bypassing the proxy by reaching the docker container directly, \
|
|
||||||
> `127.0.0.1` is specified in the run command.
|
|
||||||
|
|
||||||
#### Options / Flags
|
|
||||||
Set options by using the following flags in the `docker run` command:
|
|
||||||
|
|
||||||
##### Port
|
|
||||||
```bash
|
|
||||||
-p 127.0.0.1:8080:3000
|
|
||||||
```
|
|
||||||
> Specify the port used by the docker image
|
|
||||||
> - 3000 -> `-p 127.0.0.1:3000:3000`
|
|
||||||
> - 8080 -> `-p 127.0.0.1:8080:3000`
|
|
||||||
##### Rate limiting requests
|
|
||||||
```bash
|
|
||||||
-e RATE_LIMIT=true
|
|
||||||
```
|
|
||||||
> Limits clients to 1000 requests per 5 min
|
|
||||||
|
|
||||||
##### IPv6 Localization
|
|
||||||
```bash
|
|
||||||
-e IPV6_LOCALIZE=4
|
|
||||||
```
|
|
||||||
> To enable Peer Discovery among IPv6 peers, you can specify a reduced number of segments \
|
|
||||||
> of the client IPv6 address to be evaluated as the peer's IP. \
|
|
||||||
> This can be especially useful when using Cloudflare as a proxy.
|
|
||||||
>
|
|
||||||
> The flag must be set to an **integer** between `1` and `7`. \
|
|
||||||
> The number represents the number of IPv6 [hextets](https://en.wikipedia.org/wiki/IPv6#Address_representation) \
|
|
||||||
> to match the client IP against. The most common value would be `4`, \
|
|
||||||
> which will group peers within the same `/64` subnet.
|
|
||||||
|
|
||||||
##### Websocket Fallback (for VPN)
|
|
||||||
```bash
|
|
||||||
-e WS_FALLBACK=true
|
|
||||||
```
|
|
||||||
> Provides PairDrop to clients with an included websocket fallback \
|
|
||||||
> if the peer to peer WebRTC connection is not available to the client.
|
|
||||||
>
|
|
||||||
> This is not used on the official https://pairdrop.net website, \
|
|
||||||
> but you can activate it on your self-hosted instance.
|
|
||||||
> This is especially useful if you connect to your instance via a VPN (as most VPN services block WebRTC completely in order to hide your real IP address). ([Read more here](https://privacysavvy.com/security/safe-browsing/disable-webrtc-chrome-firefox-safari-opera-edge/)).
|
|
||||||
>
|
|
||||||
> **Warning:** All traffic sent between devices using this fallback \
|
|
||||||
> is routed through the server and therefor not peer to peer! \
|
|
||||||
> Beware that the traffic routed via this fallback is readable by the server. \
|
|
||||||
> Only ever use this on instances you can trust. \
|
|
||||||
> Additionally, beware that all traffic using this fallback debits the servers data plan.
|
|
||||||
|
|
||||||
##### Specify STUN/TURN Servers
|
|
||||||
```bash
|
|
||||||
-e RTC_CONFIG="rtc_config.json"
|
|
||||||
```
|
|
||||||
|
|
||||||
> Specify the STUN/TURN servers PairDrop clients use by setting \
|
|
||||||
> `RTC_CONFIG` to a JSON file including the configuration. \
|
|
||||||
> You can use `pairdrop/rtc_config_example.json` as a starting point.
|
|
||||||
>
|
|
||||||
> To host your own TURN server you can follow this guide: https://gabrieltanner.org/blog/turn-server/
|
|
||||||
> Alternatively, use a free, pre-configured TURN server like [OpenRelay]([url](https://www.metered.ca/tools/openrelay/))
|
|
||||||
>
|
|
||||||
> Default configuration:
|
|
||||||
> ```json
|
|
||||||
> {
|
|
||||||
> "sdpSemantics": "unified-plan",
|
|
||||||
> "iceServers": [
|
|
||||||
> {
|
|
||||||
> "urls": "stun:stun.l.google.com:19302"
|
|
||||||
> }
|
|
||||||
> ]
|
|
||||||
> }
|
|
||||||
> ```
|
|
||||||
|
|
||||||
##### Debug Mode
|
|
||||||
```bash
|
|
||||||
-e DEBUG_MODE="true"
|
|
||||||
```
|
|
||||||
|
|
||||||
> Use this flag to enable debugging information about the connecting peers IP addresses. \
|
|
||||||
> This is quite useful to check whether the [#HTTP-Server](#http-server) \
|
|
||||||
> is configured correctly, so the auto-discovery feature works correctly. \
|
|
||||||
> Otherwise, all clients discover each other mutually, independently of their network status.
|
|
||||||
>
|
|
||||||
> If this flag is set to `"true"` each peer that connects to the PairDrop server will produce a log to STDOUT like this:
|
|
||||||
> ```
|
|
||||||
> ----DEBUGGING-PEER-IP-START----
|
|
||||||
> remoteAddress: ::ffff:172.17.0.1
|
|
||||||
> x-forwarded-for: 19.117.63.126
|
|
||||||
> cf-connecting-ip: undefined
|
|
||||||
> PairDrop uses: 19.117.63.126
|
|
||||||
> IP is private: false
|
|
||||||
> if IP is private, '127.0.0.1' is used instead
|
|
||||||
> ----DEBUGGING-PEER-IP-END----
|
|
||||||
> ```
|
|
||||||
> If the IP PairDrop uses is the public IP of your device, everything is set up correctly. \
|
|
||||||
>To find out your devices public IP visit https://www.whatismyip.com/.
|
|
||||||
>
|
|
||||||
> To preserve your clients' privacy, **never use this flag in production!**
|
|
||||||
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
### Docker Image from GHCR
|
### Docker Image from GitHub Container Registry (ghcr.io)
|
||||||
```bash
|
|
||||||
docker run -d --restart=unless-stopped --name=pairdrop -p 127.0.0.1:3000:3000 ghcr.io/schlagmichdoch/pairdrop npm run start:prod
|
|
||||||
```
|
|
||||||
> You must use a server proxy to set the X-Forwarded-For to prevent \
|
|
||||||
> all clients from discovering each other (See [#HTTP-Server](#http-server)).
|
|
||||||
>
|
|
||||||
> To prevent bypassing the proxy by reaching the Docker container directly, \
|
|
||||||
> `127.0.0.1` is specified in the run command.
|
|
||||||
>
|
|
||||||
> To specify options replace `npm run start:prod` \
|
|
||||||
> according to [the documentation below.](#options--flags-1)
|
|
||||||
|
|
||||||
> The Docker Image includes a healthcheck. \
|
```bash
|
||||||
> Read more about [Docker Swarm Usage](docker-swarm-usage.md#docker-swarm-usage).
|
docker run -d --restart=unless-stopped --name=pairdrop -p 127.0.0.1:3000:3000 ghcr.io/schlagmichdoch/pairdrop
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
### Docker Image self-built
|
### Docker Image self-built
|
||||||
|
|
||||||
#### Build the image
|
#### Build the image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build --pull . -f Dockerfile -t pairdrop
|
docker build --pull . -f Dockerfile -t pairdrop
|
||||||
```
|
```
|
||||||
> A GitHub action is set up to do this step automatically.
|
|
||||||
|
> A GitHub action is set up to do this step automatically at the release of new versions.
|
||||||
>
|
>
|
||||||
> `--pull` ensures always the latest node image is used.
|
> `--pull` ensures always the latest node image is used.
|
||||||
|
|
||||||
#### Run the image
|
#### Run the image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --restart=unless-stopped --name=pairdrop -p 127.0.0.1:3000:3000 -it pairdrop npm run start:prod
|
docker run -d --restart=unless-stopped --name=pairdrop -p 127.0.0.1:3000:3000 -it pairdrop
|
||||||
```
|
```
|
||||||
> You must use a server proxy to set the X-Forwarded-For \
|
|
||||||
|
> You must use a server proxy to set the `X-Forwarded-For` header
|
||||||
> to prevent all clients from discovering each other (See [#HTTP-Server](#http-server)).
|
> to prevent all clients from discovering each other (See [#HTTP-Server](#http-server)).
|
||||||
>
|
>
|
||||||
> To prevent bypassing the proxy by reaching the Docker container \
|
> To prevent bypassing the proxy by reaching the docker container directly,
|
||||||
> directly, `127.0.0.1` is specified in the run command.
|
> `127.0.0.1` is specified in the run command.
|
||||||
>
|
|
||||||
> To specify options replace `npm run start:prod` \
|
|
||||||
> according to [the documentation below.](#options--flags-1)
|
|
||||||
|
|
||||||
> The Docker Image includes a Healthcheck. \
|
|
||||||
Read more about [Docker Swarm Usage](docker-swarm-usage.md#docker-swarm-usage).
|
<br>
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
|
||||||
|
Set options by using the following flags in the `docker run` command:
|
||||||
|
|
||||||
|
#### Port
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-p 127.0.0.1:8080:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
> Specify the port used by the docker image
|
||||||
|
>
|
||||||
|
> - 3000 -> `-p 127.0.0.1:3000:3000`
|
||||||
|
> - 8080 -> `-p 127.0.0.1:8080:3000`
|
||||||
|
|
||||||
|
#### Set Environment Variables via Docker
|
||||||
|
|
||||||
|
Environment Variables are set directly in the `docker run` command: \
|
||||||
|
e.g. `docker run -p 127.0.0.1:3000:3000 -it pairdrop -e DEBUG_MODE="true"`
|
||||||
|
|
||||||
|
Overview of available Environment Variables are found [here](#environment-variables).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name=pairdrop \
|
||||||
|
--restart=unless-stopped \
|
||||||
|
-p 127.0.0.1:3000:3000 \
|
||||||
|
-e PUID=1000 \
|
||||||
|
-e PGID=1000 \
|
||||||
|
-e WS_SERVER=false \
|
||||||
|
-e WS_FALLBACK=false \
|
||||||
|
-e RTC_CONFIG=false \
|
||||||
|
-e RATE_LIMIT=false \
|
||||||
|
-e DEBUG_MODE=false \
|
||||||
|
-e TZ=Etc/UTC \
|
||||||
|
lscr.io/linuxserver/pairdrop
|
||||||
|
```
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
## Deployment with Docker Compose
|
## Deployment with Docker Compose
|
||||||
Here's an example docker-compose file:
|
|
||||||
|
Here's an example docker compose file:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: "2"
|
version: "3"
|
||||||
services:
|
services:
|
||||||
pairdrop:
|
pairdrop:
|
||||||
image: lscr.io/linuxserver/pairdrop:latest
|
image: "lscr.io/linuxserver/pairdrop:latest"
|
||||||
container_name: pairdrop
|
container_name: pairdrop
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
@ -183,22 +134,26 @@ services:
|
||||||
- PGID=1000 # GID to run the application as
|
- PGID=1000 # GID to run the application as
|
||||||
- WS_FALLBACK=false # Set to true to enable websocket fallback if the peer to peer WebRTC connection is not available to the client.
|
- WS_FALLBACK=false # Set to true to enable websocket fallback if the peer to peer WebRTC connection is not available to the client.
|
||||||
- RATE_LIMIT=false # Set to true to limit clients to 1000 requests per 5 min.
|
- RATE_LIMIT=false # Set to true to limit clients to 1000 requests per 5 min.
|
||||||
|
- RTC_CONFIG=false # Set to the path of a file that specifies the STUN/TURN servers.
|
||||||
|
- DEBUG_MODE=false # Set to true to debug container and peer connections.
|
||||||
- TZ=Etc/UTC # Time Zone
|
- TZ=Etc/UTC # Time Zone
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:3000:3000 # Web UI
|
- "127.0.0.1:3000:3000" # Web UI
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the compose file with `docker compose up -d`.
|
Run the compose file with `docker compose up -d`.
|
||||||
|
|
||||||
> You must use a server proxy to set the X-Forwarded-For \
|
> You must use a server proxy to set the `X-Forwarded-For` header
|
||||||
> to prevent all clients from discovering each other (See [#HTTP-Server](#http-server)).
|
> to prevent all clients from discovering each other (See [#HTTP-Server](#http-server)).
|
||||||
>
|
>
|
||||||
> To prevent bypassing the proxy by reaching the Docker container \
|
> To prevent bypassing the proxy by reaching the Docker container
|
||||||
> directly, `127.0.0.1` is specified in the run command.
|
> directly, `127.0.0.1` is specified in the `ports` argument.
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
## Deployment with node
|
## Deployment with Node.js
|
||||||
|
|
||||||
|
Clone this repository and enter the folder
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/schlagmichdoch/PairDrop.git && cd PairDrop
|
git clone https://github.com/schlagmichdoch/PairDrop.git && cd PairDrop
|
||||||
|
@ -212,56 +167,223 @@ npm install
|
||||||
|
|
||||||
Start the server with:
|
Start the server with:
|
||||||
|
|
||||||
```bash
|
|
||||||
node index.js
|
|
||||||
```
|
|
||||||
or
|
|
||||||
```bash
|
```bash
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
> Remember to check your IP address using your OS command to see where you can access the server.
|
|
||||||
|
|
||||||
> By default, the node server listens on port 3000.
|
> By default, the node server listens on port 3000.
|
||||||
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
### Environment variables
|
### Options / Flags
|
||||||
#### Port
|
|
||||||
On Unix based systems
|
These are some flags only reasonable when deploying via Node.js
|
||||||
```bash
|
|
||||||
PORT=3010 npm start
|
#### Port
|
||||||
```
|
|
||||||
On Windows
|
```bash
|
||||||
```bash
|
PORT=3000
|
||||||
$env:PORT=3010; npm start
|
```
|
||||||
```
|
|
||||||
> Specify the port PairDrop is running on. (Default: 3000)
|
> Default: `3000`
|
||||||
|
>
|
||||||
|
> Environment variable to specify the port used by the Node.js server \
|
||||||
|
> e.g. `PORT=3010 npm start`
|
||||||
|
|
||||||
|
#### Local Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start -- --localhost-only
|
||||||
|
```
|
||||||
|
|
||||||
|
> Only allow connections from localhost.
|
||||||
|
>
|
||||||
|
> You must use a server proxy to set the `X-Forwarded-For` header
|
||||||
|
> to prevent all clients from discovering each other (See [#HTTP-Server](#http-server)).
|
||||||
|
>
|
||||||
|
> Use this when deploying PairDrop with node to prevent
|
||||||
|
> bypassing the reverse proxy by reaching the Node.js server directly.
|
||||||
|
|
||||||
|
#### Automatic restart on error
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start -- --auto-restart
|
||||||
|
```
|
||||||
|
|
||||||
|
> Restarts server automatically on error
|
||||||
|
|
||||||
|
#### Production (autostart and rate-limit)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
> shortcut for `RATE_LIMIT=5 npm start -- --auto-restart`
|
||||||
|
|
||||||
|
#### Production (autostart, rate-limit, localhost-only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start:prod -- --localhost-only
|
||||||
|
```
|
||||||
|
|
||||||
|
> To prevent connections to the node server from bypassing \
|
||||||
|
> the proxy server you should always use "--localhost-only" on production.
|
||||||
|
|
||||||
|
#### Set Environment Variables via Node.js
|
||||||
|
|
||||||
|
To specify environment variables set them in the run command in front of `npm start`.
|
||||||
|
The syntax is different on Unix and Windows.
|
||||||
|
|
||||||
|
On Unix based systems
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=3000 RTC_CONFIG="rtc_config.json" npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$env:PORT=3000 RTC_CONFIG="rtc_config.json"; npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Overview of available Environment Variables are found [here](#environment-variables).
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEBUG_MODE="true"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Default: `false`
|
||||||
|
>
|
||||||
|
> Logs the used environment variables for debugging.
|
||||||
|
>
|
||||||
|
> Prints debugging information about the connecting peers IP addresses.
|
||||||
|
>
|
||||||
|
> This is quite useful to check whether the [#HTTP-Server](#http-server)
|
||||||
|
> is configured correctly, so the auto-discovery feature works correctly.
|
||||||
|
> Otherwise, all clients discover each other mutually, independently of their network status.
|
||||||
|
>
|
||||||
|
> If this flag is set to `"true"` each peer that connects to the PairDrop server will produce a log to STDOUT like this:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> ----DEBUGGING-PEER-IP-START----
|
||||||
|
> remoteAddress: ::ffff:172.17.0.1
|
||||||
|
> x-forwarded-for: 19.117.63.126
|
||||||
|
> cf-connecting-ip: undefined
|
||||||
|
> PairDrop uses: 19.117.63.126
|
||||||
|
> IP is private: false
|
||||||
|
> if IP is private, '127.0.0.1' is used instead
|
||||||
|
> ----DEBUGGING-PEER-IP-END----
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> If the IP address "PairDrop uses" matches the public IP address of the client device, everything is set up correctly. \
|
||||||
|
> To find out the public IP address of the client device visit https://whatsmyip.com/.
|
||||||
|
>
|
||||||
|
> To preserve your clients' privacy: \
|
||||||
|
> **Never use this environment variable in production!**
|
||||||
|
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### Rate limiting requests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RATE_LIMIT=1
|
||||||
|
```
|
||||||
|
|
||||||
|
> Default: `false`
|
||||||
|
>
|
||||||
|
> Limits clients to 1000 requests per 5 min
|
||||||
|
>
|
||||||
|
> "If you are behind a proxy/load balancer (usually the case with most hosting services, e.g. Heroku, Bluemix, AWS ELB,
|
||||||
|
> Render, Nginx, Cloudflare, Akamai, Fastly, Firebase Hosting, Rackspace LB, Riverbed Stingray, etc.), the IP address of
|
||||||
|
> the request might be the IP of the load balancer/reverse proxy (making the rate limiter effectively a global one and
|
||||||
|
> blocking all requests once the limit is reached) or undefined."
|
||||||
|
> (See: https://express-rate-limit.mintlify.app/guides/troubleshooting-proxy-issues)
|
||||||
|
>
|
||||||
|
> To find the correct number to use for this setting:
|
||||||
|
>
|
||||||
|
> 1. Start PairDrop with `DEBUG_MODE=True` and `RATE_LIMIT=1`
|
||||||
|
> 2. Make a `get` request to `/ip` of the PairDrop instance (e.g. `https://pairdrop-example.net/ip`)
|
||||||
|
> 3. Check if the IP address returned in the response matches your public IP address (find out by visiting e.g. https://whatsmyip.com/)
|
||||||
|
> 4. You have found the correct number if the IP addresses match. If not, then increase `RATE_LIMIT` by one and redo 1. - 4.
|
||||||
|
>
|
||||||
|
> e.g. on Render you must use RATE_LIMIT=5
|
||||||
|
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### IPv6 Localization
|
||||||
|
|
||||||
#### IPv6 Localization
|
|
||||||
```bash
|
```bash
|
||||||
IPV6_LOCALIZE=4
|
IPV6_LOCALIZE=4
|
||||||
```
|
```
|
||||||
> Truncate a portion of the client IPv6 address to make peers more discoverable. \
|
|
||||||
> See [Options/Flags](#options--flags) above.
|
|
||||||
|
|
||||||
#### Specify STUN/TURN Server
|
> Default: `false`
|
||||||
On Unix based systems
|
|
||||||
```bash
|
|
||||||
RTC_CONFIG="rtc_config.json" npm start
|
|
||||||
```
|
|
||||||
On Windows
|
|
||||||
```bash
|
|
||||||
$env:RTC_CONFIG="rtc_config.json"; npm start
|
|
||||||
```
|
|
||||||
> Specify the STUN/TURN servers PairDrop clients use by setting `RTC_CONFIG` \
|
|
||||||
> to a JSON file including the configuration. \
|
|
||||||
> You can use `pairdrop/rtc_config_example.json` as a starting point.
|
|
||||||
>
|
>
|
||||||
> To host your own TURN server you can follow this guide: \
|
> To enable Peer Auto-Discovery among IPv6 peers, you can specify a reduced number of segments \
|
||||||
> https://gabrieltanner.org/blog/turn-server/
|
> of the client IPv6 address to be evaluated as the peer's IP. \
|
||||||
|
> This can be especially useful when using Cloudflare as a proxy.
|
||||||
|
>
|
||||||
|
> The flag must be set to an **integer** between `1` and `7`. \
|
||||||
|
> The number represents the number of IPv6 [hextets](https://en.wikipedia.org/wiki/IPv6#Address_representation) \
|
||||||
|
> to match the client IP against. The most common value would be `4`, \
|
||||||
|
> which will group peers within the same `/64` subnet.
|
||||||
|
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### Websocket Fallback (for VPN)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WS_FALLBACK=true
|
||||||
|
```
|
||||||
|
|
||||||
|
> Default: `false`
|
||||||
|
>
|
||||||
|
> Provides PairDrop to clients with an included websocket fallback \
|
||||||
|
> if the peer to peer WebRTC connection is not available to the client.
|
||||||
|
>
|
||||||
|
> This is not used on the official https://pairdrop.net website,
|
||||||
|
> but you can activate it on your self-hosted instance.\
|
||||||
|
> This is especially useful if you connect to your instance via a VPN (as most VPN services block WebRTC completely in
|
||||||
|
> order to hide your real IP address). ([Read more here](https://privacysavvy.com/security/safe-browsing/disable-webrtc-chrome-firefox-safari-opera-edge/)).
|
||||||
|
>
|
||||||
|
> **Warning:** \
|
||||||
|
> All traffic sent between devices using this fallback
|
||||||
|
> is routed through the server and therefor not peer to peer!
|
||||||
|
>
|
||||||
|
> Beware that the traffic routed via this fallback is readable by the server. \
|
||||||
|
> Only ever use this on instances you can trust.
|
||||||
|
>
|
||||||
|
> Additionally, beware that all traffic using this fallback debits the servers data plan.
|
||||||
|
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### Specify STUN/TURN Servers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RTC_CONFIG="rtc_config.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Default: `false`
|
||||||
|
>
|
||||||
|
> Specify the STUN/TURN servers PairDrop clients use by setting \
|
||||||
|
> `RTC_CONFIG` to a JSON file including the configuration. \
|
||||||
|
> You can use `rtc_config_example.json` as a starting point.
|
||||||
|
>
|
||||||
|
> To host your own TURN server you can follow this guide: https://gabrieltanner.org/blog/turn-server/
|
||||||
|
> Alternatively, use a free, pre-configured TURN server like [OpenRelay](<[url](https://www.metered.ca/tools/openrelay/)>)
|
||||||
>
|
>
|
||||||
> Default configuration:
|
> Default configuration:
|
||||||
|
>
|
||||||
> ```json
|
> ```json
|
||||||
> {
|
> {
|
||||||
> "sdpSemantics": "unified-plan",
|
> "sdpSemantics": "unified-plan",
|
||||||
|
@ -273,109 +395,51 @@ $env:RTC_CONFIG="rtc_config.json"; npm start
|
||||||
> }
|
> }
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
#### Debug Mode
|
|
||||||
On Unix based systems
|
|
||||||
```bash
|
|
||||||
DEBUG_MODE="true" npm start
|
|
||||||
```
|
|
||||||
On Windows
|
|
||||||
```bash
|
|
||||||
$env:DEBUG_MODE="true"; npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
> Use this flag to enable debugging info about the connecting peers IP addresses. \
|
|
||||||
> This is quite useful to check whether the [#HTTP-Server](#http-server) \
|
|
||||||
> is configured correctly, so the auto discovery feature works correctly. \
|
|
||||||
> Otherwise, all clients discover each other mutually, independently of their network status.
|
|
||||||
>
|
|
||||||
> If this flag is set to `"true"` each peer that connects to the \
|
|
||||||
> PairDrop server will produce a log to STDOUT like this:
|
|
||||||
> ```
|
|
||||||
> ----DEBUGGING-PEER-IP-START----
|
|
||||||
> remoteAddress: ::ffff:172.17.0.1
|
|
||||||
> x-forwarded-for: 19.117.63.126
|
|
||||||
> cf-connecting-ip: undefined
|
|
||||||
> PairDrop uses: 19.117.63.126
|
|
||||||
> IP is private: false
|
|
||||||
> if IP is private, '127.0.0.1' is used instead
|
|
||||||
> ----DEBUGGING-PEER-IP-END----
|
|
||||||
> ```
|
|
||||||
> If the IP PairDrop uses is the public IP of your device everything is set up correctly. \
|
|
||||||
>Find your devices public IP by visiting https://www.whatismyip.com/.
|
|
||||||
>
|
|
||||||
> Preserve your clients' privacy. **Never use this flag in production!**
|
|
||||||
|
|
||||||
|
|
||||||
### Options / Flags
|
|
||||||
#### Local Run
|
|
||||||
```bash
|
|
||||||
npm start -- --localhost-only
|
|
||||||
```
|
|
||||||
> Only allow connections from localhost.
|
|
||||||
>
|
|
||||||
> You must use a server proxy to set the X-Forwarded-For \
|
|
||||||
> to prevent all clients from discovering each other (See [#HTTP-Server](#http-server)).
|
|
||||||
>
|
|
||||||
> Use this when deploying PairDrop with node to prevent \
|
|
||||||
> bypassing the proxy by reaching the Docker container directly.
|
|
||||||
|
|
||||||
#### Automatic restart on error
|
|
||||||
```bash
|
|
||||||
npm start -- --auto-restart
|
|
||||||
```
|
|
||||||
> Restarts server automatically on error
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
#### Rate limiting requests
|
You can host an instance that uses another signaling server
|
||||||
```bash
|
This can be useful if you don't want to trust the client files that are hosted on another instance but still want to connect to devices that use https://pairdrop.net.
|
||||||
npm start -- --rate-limit
|
### Host Websocket Server (for VPN)
|
||||||
```
|
|
||||||
> Limits clients to 1000 requests per 5 min
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SIGNALING_SERVER="pairdrop.net"
|
||||||
|
```
|
||||||
|
|
||||||
|
> Default: `false`
|
||||||
|
>
|
||||||
|
> By default, clients connecting to your instance use the signaling server of your instance to connect to other devices.
|
||||||
|
>
|
||||||
|
> By using `SIGNALING_SERVER`, you can host an instance that uses another signaling server.
|
||||||
|
>
|
||||||
|
> This can be useful if you want to ensure the integrity of the client files and don't want to trust the client files that are hosted on another PairDrop instance but still want to connect to devices that use the other instance.
|
||||||
|
> E.g. host your own client files under *pairdrop.your-domain.com* but use the official signaling server under *pairdrop.net*
|
||||||
|
> This way devices connecting to *pairdrop.your-domain.com* and *pairdrop.net* can discover each other.
|
||||||
|
>
|
||||||
|
> Beware that the version of your PairDrop server is compatible with the version of the signaling server.
|
||||||
|
>
|
||||||
|
> `WS_SERVER` must be a valid url without the protocol prefix.
|
||||||
|
> Examples of valid values: `pairdrop.net`, `pairdrop.your-domain.com:3000`, `your-domain.com/pairdrop`
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
#### Websocket Fallback (for VPN)
|
## Healthcheck
|
||||||
```bash
|
|
||||||
npm start -- --include-ws-fallback
|
> The Docker Image hosted on `ghcr.io` and the self-built Docker Image include a healthcheck.
|
||||||
```
|
|
||||||
> Provides PairDrop to clients with an included websocket fallback \
|
|
||||||
> if the peer to peer WebRTC connection is not available to the client.
|
|
||||||
>
|
>
|
||||||
> This is not used on the official https://pairdrop.net, \
|
> Read more about [Docker Swarm Usage](docker-swarm-usage.md#docker-swarm-usage).
|
||||||
but you can activate it on your self-hosted instance. \
|
|
||||||
> This is especially useful if you connect to your instance \
|
|
||||||
> via a VPN as most VPN services block WebRTC completely in order to hide your real IP address.
|
|
||||||
> ([Read more](https://privacysavvy.com/security/safe-browsing/disable-webrtc-chrome-firefox-safari-opera-edge/)).
|
|
||||||
>
|
|
||||||
> **Warning:** All traffic sent between devices using this fallback \
|
|
||||||
> is routed through the server and therefor not peer to peer! \
|
|
||||||
> Beware that the traffic routed via this fallback is readable by the server. \
|
|
||||||
> Only ever use this on instances you can trust. \
|
|
||||||
> Additionally, beware that all traffic using this fallback debits the servers data plan.
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
#### Production (autostart and rate-limit)
|
|
||||||
```bash
|
|
||||||
npm run start:prod
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Production (autostart, rate-limit, localhost-only and websocket fallback for VPN)
|
|
||||||
```bash
|
|
||||||
npm run start:prod -- --localhost-only --include-ws-fallback
|
|
||||||
```
|
|
||||||
> To prevent connections to the node server from bypassing \
|
|
||||||
> the proxy server you should always use "--localhost-only" on production.
|
|
||||||
|
|
||||||
## HTTP-Server
|
## HTTP-Server
|
||||||
|
|
||||||
When running PairDrop, the `X-Forwarded-For` header has to be set by a proxy. \
|
When running PairDrop, the `X-Forwarded-For` header has to be set by a proxy. \
|
||||||
Otherwise, all clients will be mutually visible.
|
Otherwise, all clients will be mutually visible.
|
||||||
|
|
||||||
To check if your setup is configured correctly [use the environment variable `DEBUG_MODE="true"`](#debug-mode).
|
To check if your setup is configured correctly [use the environment variable `DEBUG_MODE="true"`](#debug-mode).
|
||||||
|
|
||||||
### Using nginx
|
### Using nginx
|
||||||
|
|
||||||
#### Allow http and https requests
|
#### Allow http and https requests
|
||||||
|
|
||||||
```
|
```
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
@ -409,6 +473,7 @@ server {
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Automatic http to https redirect:
|
#### Automatic http to https redirect:
|
||||||
|
|
||||||
```
|
```
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
@ -437,14 +502,21 @@ server {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
### Using Apache
|
### Using Apache
|
||||||
|
|
||||||
install modules `proxy`, `proxy_http`, `mod_proxy_wstunnel`
|
install modules `proxy`, `proxy_http`, `mod_proxy_wstunnel`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
a2enmod proxy
|
a2enmod proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
a2enmod proxy_http
|
a2enmod proxy_http
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
a2enmod proxy_wstunnel
|
a2enmod proxy_wstunnel
|
||||||
```
|
```
|
||||||
|
@ -454,7 +526,9 @@ a2enmod proxy_wstunnel
|
||||||
Create a new configuration file under `/etc/apache2/sites-available` (on Debian)
|
Create a new configuration file under `/etc/apache2/sites-available` (on Debian)
|
||||||
|
|
||||||
**pairdrop.conf**
|
**pairdrop.conf**
|
||||||
|
|
||||||
#### Allow HTTP and HTTPS requests
|
#### Allow HTTP and HTTPS requests
|
||||||
|
|
||||||
```apacheconf
|
```apacheconf
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
ProxyPass / http://127.0.0.1:3000/
|
ProxyPass / http://127.0.0.1:3000/
|
||||||
|
@ -471,7 +545,9 @@ Create a new configuration file under `/etc/apache2/sites-available` (on Debian)
|
||||||
RewriteRule ^/?(.*) "wws://127.0.0.1:3000/$1" [P,L]
|
RewriteRule ^/?(.*) "wws://127.0.0.1:3000/$1" [P,L]
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Automatic HTTP to HTTPS redirect:
|
#### Automatic HTTP to HTTPS redirect:
|
||||||
|
|
||||||
```apacheconf
|
```apacheconf
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
Redirect permanent / https://127.0.0.1:3000/
|
Redirect permanent / https://127.0.0.1:3000/
|
||||||
|
@ -484,62 +560,120 @@ Create a new configuration file under `/etc/apache2/sites-available` (on Debian)
|
||||||
RewriteRule ^/?(.*) "wws://127.0.0.1:3000/$1" [P,L]
|
RewriteRule ^/?(.*) "wws://127.0.0.1:3000/$1" [P,L]
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
|
||||||
Activate the new virtual host and reload Apache:
|
Activate the new virtual host and reload Apache:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
a2ensite pairdrop
|
a2ensite pairdrop
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
service apache2 reload
|
service apache2 reload
|
||||||
```
|
```
|
||||||
|
|
||||||
# Local Development
|
<br>
|
||||||
## Install
|
|
||||||
|
## Coturn and PairDrop via Docker Compose
|
||||||
|
|
||||||
|
### Setup container
|
||||||
|
To run coturn and PairDrop at once by using the `docker-compose-coturn.yml` with TURN over TLS enabled
|
||||||
|
you need to follow these steps:
|
||||||
|
|
||||||
|
1. Generate or retrieve certificates for your `<DOMAIN>` (e.g. letsencrypt / certbot)
|
||||||
|
2. Create `./ssl` folder: `mkdir ssl`
|
||||||
|
3. Copy your ssl-certificates and the privkey to `./ssl`
|
||||||
|
4. Restrict access to `./ssl`: `chown -R nobody:nogroup ./ssl`
|
||||||
|
5. Create a dh-params file: `openssl dhparam -out ./ssl/dhparams.pem 4096`
|
||||||
|
6. Copy `rtc_config_example.json` to `rtc_config.json`
|
||||||
|
7. Copy `turnserver_example.conf` to `turnserver.conf`
|
||||||
|
8. Change `<DOMAIN>` in both files to the domain where your PairDrop instance is running
|
||||||
|
9. Change `username` and `password` in `turnserver.conf` and `rtc-config.json`
|
||||||
|
10. To start the container including coturn run: \
|
||||||
|
`docker compose -f docker-compose-coturn.yml up -d`
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
#### Setup container
|
||||||
|
To restart the container including coturn run: \
|
||||||
|
`docker compose -f docker-compose-coturn.yml restart`
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
#### Setup container
|
||||||
|
To stop the container including coturn run: \
|
||||||
|
`docker compose -f docker-compose-coturn.yml stop`
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### Firewall
|
||||||
|
To run PairDrop including its own coturn-server you need to punch holes in the firewall. These ports must be opened additionally:
|
||||||
|
- 3478 tcp/udp
|
||||||
|
- 5349 tcp/udp
|
||||||
|
- 10000:20000 tcp/udp
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### Firewall
|
||||||
|
To run PairDrop including its own coturn-server you need to punch holes in the firewall. These ports must be opened additionally:
|
||||||
|
- 3478 tcp/udp
|
||||||
|
- 5349 tcp/udp
|
||||||
|
- 10000:20000 tcp/udp
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
All files needed for developing are available on the branch `dev`.
|
All files needed for developing are available on the branch `dev`.
|
||||||
|
|
||||||
First, [Install docker with docker-compose.](https://docs.docker.com/compose/install/)
|
First, [Install docker with docker-compose.](https://docs.docker.com/compose/install/)
|
||||||
|
|
||||||
Then, clone the repository and run docker-compose:
|
Then, clone the repository and run docker-compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/schlagmichdoch/PairDrop.git
|
git clone https://github.com/schlagmichdoch/PairDrop.git && cd PairDrop
|
||||||
|
|
||||||
cd PairDrop
|
|
||||||
|
|
||||||
git checkout dev
|
|
||||||
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
```
|
||||||
|
```bash
|
||||||
|
git checkout dev
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose-dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
Now point your web browser to `http://localhost:8080`.
|
Now point your web browser to `http://localhost:8080`.
|
||||||
|
|
||||||
- To restart the containers, run `docker-compose restart`.
|
- To restart the containers, run `docker compose restart`.
|
||||||
- To stop the containers, run `docker-compose stop`.
|
- To stop the containers, run `docker compose stop`.
|
||||||
- To debug the NodeJS server, run `docker logs pairdrop_node_1`.
|
- To debug the Node.js server, run `docker logs pairdrop`.
|
||||||
|
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
## Testing PWA related features
|
### Testing PWA related features
|
||||||
|
|
||||||
PWAs requires the app to be served under a correctly set up and trusted TLS endpoint.
|
PWAs requires the app to be served under a correctly set up and trusted TLS endpoint.
|
||||||
|
|
||||||
The NGINX container creates a CA certificate and a website certificate for you. \
|
The NGINX container creates a CA certificate and a website certificate for you.
|
||||||
To correctly set the common name of the certificate, \
|
To correctly set the common name of the certificate,
|
||||||
you need to change the FQDN environment variable in `docker/fqdn.env` \
|
you need to change the FQDN environment variable in `docker/fqdn.env`
|
||||||
to the fully qualified domain name of your workstation.
|
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. \
|
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`. \
|
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. \
|
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`, \
|
- On Windows, make sure to install it to the `Trusted Root Certification Authorities` store.
|
||||||
- expand `Trust`, and select `Always Trust` for SSL. \
|
- On macOS, double-click the installed CA certificate in `Keychain Access`,
|
||||||
- Firefox uses its own trust store. To install the CA, \
|
- expand `Trust`, and select `Always Trust` for SSL.
|
||||||
- point Firefox at `http://<Your FQDN>:8080/ca.crt`. \
|
- Firefox uses its own trust store. To install the CA,
|
||||||
- When prompted, select `Trust this CA to identify websites` and click *OK*. \
|
- point Firefox at `http://<Your FQDN>:8080/ca.crt`.
|
||||||
- When using Chrome, you need to restart Chrome so it reloads the trust store (`chrome://restart`). \
|
- When prompted, select `Trust this CA to identify websites` and click _OK_.
|
||||||
- Additionally, after installing a new cert, \
|
- When using Chrome, you need to restart Chrome so it reloads the trust store (`chrome://restart`).
|
||||||
- you need to clear the Storage (DevTools → Application → Clear storage → Clear site data).
|
- 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.
|
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.
|
Also, whenever you restart the NGINX Docker container new certificates are created.
|
||||||
|
|
||||||
The site is served on `https://<Your FQDN>:8443`.
|
The site is served on `https://<Your FQDN>:8443`.
|
||||||
|
|
||||||
|
|
852
index.js
|
@ -1,852 +0,0 @@
|
||||||
const process = require('process')
|
|
||||||
const crypto = require('crypto')
|
|
||||||
const {spawn} = require('child_process')
|
|
||||||
const WebSocket = require('ws');
|
|
||||||
const fs = require('fs');
|
|
||||||
const parser = require('ua-parser-js');
|
|
||||||
const { uniqueNamesGenerator, animals, colors } = require('unique-names-generator');
|
|
||||||
const express = require('express');
|
|
||||||
const RateLimit = require('express-rate-limit');
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle APP ERRORS
|
|
||||||
process.on('uncaughtException', (error, origin) => {
|
|
||||||
console.log('----- Uncaught exception -----')
|
|
||||||
console.log(error)
|
|
||||||
console.log('----- Exception origin -----')
|
|
||||||
console.log(origin)
|
|
||||||
})
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
|
||||||
console.log('----- Unhandled Rejection at -----')
|
|
||||||
console.log(promise)
|
|
||||||
console.log('----- Reason -----')
|
|
||||||
console.log(reason)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (process.argv.includes('--auto-restart')) {
|
|
||||||
process.on(
|
|
||||||
'uncaughtException',
|
|
||||||
() => {
|
|
||||||
process.once(
|
|
||||||
'exit',
|
|
||||||
() => spawn(
|
|
||||||
process.argv.shift(),
|
|
||||||
process.argv,
|
|
||||||
{
|
|
||||||
cwd: process.cwd(),
|
|
||||||
detached: true,
|
|
||||||
stdio: 'inherit'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
process.exit();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rtcConfig = process.env.RTC_CONFIG
|
|
||||||
? JSON.parse(fs.readFileSync(process.env.RTC_CONFIG, 'utf8'))
|
|
||||||
: {
|
|
||||||
"sdpSemantics": "unified-plan",
|
|
||||||
"iceServers": [
|
|
||||||
{
|
|
||||||
"urls": "stun:stun.l.google.com:19302"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
if (process.argv.includes('--rate-limit')) {
|
|
||||||
const limiter = RateLimit({
|
|
||||||
windowMs: 5 * 60 * 1000, // 5 minutes
|
|
||||||
max: 1000, // Limit each IP to 1000 requests per `window` (here, per 5 minutes)
|
|
||||||
message: 'Too many requests from this IP Address, please try again after 5 minutes.',
|
|
||||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
|
||||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
|
||||||
})
|
|
||||||
|
|
||||||
app.use(limiter);
|
|
||||||
// ensure correct client ip and not the ip of the reverse proxy is used for rate limiting on render.com
|
|
||||||
// see https://github.com/express-rate-limit/express-rate-limit#troubleshooting-proxy-issues
|
|
||||||
app.set('trust proxy', 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv.includes('--include-ws-fallback')) {
|
|
||||||
app.use(express.static('public_included_ws_fallback'));
|
|
||||||
} else {
|
|
||||||
app.use(express.static('public'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const debugMode = process.env.DEBUG_MODE === "true";
|
|
||||||
|
|
||||||
if (debugMode) {
|
|
||||||
console.log("DEBUG_MODE is active. To protect privacy, do not use in production.")
|
|
||||||
}
|
|
||||||
|
|
||||||
let ipv6_lcl;
|
|
||||||
if (process.env.IPV6_LOCALIZE) {
|
|
||||||
ipv6_lcl = parseInt(process.env.IPV6_LOCALIZE);
|
|
||||||
if (!ipv6_lcl || !(0 < ipv6_lcl && ipv6_lcl < 8)) {
|
|
||||||
console.error("IPV6_LOCALIZE must be an integer between 1 and 7");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("IPv6 client IPs will be localized to", ipv6_lcl, ipv6_lcl === 1 ? "segment" : "segments");
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use(function(req, res) {
|
|
||||||
res.redirect('/');
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
|
||||||
res.sendFile('index.html');
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = http.createServer(app);
|
|
||||||
const port = process.env.PORT || 3000;
|
|
||||||
|
|
||||||
if (process.argv.includes('--localhost-only')) {
|
|
||||||
server.listen(port, '127.0.0.1');
|
|
||||||
} else {
|
|
||||||
server.listen(port);
|
|
||||||
}
|
|
||||||
|
|
||||||
server.on('error', (err) => {
|
|
||||||
if (err.code === 'EADDRINUSE') {
|
|
||||||
console.error(err);
|
|
||||||
console.info("Error EADDRINUSE received, exiting process without restarting process...");
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
class PairDropServer {
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this._wss = new WebSocket.Server({ server });
|
|
||||||
this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request)));
|
|
||||||
|
|
||||||
this._rooms = {}; // { roomId: peers[] }
|
|
||||||
this._roomSecrets = {}; // { pairKey: roomSecret }
|
|
||||||
|
|
||||||
this._keepAliveTimers = {};
|
|
||||||
|
|
||||||
console.log('PairDrop is running on port', port);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onConnection(peer) {
|
|
||||||
peer.socket.on('message', message => this._onMessage(peer, message));
|
|
||||||
peer.socket.onerror = e => console.error(e);
|
|
||||||
|
|
||||||
this._keepAlive(peer);
|
|
||||||
|
|
||||||
this._send(peer, {
|
|
||||||
type: 'rtc-config',
|
|
||||||
config: rtcConfig
|
|
||||||
});
|
|
||||||
|
|
||||||
// send displayName
|
|
||||||
this._send(peer, {
|
|
||||||
type: 'display-name',
|
|
||||||
message: {
|
|
||||||
displayName: peer.name.displayName,
|
|
||||||
deviceName: peer.name.deviceName,
|
|
||||||
peerId: peer.id,
|
|
||||||
peerIdHash: hasher.hashCodeSalted(peer.id)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_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._onDisconnect(sender);
|
|
||||||
break;
|
|
||||||
case 'pong':
|
|
||||||
this._setKeepAliveTimerToNow(sender);
|
|
||||||
break;
|
|
||||||
case 'join-ip-room':
|
|
||||||
this._joinIpRoom(sender);
|
|
||||||
break;
|
|
||||||
case 'room-secrets':
|
|
||||||
this._onRoomSecrets(sender, message);
|
|
||||||
break;
|
|
||||||
case 'room-secrets-deleted':
|
|
||||||
this._onRoomSecretsDeleted(sender, message);
|
|
||||||
break;
|
|
||||||
case 'pair-device-initiate':
|
|
||||||
this._onPairDeviceInitiate(sender);
|
|
||||||
break;
|
|
||||||
case 'pair-device-join':
|
|
||||||
this._onPairDeviceJoin(sender, message);
|
|
||||||
break;
|
|
||||||
case 'pair-device-cancel':
|
|
||||||
this._onPairDeviceCancel(sender);
|
|
||||||
break;
|
|
||||||
case 'regenerate-room-secret':
|
|
||||||
this._onRegenerateRoomSecret(sender, message);
|
|
||||||
break;
|
|
||||||
case 'create-public-room':
|
|
||||||
this._onCreatePublicRoom(sender);
|
|
||||||
break;
|
|
||||||
case 'join-public-room':
|
|
||||||
this._onJoinPublicRoom(sender, message);
|
|
||||||
break;
|
|
||||||
case 'leave-public-room':
|
|
||||||
this._onLeavePublicRoom(sender);
|
|
||||||
break;
|
|
||||||
case 'signal':
|
|
||||||
default:
|
|
||||||
this._signalAndRelay(sender, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_signalAndRelay(sender, message) {
|
|
||||||
const room = message.roomType === 'ip'
|
|
||||||
? sender.ip
|
|
||||||
: message.roomId;
|
|
||||||
|
|
||||||
// relay message to recipient
|
|
||||||
if (message.to && Peer.isValidUuid(message.to) && this._rooms[room]) {
|
|
||||||
const recipient = this._rooms[room][message.to];
|
|
||||||
delete message.to;
|
|
||||||
// add sender
|
|
||||||
message.sender = {
|
|
||||||
id: sender.id,
|
|
||||||
rtcSupported: sender.rtcSupported
|
|
||||||
};
|
|
||||||
this._send(recipient, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onDisconnect(sender) {
|
|
||||||
this._disconnect(sender);
|
|
||||||
}
|
|
||||||
|
|
||||||
_disconnect(sender) {
|
|
||||||
this._removePairKey(sender.pairKey);
|
|
||||||
sender.pairKey = null;
|
|
||||||
|
|
||||||
this._cancelKeepAlive(sender);
|
|
||||||
delete this._keepAliveTimers[sender.id];
|
|
||||||
|
|
||||||
this._leaveIpRoom(sender, true);
|
|
||||||
this._leaveAllSecretRooms(sender, true);
|
|
||||||
this._leavePublicRoom(sender, true);
|
|
||||||
|
|
||||||
sender.socket.terminate();
|
|
||||||
}
|
|
||||||
|
|
||||||
_onRoomSecrets(sender, message) {
|
|
||||||
if (!message.roomSecrets) return;
|
|
||||||
|
|
||||||
const roomSecrets = message.roomSecrets.filter(roomSecret => {
|
|
||||||
return /^[\x00-\x7F]{64,256}$/.test(roomSecret);
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!roomSecrets) return;
|
|
||||||
|
|
||||||
this._joinSecretRooms(sender, roomSecrets);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onRoomSecretsDeleted(sender, message) {
|
|
||||||
for (let i = 0; i<message.roomSecrets.length; i++) {
|
|
||||||
this._deleteSecretRoom(message.roomSecrets[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_deleteSecretRoom(roomSecret) {
|
|
||||||
const room = this._rooms[roomSecret];
|
|
||||||
if (!room) return;
|
|
||||||
|
|
||||||
for (const peerId in room) {
|
|
||||||
const peer = room[peerId];
|
|
||||||
|
|
||||||
this._leaveSecretRoom(peer, roomSecret, true);
|
|
||||||
|
|
||||||
this._send(peer, {
|
|
||||||
type: 'secret-room-deleted',
|
|
||||||
roomSecret: roomSecret,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onPairDeviceInitiate(sender) {
|
|
||||||
let roomSecret = randomizer.getRandomString(256);
|
|
||||||
let pairKey = this._createPairKey(sender, roomSecret);
|
|
||||||
|
|
||||||
if (sender.pairKey) {
|
|
||||||
this._removePairKey(sender.pairKey);
|
|
||||||
}
|
|
||||||
sender.pairKey = pairKey;
|
|
||||||
|
|
||||||
this._send(sender, {
|
|
||||||
type: 'pair-device-initiated',
|
|
||||||
roomSecret: roomSecret,
|
|
||||||
pairKey: pairKey
|
|
||||||
});
|
|
||||||
this._joinSecretRoom(sender, roomSecret);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onPairDeviceJoin(sender, message) {
|
|
||||||
if (sender.rateLimitReached()) {
|
|
||||||
this._send(sender, { type: 'join-key-rate-limit' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this._roomSecrets[message.pairKey] || sender.id === this._roomSecrets[message.pairKey].creator.id) {
|
|
||||||
this._send(sender, { type: 'pair-device-join-key-invalid' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomSecret = this._roomSecrets[message.pairKey].roomSecret;
|
|
||||||
const creator = this._roomSecrets[message.pairKey].creator;
|
|
||||||
this._removePairKey(message.pairKey);
|
|
||||||
this._send(sender, {
|
|
||||||
type: 'pair-device-joined',
|
|
||||||
roomSecret: roomSecret,
|
|
||||||
peerId: creator.id
|
|
||||||
});
|
|
||||||
this._send(creator, {
|
|
||||||
type: 'pair-device-joined',
|
|
||||||
roomSecret: roomSecret,
|
|
||||||
peerId: sender.id
|
|
||||||
});
|
|
||||||
this._joinSecretRoom(sender, roomSecret);
|
|
||||||
this._removePairKey(sender.pairKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onPairDeviceCancel(sender) {
|
|
||||||
const pairKey = sender.pairKey
|
|
||||||
|
|
||||||
if (!pairKey) return;
|
|
||||||
|
|
||||||
this._removePairKey(pairKey);
|
|
||||||
this._send(sender, {
|
|
||||||
type: 'pair-device-canceled',
|
|
||||||
pairKey: pairKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_onCreatePublicRoom(sender) {
|
|
||||||
let publicRoomId = randomizer.getRandomString(5, true).toLowerCase();
|
|
||||||
|
|
||||||
this._send(sender, {
|
|
||||||
type: 'public-room-created',
|
|
||||||
roomId: publicRoomId
|
|
||||||
});
|
|
||||||
|
|
||||||
this._joinPublicRoom(sender, publicRoomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onJoinPublicRoom(sender, message) {
|
|
||||||
if (sender.rateLimitReached()) {
|
|
||||||
this._send(sender, { type: 'join-key-rate-limit' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this._rooms[message.publicRoomId] && !message.createIfInvalid) {
|
|
||||||
this._send(sender, { type: 'public-room-id-invalid', publicRoomId: message.publicRoomId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._leavePublicRoom(sender);
|
|
||||||
this._joinPublicRoom(sender, message.publicRoomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onLeavePublicRoom(sender) {
|
|
||||||
this._leavePublicRoom(sender, true);
|
|
||||||
this._send(sender, { type: 'public-room-left' });
|
|
||||||
}
|
|
||||||
|
|
||||||
_onRegenerateRoomSecret(sender, message) {
|
|
||||||
const oldRoomSecret = message.roomSecret;
|
|
||||||
const newRoomSecret = randomizer.getRandomString(256);
|
|
||||||
|
|
||||||
// notify all other peers
|
|
||||||
for (const peerId in this._rooms[oldRoomSecret]) {
|
|
||||||
const peer = this._rooms[oldRoomSecret][peerId];
|
|
||||||
this._send(peer, {
|
|
||||||
type: 'room-secret-regenerated',
|
|
||||||
oldRoomSecret: oldRoomSecret,
|
|
||||||
newRoomSecret: newRoomSecret,
|
|
||||||
});
|
|
||||||
peer.removeRoomSecret(oldRoomSecret);
|
|
||||||
}
|
|
||||||
delete this._rooms[oldRoomSecret];
|
|
||||||
}
|
|
||||||
|
|
||||||
_createPairKey(creator, roomSecret) {
|
|
||||||
let pairKey;
|
|
||||||
do {
|
|
||||||
// get randomInt until keyRoom not occupied
|
|
||||||
pairKey = crypto.randomInt(1000000, 1999999).toString().substring(1); // include numbers with leading 0s
|
|
||||||
} while (pairKey in this._roomSecrets)
|
|
||||||
|
|
||||||
this._roomSecrets[pairKey] = {
|
|
||||||
roomSecret: roomSecret,
|
|
||||||
creator: creator
|
|
||||||
}
|
|
||||||
|
|
||||||
return pairKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
_removePairKey(roomKey) {
|
|
||||||
if (roomKey in this._roomSecrets) {
|
|
||||||
this._roomSecrets[roomKey].creator.roomKey = null
|
|
||||||
delete this._roomSecrets[roomKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_joinIpRoom(peer) {
|
|
||||||
this._joinRoom(peer, 'ip', peer.ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
_joinSecretRoom(peer, roomSecret) {
|
|
||||||
this._joinRoom(peer, 'secret', roomSecret);
|
|
||||||
|
|
||||||
// add secret to peer
|
|
||||||
peer.addRoomSecret(roomSecret);
|
|
||||||
}
|
|
||||||
|
|
||||||
_joinPublicRoom(peer, publicRoomId) {
|
|
||||||
// prevent joining of 2 public rooms simultaneously
|
|
||||||
this._leavePublicRoom(peer);
|
|
||||||
|
|
||||||
this._joinRoom(peer, 'public-id', publicRoomId);
|
|
||||||
|
|
||||||
peer.publicRoomId = publicRoomId;
|
|
||||||
}
|
|
||||||
|
|
||||||
_joinRoom(peer, roomType, roomId) {
|
|
||||||
// roomType: 'ip', 'secret' or 'public-id'
|
|
||||||
if (this._rooms[roomId] && this._rooms[roomId][peer.id]) {
|
|
||||||
// ensures that otherPeers never receive `peer-left` after `peer-joined` on reconnect.
|
|
||||||
this._leaveRoom(peer, roomType, roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if room doesn't exist, create it
|
|
||||||
if (!this._rooms[roomId]) {
|
|
||||||
this._rooms[roomId] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
this._notifyPeers(peer, roomType, roomId);
|
|
||||||
|
|
||||||
// add peer to room
|
|
||||||
this._rooms[roomId][peer.id] = peer;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_leaveIpRoom(peer, disconnect = false) {
|
|
||||||
this._leaveRoom(peer, 'ip', peer.ip, disconnect);
|
|
||||||
}
|
|
||||||
|
|
||||||
_leaveSecretRoom(peer, roomSecret, disconnect = false) {
|
|
||||||
this._leaveRoom(peer, 'secret', roomSecret, disconnect)
|
|
||||||
|
|
||||||
//remove secret from peer
|
|
||||||
peer.removeRoomSecret(roomSecret);
|
|
||||||
}
|
|
||||||
|
|
||||||
_leavePublicRoom(peer, disconnect = false) {
|
|
||||||
if (!peer.publicRoomId) return;
|
|
||||||
|
|
||||||
this._leaveRoom(peer, 'public-id', peer.publicRoomId, disconnect);
|
|
||||||
|
|
||||||
peer.publicRoomId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_leaveRoom(peer, roomType, roomId, disconnect = false) {
|
|
||||||
if (!this._rooms[roomId] || !this._rooms[roomId][peer.id]) return;
|
|
||||||
|
|
||||||
// remove peer from room
|
|
||||||
delete this._rooms[roomId][peer.id];
|
|
||||||
|
|
||||||
// delete room if empty and abort
|
|
||||||
if (!Object.keys(this._rooms[roomId]).length) {
|
|
||||||
delete this._rooms[roomId];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// notify all other peers that remain in room that peer left
|
|
||||||
for (const otherPeerId in this._rooms[roomId]) {
|
|
||||||
const otherPeer = this._rooms[roomId][otherPeerId];
|
|
||||||
|
|
||||||
let msg = {
|
|
||||||
type: 'peer-left',
|
|
||||||
peerId: peer.id,
|
|
||||||
roomType: roomType,
|
|
||||||
roomId: roomId,
|
|
||||||
disconnect: disconnect
|
|
||||||
};
|
|
||||||
|
|
||||||
this._send(otherPeer, msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_notifyPeers(peer, roomType, roomId) {
|
|
||||||
if (!this._rooms[roomId]) return;
|
|
||||||
|
|
||||||
// notify all other peers that peer joined
|
|
||||||
for (const otherPeerId in this._rooms[roomId]) {
|
|
||||||
if (otherPeerId === peer.id) continue;
|
|
||||||
const otherPeer = this._rooms[roomId][otherPeerId];
|
|
||||||
|
|
||||||
let msg = {
|
|
||||||
type: 'peer-joined',
|
|
||||||
peer: peer.getInfo(),
|
|
||||||
roomType: roomType,
|
|
||||||
roomId: roomId
|
|
||||||
};
|
|
||||||
|
|
||||||
this._send(otherPeer, msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// notify peer about peers already in the room
|
|
||||||
const otherPeers = [];
|
|
||||||
for (const otherPeerId in this._rooms[roomId]) {
|
|
||||||
if (otherPeerId === peer.id) continue;
|
|
||||||
otherPeers.push(this._rooms[roomId][otherPeerId].getInfo());
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = {
|
|
||||||
type: 'peers',
|
|
||||||
peers: otherPeers,
|
|
||||||
roomType: roomType,
|
|
||||||
roomId: roomId
|
|
||||||
};
|
|
||||||
|
|
||||||
this._send(peer, msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
_joinSecretRooms(peer, roomSecrets) {
|
|
||||||
for (let i=0; i<roomSecrets.length; i++) {
|
|
||||||
this._joinSecretRoom(peer, roomSecrets[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_leaveAllSecretRooms(peer, disconnect = false) {
|
|
||||||
for (let i=0; i<peer.roomSecrets.length; i++) {
|
|
||||||
this._leaveSecretRoom(peer, peer.roomSecrets[i], disconnect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_send(peer, message) {
|
|
||||||
if (!peer) return;
|
|
||||||
if (this._wss.readyState !== this._wss.OPEN) return;
|
|
||||||
message = JSON.stringify(message);
|
|
||||||
peer.socket.send(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
_keepAlive(peer) {
|
|
||||||
this._cancelKeepAlive(peer);
|
|
||||||
let timeout = 1000;
|
|
||||||
|
|
||||||
if (!this._keepAliveTimers[peer.id]) {
|
|
||||||
this._keepAliveTimers[peer.id] = {
|
|
||||||
timer: 0,
|
|
||||||
lastBeat: Date.now()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now() - this._keepAliveTimers[peer.id].lastBeat > 5 * timeout) {
|
|
||||||
// Disconnect peer if unresponsive for 10s
|
|
||||||
this._disconnect(peer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._send(peer, { type: 'ping' });
|
|
||||||
|
|
||||||
this._keepAliveTimers[peer.id].timer = setTimeout(() => this._keepAlive(peer), timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
_cancelKeepAlive(peer) {
|
|
||||||
if (this._keepAliveTimers[peer.id]?.timer) {
|
|
||||||
clearTimeout(this._keepAliveTimers[peer.id].timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_setKeepAliveTimerToNow(peer) {
|
|
||||||
if (this._keepAliveTimers[peer.id]?.lastBeat) {
|
|
||||||
this._keepAliveTimers[peer.id].lastBeat = Date.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
this.requestRate = 0;
|
|
||||||
|
|
||||||
this.roomSecrets = [];
|
|
||||||
this.roomKey = null;
|
|
||||||
|
|
||||||
this.publicRoomId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
rateLimitReached() {
|
|
||||||
// rate limit implementation: max 10 attempts every 10s
|
|
||||||
if (this.requestRate >= 10) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
this.requestRate += 1;
|
|
||||||
setTimeout(_ => this.requestRate -= 1, 10000);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_setIP(request) {
|
|
||||||
if (request.headers['cf-connecting-ip']) {
|
|
||||||
this.ip = request.headers['cf-connecting-ip'].split(/\s*,\s*/)[0];
|
|
||||||
} else if (request.headers['x-forwarded-for']) {
|
|
||||||
this.ip = request.headers['x-forwarded-for'].split(/\s*,\s*/)[0];
|
|
||||||
} else {
|
|
||||||
this.ip = request.connection.remoteAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove the prefix used for IPv4-translated addresses
|
|
||||||
if (this.ip.substring(0,7) === "::ffff:")
|
|
||||||
this.ip = this.ip.substring(7);
|
|
||||||
|
|
||||||
let ipv6_was_localized = false;
|
|
||||||
if (ipv6_lcl && this.ip.includes(':')) {
|
|
||||||
this.ip = this.ip.split(':',ipv6_lcl).join(':');
|
|
||||||
ipv6_was_localized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (debugMode) {
|
|
||||||
console.debug("----DEBUGGING-PEER-IP-START----");
|
|
||||||
console.debug("remoteAddress:", request.connection.remoteAddress);
|
|
||||||
console.debug("x-forwarded-for:", request.headers['x-forwarded-for']);
|
|
||||||
console.debug("cf-connecting-ip:", request.headers['cf-connecting-ip']);
|
|
||||||
if (ipv6_was_localized)
|
|
||||||
console.debug("IPv6 client IP was localized to", ipv6_lcl, ipv6_lcl > 1 ? "segments" : "segment");
|
|
||||||
console.debug("PairDrop uses:", this.ip);
|
|
||||||
console.debug("IP is private:", this.ipIsPrivate(this.ip));
|
|
||||||
console.debug("if IP is private, '127.0.0.1' is used instead");
|
|
||||||
console.debug("----DEBUGGING-PEER-IP-END----");
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPv4 and IPv6 use different values to refer to localhost
|
|
||||||
// put all peers on the same network as the server into the same room as well
|
|
||||||
if (this.ip === '::1' || this.ipIsPrivate(this.ip)) {
|
|
||||||
this.ip = '127.0.0.1';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ipIsPrivate(ip) {
|
|
||||||
// if ip is IPv4
|
|
||||||
if (!ip.includes(":")) {
|
|
||||||
// 10.0.0.0 - 10.255.255.255 || 172.16.0.0 - 172.31.255.255 || 192.168.0.0 - 192.168.255.255
|
|
||||||
return /^(10)\.(.*)\.(.*)\.(.*)$/.test(ip) || /^(172)\.(1[6-9]|2[0-9]|3[0-1])\.(.*)\.(.*)$/.test(ip) || /^(192)\.(168)\.(.*)\.(.*)$/.test(ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// else: ip is IPv6
|
|
||||||
const firstWord = ip.split(":").find(el => !!el); //get first not empty word
|
|
||||||
|
|
||||||
// The original IPv6 Site Local addresses (fec0::/10) are deprecated. Range: fec0 - feff
|
|
||||||
if (/^fe[c-f][0-f]$/.test(firstWord))
|
|
||||||
return true;
|
|
||||||
|
|
||||||
// These days Unique Local Addresses (ULA) are used in place of Site Local.
|
|
||||||
// Range: fc00 - fcff
|
|
||||||
else if (/^fc[0-f]{2}$/.test(firstWord))
|
|
||||||
return true;
|
|
||||||
|
|
||||||
// Range: fd00 - fcff
|
|
||||||
else if (/^fd[0-f]{2}$/.test(firstWord))
|
|
||||||
return true;
|
|
||||||
|
|
||||||
// Link local addresses (prefixed with fe80) are not routable
|
|
||||||
else if (firstWord === "fe80")
|
|
||||||
return true;
|
|
||||||
|
|
||||||
// Discard Prefix
|
|
||||||
else if (firstWord === "100")
|
|
||||||
return true;
|
|
||||||
|
|
||||||
// Any other IP address is not Unique Local Address (ULA)
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_setPeerId(request) {
|
|
||||||
const searchParams = new URL(request.url, "http://server").searchParams;
|
|
||||||
let peerId = searchParams.get("peer_id");
|
|
||||||
let peerIdHash = searchParams.get("peer_id_hash");
|
|
||||||
if (peerId && Peer.isValidUuid(peerId) && this.isPeerIdHashValid(peerId, peerIdHash)) {
|
|
||||||
this.id = peerId;
|
|
||||||
} else {
|
|
||||||
this.id = crypto.randomUUID();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: 2,
|
|
||||||
separator: ' ',
|
|
||||||
dictionaries: [colors, animals],
|
|
||||||
style: 'capital',
|
|
||||||
seed: cyrb53(this.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static isValidUuid(uuid) {
|
|
||||||
return /^([0-9]|[a-f]){8}-(([0-9]|[a-f]){4}-){3}([0-9]|[a-f]){12}$/.test(uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
isPeerIdHashValid(peerId, peerIdHash) {
|
|
||||||
return peerIdHash === hasher.hashCodeSalted(peerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
addRoomSecret(roomSecret) {
|
|
||||||
if (!(roomSecret in this.roomSecrets)) {
|
|
||||||
this.roomSecrets.push(roomSecret);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeRoomSecret(roomSecret) {
|
|
||||||
if (roomSecret in this.roomSecrets) {
|
|
||||||
delete this.roomSecrets[roomSecret];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasher = (() => {
|
|
||||||
let password;
|
|
||||||
return {
|
|
||||||
hashCodeSalted(salt) {
|
|
||||||
if (!password) {
|
|
||||||
// password is created on first call.
|
|
||||||
password = randomizer.getRandomString(128);
|
|
||||||
}
|
|
||||||
|
|
||||||
return crypto.createHash("sha3-512")
|
|
||||||
.update(password)
|
|
||||||
.update(crypto.createHash("sha3-512").update(salt, "utf8").digest("hex"))
|
|
||||||
.digest("hex");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const randomizer = (() => {
|
|
||||||
let charCodeLettersOnly = r => 65 <= r && r <= 90;
|
|
||||||
let charCodeAllPrintableChars = r => r === 45 || 47 <= r && r <= 57 || 64 <= r && r <= 90 || 97 <= r && r <= 122;
|
|
||||||
|
|
||||||
return {
|
|
||||||
getRandomString(length, lettersOnly = false) {
|
|
||||||
const charCodeCondition = lettersOnly
|
|
||||||
? charCodeLettersOnly
|
|
||||||
: charCodeAllPrintableChars;
|
|
||||||
|
|
||||||
let string = "";
|
|
||||||
while (string.length < length) {
|
|
||||||
let arr = new Uint16Array(length);
|
|
||||||
crypto.webcrypto.getRandomValues(arr);
|
|
||||||
arr = Array.apply([], arr); /* turn into non-typed array */
|
|
||||||
arr = arr.map(function (r) {
|
|
||||||
return r % 128
|
|
||||||
})
|
|
||||||
arr = arr.filter(function (r) {
|
|
||||||
/* strip non-printables: if we transform into desirable range we have a probability bias, so I suppose we better skip this character */
|
|
||||||
return charCodeCondition(r);
|
|
||||||
});
|
|
||||||
string += String.fromCharCode.apply(String, arr);
|
|
||||||
}
|
|
||||||
return string.substring(0, length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
/*
|
|
||||||
cyrb53 (c) 2018 bryc (github.com/bryc)
|
|
||||||
A fast and simple hash function with decent collision resistance.
|
|
||||||
Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity.
|
|
||||||
Public domain. Attribution appreciated.
|
|
||||||
*/
|
|
||||||
const cyrb53 = function(str, seed = 0) {
|
|
||||||
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
|
|
||||||
for (let i = 0, ch; i < str.length; i++) {
|
|
||||||
ch = str.charCodeAt(i);
|
|
||||||
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
||||||
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
||||||
}
|
|
||||||
h1 = Math.imul(h1 ^ (h1>>>16), 2246822507) ^ Math.imul(h2 ^ (h2>>>13), 3266489909);
|
|
||||||
h2 = Math.imul(h2 ^ (h2>>>16), 2246822507) ^ Math.imul(h1 ^ (h1>>>13), 3266489909);
|
|
||||||
return 4294967296 * (2097151 & h2) + (h1>>>0);
|
|
||||||
};
|
|
||||||
|
|
||||||
new PairDropServer();
|
|
|
@ -1,11 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "pairdrop",
|
"name": "pairdrop",
|
||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
|
"type": "module",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node server/index.js",
|
||||||
"start:prod": "node index.js --rate-limit --auto-restart"
|
"start:prod": "node server/index.js --rate-limit --auto-restart"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
|
93
public/fonts/OpenSans/OFL.txt
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
BIN
public/fonts/OpenSans/OpenSans-Italic-VariableFont_wdth,wght.ttf
Normal file
BIN
public/fonts/OpenSans/OpenSans-VariableFont_wdth,wght.ttf
Normal file
100
public/fonts/OpenSans/README.txt
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
Open Sans Variable Font
|
||||||
|
=======================
|
||||||
|
|
||||||
|
This download contains Open Sans as both variable fonts and static fonts.
|
||||||
|
|
||||||
|
Open Sans is a variable font with these axes:
|
||||||
|
wdth
|
||||||
|
wght
|
||||||
|
|
||||||
|
This means all the styles are contained in these files:
|
||||||
|
OpenSans-VariableFont_wdth,wght.ttf
|
||||||
|
OpenSans-Italic-VariableFont_wdth,wght.ttf
|
||||||
|
|
||||||
|
If your app fully supports variable fonts, you can now pick intermediate styles
|
||||||
|
that aren’t available as static fonts. Not all apps support variable fonts, and
|
||||||
|
in those cases you can use the static font files for Open Sans:
|
||||||
|
static/OpenSans_Condensed-Light.ttf
|
||||||
|
static/OpenSans_Condensed-Regular.ttf
|
||||||
|
static/OpenSans_Condensed-Medium.ttf
|
||||||
|
static/OpenSans_Condensed-SemiBold.ttf
|
||||||
|
static/OpenSans_Condensed-Bold.ttf
|
||||||
|
static/OpenSans_Condensed-ExtraBold.ttf
|
||||||
|
static/OpenSans_SemiCondensed-Light.ttf
|
||||||
|
static/OpenSans_SemiCondensed-Regular.ttf
|
||||||
|
static/OpenSans_SemiCondensed-Medium.ttf
|
||||||
|
static/OpenSans_SemiCondensed-SemiBold.ttf
|
||||||
|
static/OpenSans_SemiCondensed-Bold.ttf
|
||||||
|
static/OpenSans_SemiCondensed-ExtraBold.ttf
|
||||||
|
static/OpenSans-Light.ttf
|
||||||
|
static/OpenSans-Regular.ttf
|
||||||
|
static/OpenSans-Medium.ttf
|
||||||
|
static/OpenSans-SemiBold.ttf
|
||||||
|
static/OpenSans-Bold.ttf
|
||||||
|
static/OpenSans-ExtraBold.ttf
|
||||||
|
static/OpenSans_Condensed-LightItalic.ttf
|
||||||
|
static/OpenSans_Condensed-Italic.ttf
|
||||||
|
static/OpenSans_Condensed-MediumItalic.ttf
|
||||||
|
static/OpenSans_Condensed-SemiBoldItalic.ttf
|
||||||
|
static/OpenSans_Condensed-BoldItalic.ttf
|
||||||
|
static/OpenSans_Condensed-ExtraBoldItalic.ttf
|
||||||
|
static/OpenSans_SemiCondensed-LightItalic.ttf
|
||||||
|
static/OpenSans_SemiCondensed-Italic.ttf
|
||||||
|
static/OpenSans_SemiCondensed-MediumItalic.ttf
|
||||||
|
static/OpenSans_SemiCondensed-SemiBoldItalic.ttf
|
||||||
|
static/OpenSans_SemiCondensed-BoldItalic.ttf
|
||||||
|
static/OpenSans_SemiCondensed-ExtraBoldItalic.ttf
|
||||||
|
static/OpenSans-LightItalic.ttf
|
||||||
|
static/OpenSans-Italic.ttf
|
||||||
|
static/OpenSans-MediumItalic.ttf
|
||||||
|
static/OpenSans-SemiBoldItalic.ttf
|
||||||
|
static/OpenSans-BoldItalic.ttf
|
||||||
|
static/OpenSans-ExtraBoldItalic.ttf
|
||||||
|
|
||||||
|
Get started
|
||||||
|
-----------
|
||||||
|
|
||||||
|
1. Install the font files you want to use
|
||||||
|
|
||||||
|
2. Use your app's font picker to view the font family and all the
|
||||||
|
available styles
|
||||||
|
|
||||||
|
Learn more about variable fonts
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
||||||
|
https://variablefonts.typenetwork.com
|
||||||
|
https://medium.com/variable-fonts
|
||||||
|
|
||||||
|
In desktop apps
|
||||||
|
|
||||||
|
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
||||||
|
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
||||||
|
|
||||||
|
Online
|
||||||
|
|
||||||
|
https://developers.google.com/fonts/docs/getting_started
|
||||||
|
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
||||||
|
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
||||||
|
|
||||||
|
Installing fonts
|
||||||
|
|
||||||
|
MacOS: https://support.apple.com/en-us/HT201749
|
||||||
|
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
||||||
|
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
||||||
|
|
||||||
|
Android Apps
|
||||||
|
|
||||||
|
https://developers.google.com/fonts/docs/android
|
||||||
|
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
||||||
|
|
||||||
|
License
|
||||||
|
-------
|
||||||
|
Please read the full license text (OFL.txt) to understand the permissions,
|
||||||
|
restrictions and requirements for usage, redistribution, and modification.
|
||||||
|
|
||||||
|
You can use them in your products & projects – print or digital,
|
||||||
|
commercial or otherwise.
|
||||||
|
|
||||||
|
This isn't legal advice, please consider consulting a lawyer and see the full
|
||||||
|
license for all details.
|
BIN
public/fonts/OpenSans/static/OpenSans-Bold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-BoldItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-ExtraBold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-ExtraBoldItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-Italic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-Light.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-LightItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-Medium.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-MediumItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-Regular.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-SemiBold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans-SemiBoldItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-Bold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-BoldItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-ExtraBold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-Italic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-Light.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-LightItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-Medium.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-MediumItalic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-Regular.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_Condensed-SemiBold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-Bold.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-Italic.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-Light.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-Medium.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-Regular.ttf
Normal file
BIN
public/fonts/OpenSans/static/OpenSans_SemiCondensed-SemiBold.ttf
Normal file
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 59 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 31 KiB |
|
@ -5,17 +5,17 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
<!-- Web App Config -->
|
<!-- Web App Config -->
|
||||||
<title>PairDrop</title>
|
<title>PairDrop | Transfer Files Cross-Platform. No Setup, No Signup.</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="theme-color" content="#3367d6">
|
<meta name="theme-color" content="#3367d6">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
<meta name="apple-mobile-web-app-capable" content="no">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-title" content="PairDrop">
|
<meta name="apple-mobile-web-app-title" content="PairDrop">
|
||||||
<meta name="application-name" content="PairDrop">
|
<meta name="application-name" content="PairDrop">
|
||||||
<!-- Descriptions -->
|
<!-- 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="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="keywords" content="File, Transfer, Share, Peer2Peer">
|
||||||
<meta name="author" content="RobinLinus">
|
<meta name="author" content="schlagmichdoch">
|
||||||
<meta property="og:title" content="PairDrop">
|
<meta property="og:title" content="PairDrop">
|
||||||
<meta property="og:type" content="article">
|
<meta property="og:type" content="article">
|
||||||
<meta property="og:url" content="https://pairdrop.net/">
|
<meta property="og:url" content="https://pairdrop.net/">
|
||||||
|
@ -28,13 +28,14 @@
|
||||||
<link rel="icon" sizes="96x96" href="images/favicon-96x96.png">
|
<link rel="icon" sizes="96x96" href="images/favicon-96x96.png">
|
||||||
<link rel="shortcut icon" 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">
|
<link rel="apple-touch-icon" href="images/apple-touch-icon.png">
|
||||||
|
<link rel="apple-touch-icon-precomposed" href="images/apple-touch-icon.png">
|
||||||
<meta name="msapplication-TileImage" content="images/mstile-150x150.png">
|
<meta name="msapplication-TileImage" content="images/mstile-150x150.png">
|
||||||
<link rel="fluid-icon" type="image/png" href="images/android-chrome-192x192.png">
|
<link rel="fluid-icon" type="image/png" href="images/android-chrome-192x192.png">
|
||||||
<meta name="twitter:image" content="images/logo_transparent_512x512.png">
|
<meta name="twitter:image" content="images/logo_transparent_512x512.png">
|
||||||
<meta property="og:image" content="images/logo_transparent_512x512.png">
|
<meta property="og:image" content="images/logo_transparent_512x512.png">
|
||||||
<!-- Resources -->
|
<!-- Resources -->
|
||||||
<link rel="preload" href="lang/en.json" as="fetch">
|
<link rel="preload" href="lang/en.json" as="fetch">
|
||||||
<link rel="stylesheet" type="text/css" href="styles.css">
|
<link rel="stylesheet" type="text/css" href="styles/styles-main.css">
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
@ -94,7 +95,7 @@
|
||||||
<use xlink:href="#public-room-icon"></use>
|
<use xlink:href="#public-room-icon"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div id="cancel-paste-mode" class="button" data-i18n-key="header.cancel-paste-mode" data-i18n-attrs="text" hidden></div>
|
<div id="cancel-paste-mode" class="btn" data-i18n-key="header.cancel-paste-mode" data-i18n-attrs="text" hidden></div>
|
||||||
</header>
|
</header>
|
||||||
<!-- Center -->
|
<!-- Center -->
|
||||||
<div id="center" class="opacity-0">
|
<div id="center" class="opacity-0">
|
||||||
|
@ -108,11 +109,22 @@
|
||||||
<x-instructions data-i18n-key="instructions.x-instructions" data-i18n-attrs="desktop mobile data-drop-peer data-drop-bg">
|
<x-instructions data-i18n-key="instructions.x-instructions" data-i18n-attrs="desktop mobile data-drop-peer data-drop-bg">
|
||||||
<p id="paste-filename"></p>
|
<p id="paste-filename"></p>
|
||||||
</x-instructions>
|
</x-instructions>
|
||||||
|
<div id="websocket-fallback" hidden>
|
||||||
|
<span data-i18n-key="footer.traffic" data-i18n-attrs="text"></span>
|
||||||
|
<span data-i18n-key="footer.routed" data-i18n-attrs="text"></span>
|
||||||
|
<span data-i18n-key="footer.webrtc" data-i18n-attrs="text"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="column opacity-0">
|
<footer class="column opacity-0">
|
||||||
<svg class="icon logo">
|
<svg class="icon logo">
|
||||||
<use xlink:href="#wifi-tethering"></use>
|
<defs>
|
||||||
|
<linearGradient id="primaryGradient" gradientTransform="rotate(90)">
|
||||||
|
<stop offset="0%" class="start-color" />
|
||||||
|
<stop offset="100%" class="stop-color" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<use xlink:href="#wifi-tethering" style="fill: url(#primaryGradient);"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="known-as-wrapper">
|
<div class="known-as-wrapper">
|
||||||
|
@ -127,9 +139,9 @@
|
||||||
<span data-i18n-key="footer.discovery" data-i18n-attrs="text"></span>
|
<span data-i18n-key="footer.discovery" data-i18n-attrs="text"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row center">
|
<div class="row center">
|
||||||
<span class="badge badge-room-ip" data-i18n-key="footer.on-this-network" data-i18n-attrs="text title"></span>
|
<span class="badge badge-gradient badge-room-ip" data-i18n-key="footer.on-this-network" data-i18n-attrs="text title"></span>
|
||||||
<span class="badge badge-room-secret pointer" data-i18n-key="footer.paired-devices" data-i18n-attrs="text title" hidden></span>
|
<span class="badge badge-gradient badge-room-secret pointer" data-i18n-key="footer.paired-devices" data-i18n-attrs="text title" hidden></span>
|
||||||
<span class="badge badge-room-public-id pointer" data-i18n-key="footer.public-room-devices" data-i18n-attrs="title" hidden>in room IAIAI</span>
|
<span class="badge badge-gradient badge-room-public-id pointer" data-i18n-key="footer.public-room-devices" data-i18n-attrs="title" hidden>in room IAIAI</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -142,83 +154,83 @@
|
||||||
<h2 class="center" data-i18n-key="dialogs.language-selector-title" data-i18n-attrs="text"></h2>
|
<h2 class="center" data-i18n-key="dialogs.language-selector-title" data-i18n-attrs="text"></h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="language-buttons">
|
<div class="language-buttons">
|
||||||
<button class="button fw" data-i18n-key="dialogs.system-language" data-i18n-attrs="text"></button>
|
<button class="btn fw" data-i18n-key="dialogs.system-language" data-i18n-attrs="text"></button>
|
||||||
<button class="button fw" value="ar">
|
<button class="btn fw" value="ar">
|
||||||
<span>العربية</span>
|
<span>العربية</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Arabic)</span>
|
<span>(Arabic)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="de">
|
<button class="btn fw" value="de">
|
||||||
<span>Deutsch</span>
|
<span>Deutsch</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(German)</span>
|
<span>(German)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="en">
|
<button class="btn fw" value="en">
|
||||||
<span>English</span>
|
<span>English</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="es">
|
<button class="btn fw" value="es">
|
||||||
<span>Español</span>
|
<span>Español</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Spanish)</span>
|
<span>(Spanish)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="fr">
|
<button class="btn fw" value="fr">
|
||||||
<span>Français</span>
|
<span>Français</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(French)</span>
|
<span>(French)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="id">
|
<button class="btn fw" value="id">
|
||||||
<span>Bahasa Indonesia</span>
|
<span>Bahasa Indonesia</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Indonesian)</span>
|
<span>(Indonesian)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="it">
|
<button class="btn fw" value="it">
|
||||||
<span>Italiano</span>
|
<span>Italiano</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Italian)</span>
|
<span>(Italian)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="nl">
|
<button class="btn fw" value="nl">
|
||||||
<span>Nederlands</span>
|
<span>Nederlands</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Dutch)</span>
|
<span>(Dutch)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="nb">
|
<button class="btn fw" value="nb">
|
||||||
<span>Norsk</span>
|
<span>Norsk</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Norwegian)</span>
|
<span>(Norwegian)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="pt-BR">
|
<button class="btn fw" value="pt-BR">
|
||||||
<span>Português do Brasil</span>
|
<span>Português do Brasil</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Brazilian Portuguese)</span>
|
<span>(Brazilian Portuguese)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="ro">
|
<button class="btn fw" value="ro">
|
||||||
<span>Română</span>
|
<span>Română</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Romanian)</span>
|
<span>(Romanian)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="ru">
|
<button class="btn fw" value="ru">
|
||||||
<span>Русский язык</span>
|
<span>Русский язык</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Russian)</span>
|
<span>(Russian)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="tr">
|
<button class="btn fw" value="tr">
|
||||||
<span>Türkçe</span>
|
<span>Türkçe</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Turkish)</span>
|
<span>(Turkish)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="zh-CN">
|
<button class="btn fw" value="zh-CN">
|
||||||
<span>中文</span>
|
<span>中文</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Chinese)</span>
|
<span>(Chinese)</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button fw" value="ja">
|
<button class="btn fw" value="ja">
|
||||||
<span>日本語</span>
|
<span>日本語</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>(Japanese)</span>
|
<span>(Japanese)</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="center row-reverse button-row">
|
<div class="center row-reverse button-row">
|
||||||
<button class="button" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
<button class="btn btn-rounded btn-grey" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||||
</div>
|
</div>
|
||||||
</x-paper>
|
</x-paper>
|
||||||
</x-background>
|
</x-background>
|
||||||
|
@ -248,7 +260,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row center">
|
<div class="row center">
|
||||||
<div class="column">
|
<div class="column fw">
|
||||||
<div class="input-key-container six-chars" dir="ltr">
|
<div class="input-key-container six-chars" dir="ltr">
|
||||||
<input type="tel" class="textarea center" aria-label="pair-key-char-1" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" autofocus contenteditable placeholder disabled>
|
<input type="tel" class="textarea center" aria-label="pair-key-char-1" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" autofocus contenteditable placeholder disabled>
|
||||||
<input type="tel" class="textarea center" aria-label="pair-key-char-2" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" contenteditable placeholder disabled>
|
<input type="tel" class="textarea center" aria-label="pair-key-char-2" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" contenteditable placeholder disabled>
|
||||||
|
@ -261,8 +273,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row row-reverse">
|
<div class="button-row row-reverse">
|
||||||
<button class="button" type="submit" data-i18n-key="dialogs.pair" data-i18n-attrs="text" disabled></button>
|
<button class="btn btn-rounded btn-grey" type="submit" data-i18n-key="dialogs.pair" data-i18n-attrs="text" disabled></button>
|
||||||
<button class="button" type="button" data-i18n-key="dialogs.cancel" data-i18n-attrs="text" close></button>
|
<button class="btn btn-rounded btn-grey" type="button" data-i18n-key="dialogs.cancel" data-i18n-attrs="text" close></button>
|
||||||
</div>
|
</div>
|
||||||
</x-paper>
|
</x-paper>
|
||||||
</x-background>
|
</x-background>
|
||||||
|
@ -285,7 +297,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="center row-reverse button-row">
|
<div class="center row-reverse button-row">
|
||||||
<button class="button" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
<button class="btn btn-rounded btn-grey" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||||
</div>
|
</div>
|
||||||
</x-paper>
|
</x-paper>
|
||||||
</x-background>
|
</x-background>
|
||||||
|
@ -318,7 +330,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row center">
|
<div class="row center">
|
||||||
<div class="column">
|
<div class="column fw">
|
||||||
<div class="input-key-container" dir="ltr">
|
<div class="input-key-container" dir="ltr">
|
||||||
<input type="text" class="textarea center" aria-label="room-id-char-1" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" autofocus contenteditable placeholder disabled>
|
<input type="text" class="textarea center" aria-label="room-id-char-1" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" autofocus contenteditable placeholder disabled>
|
||||||
<input type="text" class="textarea center" aria-label="room-id-char-2" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" contenteditable placeholder disabled>
|
<input type="text" class="textarea center" aria-label="room-id-char-2" maxlength="1" autocorrect="off" autocomplete="off" autocapitalize="none" spellcheck="false" contenteditable placeholder disabled>
|
||||||
|
@ -330,9 +342,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="center row-reverse button-row">
|
<div class="center row-reverse button-row">
|
||||||
<button class="button" type="submit" data-i18n-key="dialogs.join" data-i18n-attrs="text" disabled></button>
|
<button class="btn btn-rounded btn-grey" type="submit" data-i18n-key="dialogs.join" data-i18n-attrs="text" disabled></button>
|
||||||
<button class="button" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
<button class="btn btn-rounded btn-grey" type="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||||
<button class="button leave-room" type="button" data-i18n-key="dialogs.leave" data-i18n-attrs="text"></button>
|
<button class="btn btn-rounded btn-grey leave-room" type="button" data-i18n-key="dialogs.leave" data-i18n-attrs="text"></button>
|
||||||
</div>
|
</div>
|
||||||
</x-paper>
|
</x-paper>
|
||||||
</x-background>
|
</x-background>
|
||||||
|
@ -347,10 +359,10 @@
|
||||||
<h2 class="center"></h2>
|
<h2 class="center"></h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row center">
|
<div class="row center p1">
|
||||||
<div class="column center file-description">
|
<div class="column center file-description">
|
||||||
<div>
|
<div>
|
||||||
<span class="display-name badge"></span>
|
<span class="display-name badge badge-gradient"></span>
|
||||||
<span data-i18n-key="dialogs.would-like-to-share" data-i18n-attrs="text"></span>
|
<span data-i18n-key="dialogs.would-like-to-share" data-i18n-attrs="text"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row file-name">
|
<div class="row file-name">
|
||||||
|
@ -364,8 +376,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="center file-preview"></div>
|
<div class="center file-preview"></div>
|
||||||
<div class="row-reverse center button-row">
|
<div class="row-reverse center button-row">
|
||||||
<button id="accept-request" class="button" title="ENTER" data-i18n-key="dialogs.accept" data-i18n-attrs="text" autofocus></button>
|
<button id="accept-request" class="btn btn-rounded btn-grey" title="ENTER" data-i18n-key="dialogs.accept" data-i18n-attrs="text" autofocus></button>
|
||||||
<button id="decline-request" class="button" title="ESCAPE" data-i18n-key="dialogs.decline" data-i18n-attrs="text"></button>
|
<button id="decline-request" class="btn btn-rounded btn-grey" title="ESCAPE" data-i18n-key="dialogs.decline" data-i18n-attrs="text"></button>
|
||||||
</div>
|
</div>
|
||||||
</x-paper>
|
</x-paper>
|
||||||
</x-background>
|
</x-background>
|
||||||
|
@ -379,10 +391,10 @@
|
||||||
<h2 class="center"></h2>
|
<h2 class="center"></h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row center">
|
<div class="row center p1">
|
||||||
<div class="column center file-description">
|
<div class="column center file-description">
|
||||||
<div>
|
<div>
|
||||||
<span class="display-name badge"></span>
|
<span class="display-name badge badge-gradient"></span>
|
||||||
<span data-i18n-key="dialogs.has-sent" data-i18n-attrs="text"></span>
|
<span data-i18n-key="dialogs.has-sent" data-i18n-attrs="text"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row file-name">
|
<div class="row file-name">
|
||||||
|
@ -396,9 +408,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="center file-preview"></div>
|
<div class="center file-preview"></div>
|
||||||
<div class="row-reverse center button-row">
|
<div class="row-reverse center button-row">
|
||||||
<button id="share-btn" class="button" data-i18n-key="dialogs.share" data-i18n-attrs="text" hidden></button>
|
<button id="share-btn" class="btn btn-rounded btn-grey" data-i18n-key="dialogs.share" data-i18n-attrs="text" hidden></button>
|
||||||
<button id="download-btn" class="button" data-i18n-key="dialogs.download" data-i18n-attrs="text" autofocus></button>
|
<button id="download-btn" class="btn btn-rounded btn-grey" data-i18n-key="dialogs.download" data-i18n-attrs="text" autofocus></button>
|
||||||
<button class="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
<button class="btn btn-rounded btn-grey" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||||
</div>
|
</div>
|
||||||
</x-paper>
|
</x-paper>
|
||||||
</x-background>
|
</x-background>
|
||||||
|
@ -413,22 +425,22 @@
|
||||||
<h2 class="center" data-i18n-key="dialogs.send-message-title" data-i18n-attrs="text"></h2>
|
<h2 class="center" data-i18n-key="dialogs.send-message-title" data-i18n-attrs="text"></h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row center display-name-wrapper">
|
<div class="row center p1 display-name-wrapper">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span data-i18n-key="dialogs.send-message-to" data-i18n-attrs="text"></span>
|
<span data-i18n-key="dialogs.send-message-to" data-i18n-attrs="text"></span>
|
||||||
<span class="display-name badge"></span>
|
<span class="display-name badge badge-gradient"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row p1">
|
||||||
<div class="column fw">
|
<div class="column fw">
|
||||||
<div id="text-input" class="textarea" role="textbox" data-i18n-key="dialogs.message" data-i18n-attrs="title" autocapitalize="none" spellcheck="false" autofocus contenteditable></div>
|
<div id="text-input" class="fw textarea" role="textbox" data-i18n-key="dialogs.message" data-i18n-attrs="title placeholder" autofocus contenteditable></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row row-reverse">
|
<div class="button-row row-reverse">
|
||||||
<button class="button" type="submit" title="CTRL/⌘ + ENTER" data-i18n-key="dialogs.send" data-i18n-attrs="text" disabled></button>
|
<button class="btn btn-rounded btn-grey" type="submit" title="CTRL/⌘ + ENTER" data-i18n-key="dialogs.send" data-i18n-attrs="text" disabled></button>
|
||||||
<button class="button" type="button" title="ESCAPE" data-i18n-key="dialogs.cancel" data-i18n-attrs="text" close></button>
|
<button class="btn btn-rounded btn-grey" type="button" title="ESCAPE" data-i18n-key="dialogs.cancel" data-i18n-attrs="text" close></button>
|
||||||
</div>
|
</div>
|
||||||
</x-paper>
|
</x-paper>
|
||||||
</x-background>
|
</x-background>
|
||||||
|
@ -441,20 +453,20 @@
|
||||||
<div class="row center">
|
<div class="row center">
|
||||||
<h2 class="text-center" data-i18n-key="dialogs.receive-text-title" data-i18n-attrs="text"></h2>
|
<h2 class="text-center" data-i18n-key="dialogs.receive-text-title" data-i18n-attrs="text"></h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="row center">
|
<div class="row center p1 display-name-wrapper">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="display-name badge"></span>
|
<span class="display-name badge badge-gradient"></span>
|
||||||
<span data-i18n-key="dialogs.has-sent" data-i18n-attrs="text"></span>
|
<span data-i18n-key="dialogs.has-sent" data-i18n-attrs="text"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row center">
|
<div class="row center p1">
|
||||||
<div class="column fw">
|
<div class="column fw">
|
||||||
<div id="text" class="textarea fw"></div>
|
<div id="text" class="textarea"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-reverse center button-row">
|
<div class="row-reverse center button-row">
|
||||||
<button id="copy" class="button" title="CTRL/⌘ + C" data-i18n-key="dialogs.copy" data-i18n-attrs="text"></button>
|
<button id="copy" class="btn btn-rounded btn-grey" title="CTRL/⌘ + C" data-i18n-key="dialogs.copy" data-i18n-attrs="text"></button>
|
||||||
<button id="close" class="button" title="ESCAPE" data-i18n-key="dialogs.close" data-i18n-attrs="text"></button>
|
<button id="close" class="btn btn-rounded btn-grey" title="ESCAPE" data-i18n-key="dialogs.close" data-i18n-attrs="text"></button>
|
||||||
</div>
|
</div>
|
||||||
</x-paper>
|
</x-paper>
|
||||||
</x-background>
|
</x-background>
|
||||||
|
@ -463,10 +475,10 @@
|
||||||
<x-dialog id="base64-paste-dialog">
|
<x-dialog id="base64-paste-dialog">
|
||||||
<x-background class="full center">
|
<x-background class="full center">
|
||||||
<x-paper shadow="2">
|
<x-paper shadow="2">
|
||||||
<button class="button center" id="base64-paste-btn" title="Paste"></button>
|
<button class="btn btn-rounded btn-grey center" id="base64-paste-btn" title="Paste"></button>
|
||||||
<div class="textarea" placeholder="Paste here to send files" title="CMD/⌘ + V" contenteditable hidden></div>
|
<div class="textarea" placeholder="Paste here to send files" title="CMD/⌘ + V" contenteditable hidden></div>
|
||||||
<div class="row-reverse center button-row">
|
<div class="row-reverse center button-row">
|
||||||
<button class="button" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
<button class="btn btn-rounded btn-grey" data-i18n-key="dialogs.close" data-i18n-attrs="text" close></button>
|
||||||
</div>
|
</div>
|
||||||
</x-paper>
|
</x-paper>
|
||||||
</x-background>
|
</x-background>
|
||||||
|
@ -595,13 +607,9 @@
|
||||||
</svg>
|
</svg>
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="scripts/localization.js"></script>
|
<script src="scripts/localization.js"></script>
|
||||||
<script src="scripts/theme.js"></script>
|
<script src="scripts/persistent-storage.js"></script>
|
||||||
<script src="scripts/network.js"></script>
|
<script src="scripts/ui-main.js"></script>
|
||||||
<script src="scripts/ui.js"></script>
|
<script src="scripts/main.js"></script>
|
||||||
<script src="scripts/util.js"></script>
|
|
||||||
<script src="scripts/QRCode.min.js" async></script>
|
|
||||||
<script src="scripts/zip.min.js" async></script>
|
|
||||||
<script src="scripts/NoSleep.min.js" async></script>
|
|
||||||
<!-- Sounds -->
|
<!-- Sounds -->
|
||||||
<audio id="blop" autobuffer="true">
|
<audio id="blop" autobuffer="true">
|
||||||
<source src="sounds/blop.mp3" type="audio/mpeg">
|
<source src="sounds/blop.mp3" type="audio/mpeg">
|
||||||
|
|
|
@ -25,7 +25,8 @@
|
||||||
"tap-to-send": "Tap to send",
|
"tap-to-send": "Tap to send",
|
||||||
"activate-paste-mode-base": "Open PairDrop on other devices to send",
|
"activate-paste-mode-base": "Open PairDrop on other devices to send",
|
||||||
"activate-paste-mode-and-other-files": "and {{count}} other files",
|
"activate-paste-mode-and-other-files": "and {{count}} other files",
|
||||||
"activate-paste-mode-shared-text": "shared text"
|
"activate-paste-mode-shared-text": "shared text",
|
||||||
|
"webrtc-requirement": "To use PairDrop on this instance, WebRTC must be enabled!"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"known-as": "You are known as:",
|
"known-as": "You are known as:",
|
||||||
|
@ -69,8 +70,9 @@
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"send-message-title": "Send Message",
|
"send-message-title": "Send Message",
|
||||||
"send-message-to": "Send a Message to",
|
"send-message-to": "To:",
|
||||||
"message_title": "Insert message to send",
|
"message_title": "Insert message to send",
|
||||||
|
"message_placeholder": "Text",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"receive-text-title": "Message Received",
|
"receive-text-title": "Message Received",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
|
|
|
@ -1,29 +1,30 @@
|
||||||
{
|
{
|
||||||
"name": "PairDrop",
|
"name": "PairDrop",
|
||||||
"short_name": "PairDrop",
|
"short_name": "PairDrop",
|
||||||
"icons": [{
|
"icons": [
|
||||||
|
{
|
||||||
"src": "images/android-chrome-192x192.png",
|
"src": "images/android-chrome-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},{
|
},
|
||||||
|
{
|
||||||
"src": "images/android-chrome-512x512.png",
|
"src": "images/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},{
|
},
|
||||||
|
{
|
||||||
"src": "images/android-chrome-192x192-maskable.png",
|
"src": "images/android-chrome-192x192-maskable.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},{
|
},
|
||||||
|
{
|
||||||
"src": "images/android-chrome-512x512-maskable.png",
|
"src": "images/android-chrome-512x512-maskable.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},{
|
}
|
||||||
"src": "images/favicon-96x96.png",
|
],
|
||||||
"sizes": "96x96",
|
|
||||||
"type": "image/png"
|
|
||||||
}],
|
|
||||||
"background_color": "#efefef",
|
"background_color": "#efefef",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
|
|
60
public/scripts/browser-tabs-connector.js
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
class BrowserTabsConnector {
|
||||||
|
constructor() {
|
||||||
|
this.bc = new BroadcastChannel('pairdrop');
|
||||||
|
this.bc.addEventListener('message', e => this._onMessage(e));
|
||||||
|
Events.on('broadcast-send', e => this._broadcastSend(e.detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
_broadcastSend(message) {
|
||||||
|
this.bc.postMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMessage(e) {
|
||||||
|
console.log('Broadcast:', e.data)
|
||||||
|
switch (e.data.type) {
|
||||||
|
case 'self-display-name-changed':
|
||||||
|
Events.fire('self-display-name-changed', e.data.detail);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static peerIsSameBrowser(peerId) {
|
||||||
|
let peerIdsBrowser = JSON.parse(localStorage.getItem("peer_ids_browser"));
|
||||||
|
return peerIdsBrowser
|
||||||
|
? peerIdsBrowser.indexOf(peerId) !== -1
|
||||||
|
: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addPeerIdToLocalStorage() {
|
||||||
|
const peerId = sessionStorage.getItem("peer_id");
|
||||||
|
if (!peerId) return false;
|
||||||
|
|
||||||
|
let peerIdsBrowser = [];
|
||||||
|
let peerIdsBrowserOld = JSON.parse(localStorage.getItem("peer_ids_browser"));
|
||||||
|
|
||||||
|
if (peerIdsBrowserOld) peerIdsBrowser.push(...peerIdsBrowserOld);
|
||||||
|
peerIdsBrowser.push(peerId);
|
||||||
|
peerIdsBrowser = peerIdsBrowser.filter(onlyUnique);
|
||||||
|
localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser));
|
||||||
|
|
||||||
|
return peerIdsBrowser;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async removePeerIdFromLocalStorage(peerId) {
|
||||||
|
let peerIdsBrowser = JSON.parse(localStorage.getItem("peer_ids_browser"));
|
||||||
|
const index = peerIdsBrowser.indexOf(peerId);
|
||||||
|
peerIdsBrowser.splice(index, 1);
|
||||||
|
localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser));
|
||||||
|
return peerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static async removeOtherPeerIdsFromLocalStorage() {
|
||||||
|
const peerId = sessionStorage.getItem("peer_id");
|
||||||
|
if (!peerId) return false;
|
||||||
|
|
||||||
|
let peerIdsBrowser = [peerId];
|
||||||
|
localStorage.setItem("peer_ids_browser", JSON.stringify(peerIdsBrowser));
|
||||||
|
return peerIdsBrowser;
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,7 +15,8 @@ class Localization {
|
||||||
? storedLanguageCode
|
? storedLanguageCode
|
||||||
: Localization.systemLocale;
|
: Localization.systemLocale;
|
||||||
|
|
||||||
Localization.setTranslation(Localization.initialLocale)
|
Localization
|
||||||
|
.setTranslation(Localization.initialLocale)
|
||||||
.then(_ => {
|
.then(_ => {
|
||||||
console.log("Initial translation successful.");
|
console.log("Initial translation successful.");
|
||||||
Events.fire("initial-translation-loaded");
|
Events.fire("initial-translation-loaded");
|
||||||
|
@ -50,7 +51,8 @@ class Localization {
|
||||||
|
|
||||||
if (Localization.isRTLLanguage(locale)) {
|
if (Localization.isRTLLanguage(locale)) {
|
||||||
htmlRootNode.setAttribute('dir', 'rtl');
|
htmlRootNode.setAttribute('dir', 'rtl');
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
htmlRootNode.removeAttribute('dir');
|
htmlRootNode.removeAttribute('dir');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,13 +114,9 @@ class Localization {
|
||||||
let attr = attrs[i];
|
let attr = attrs[i];
|
||||||
if (attr === "text") {
|
if (attr === "text") {
|
||||||
element.innerText = Localization.getTranslation(key);
|
element.innerText = Localization.getTranslation(key);
|
||||||
} else {
|
|
||||||
if (attr.startsWith("data-")) {
|
|
||||||
let dataAttr = attr.substring(5);
|
|
||||||
element.dataset.dataAttr = Localization.getTranslation(key, attr);
|
|
||||||
} {
|
|
||||||
element.setAttribute(attr, Localization.getTranslation(key, attr));
|
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
element.setAttribute(attr, Localization.getTranslation(key, attr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -156,7 +154,8 @@ class Localization {
|
||||||
console.warn(`Missing translation entry for your language ${Localization.locale.toUpperCase()}. Using ${Localization.defaultLocale.toUpperCase()} instead.`, key, attr);
|
console.warn(`Missing translation entry for your language ${Localization.locale.toUpperCase()}. Using ${Localization.defaultLocale.toUpperCase()} instead.`, key, attr);
|
||||||
console.warn(`Translate this string here: https://hosted.weblate.org/browse/pairdrop/pairdrop-spa/${Localization.locale.toLowerCase()}/?q=${key}`)
|
console.warn(`Translate this string here: https://hosted.weblate.org/browse/pairdrop/pairdrop-spa/${Localization.locale.toLowerCase()}/?q=${key}`)
|
||||||
console.log("Help translating PairDrop: https://hosted.weblate.org/engage/pairdrop/");
|
console.log("Help translating PairDrop: https://hosted.weblate.org/engage/pairdrop/");
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
console.warn("Missing translation in default language:", key, attr);
|
console.warn("Missing translation in default language:", key, attr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
180
public/scripts/main.js
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
class PairDrop {
|
||||||
|
constructor() {
|
||||||
|
this.$header = $$('header.opacity-0');
|
||||||
|
this.$center = $$('#center');
|
||||||
|
this.$footer = $$('footer');
|
||||||
|
this.$xNoPeers = $$('x-no-peers');
|
||||||
|
this.$headerNotificationButton = $('notification');
|
||||||
|
this.$editPairedDevicesHeaderBtn = $('edit-paired-devices');
|
||||||
|
this.$footerInstructionsPairedDevices = $$('.discovery-wrapper .badge-room-secret');
|
||||||
|
this.$head = $$('head');
|
||||||
|
this.$installBtn = $('install');
|
||||||
|
|
||||||
|
this.registerServiceWorker();
|
||||||
|
|
||||||
|
Events.on('beforeinstallprompt', e => this.onPwaInstallable(e));
|
||||||
|
|
||||||
|
const persistentStorage = new PersistentStorage();
|
||||||
|
const themeUI = new ThemeUI();
|
||||||
|
const backgroundCanvas = new BackgroundCanvas();
|
||||||
|
|
||||||
|
Events.on('initial-translation-loaded', _ => {
|
||||||
|
// FooterUI needs translations
|
||||||
|
const footerUI = new FooterUI();
|
||||||
|
|
||||||
|
Events.on('fade-in-ui', _ => this.fadeInUI())
|
||||||
|
Events.on('fade-in-header', _ => this.fadeInHeader())
|
||||||
|
|
||||||
|
// Evaluate UI elements and fade in UI
|
||||||
|
this.evaluateUI();
|
||||||
|
|
||||||
|
// Load deferred assets
|
||||||
|
this.loadDeferredAssets();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Translate page -> fires 'initial-translation-loaded' on finish
|
||||||
|
const localization = new Localization();
|
||||||
|
}
|
||||||
|
|
||||||
|
registerServiceWorker() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('service-worker.js')
|
||||||
|
.then(serviceWorker => {
|
||||||
|
console.log('Service Worker registered');
|
||||||
|
window.serviceWorker = serviceWorker
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPwaInstallable(e) {
|
||||||
|
if (!window.matchMedia('(display-mode: minimal-ui)').matches) {
|
||||||
|
// only display install btn when not installed
|
||||||
|
this.$installBtn.removeAttribute('hidden');
|
||||||
|
this.$installBtn.addEventListener('click', () => {
|
||||||
|
this.$installBtn.setAttribute('hidden', true);
|
||||||
|
e.prompt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluateUI() {
|
||||||
|
// Check whether notification permissions have already been granted
|
||||||
|
if ('Notification' in window && Notification.permission !== 'granted') {
|
||||||
|
this.$headerNotificationButton.removeAttribute('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistentStorage
|
||||||
|
.getAllRoomSecrets()
|
||||||
|
.then(roomSecrets => {
|
||||||
|
if (roomSecrets.length > 0) {
|
||||||
|
this.$editPairedDevicesHeaderBtn.removeAttribute('hidden');
|
||||||
|
this.$footerInstructionsPairedDevices.removeAttribute('hidden');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
Events.fire('evaluate-footer-badges');
|
||||||
|
Events.fire('fade-in-header');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fadeInUI() {
|
||||||
|
this.$center.classList.remove('opacity-0');
|
||||||
|
this.$footer.classList.remove('opacity-0');
|
||||||
|
|
||||||
|
// Prevent flickering on load
|
||||||
|
setTimeout(() => {
|
||||||
|
this.$xNoPeers.classList.remove('no-animation-on-load');
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
fadeInHeader() {
|
||||||
|
this.$header.classList.remove('opacity-0');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDeferredAssets() {
|
||||||
|
console.log("Load deferred assets");
|
||||||
|
this.deferredStyles = [
|
||||||
|
"styles/deferred-styles.css"
|
||||||
|
];
|
||||||
|
this.deferredScripts = [
|
||||||
|
"scripts/browser-tabs-connector.js",
|
||||||
|
"scripts/util.js",
|
||||||
|
"scripts/network.js",
|
||||||
|
"scripts/ui.js",
|
||||||
|
"scripts/qr-code.min.js",
|
||||||
|
"scripts/zip.min.js",
|
||||||
|
"scripts/no-sleep.min.js"
|
||||||
|
];
|
||||||
|
this.deferredStyles.forEach(url => this.loadStyleSheet(url, _ => this.onStyleLoaded(url)))
|
||||||
|
this.deferredScripts.forEach(url => this.loadScript(url, _ => this.onScriptLoaded(url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStyleSheet(url, callback) {
|
||||||
|
let stylesheet = document.createElement('link');
|
||||||
|
stylesheet.rel = 'stylesheet';
|
||||||
|
stylesheet.href = url;
|
||||||
|
stylesheet.type = 'text/css';
|
||||||
|
stylesheet.onload = callback;
|
||||||
|
this.$head.appendChild(stylesheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadScript(url, callback) {
|
||||||
|
let script = document.createElement("script");
|
||||||
|
script.src = url;
|
||||||
|
script.onload = callback;
|
||||||
|
document.body.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
onStyleLoaded(url) {
|
||||||
|
// remove entry from array
|
||||||
|
const index = this.deferredStyles.indexOf(url);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.deferredStyles.splice(index, 1);
|
||||||
|
}
|
||||||
|
this.onAssetLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
onScriptLoaded(url) {
|
||||||
|
// remove entry from array
|
||||||
|
const index = this.deferredScripts.indexOf(url);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.deferredScripts.splice(index, 1);
|
||||||
|
}
|
||||||
|
this.onAssetLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
onAssetLoaded() {
|
||||||
|
if (this.deferredScripts.length || this.deferredStyles.length) return;
|
||||||
|
|
||||||
|
console.log("Loading of deferred assets completed. Start UI hydration.");
|
||||||
|
|
||||||
|
this.hydrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrate() {
|
||||||
|
const peersUI = new PeersUI();
|
||||||
|
const languageSelectDialog = new LanguageSelectDialog();
|
||||||
|
const receiveFileDialog = new ReceiveFileDialog();
|
||||||
|
const receiveRequestDialog = new ReceiveRequestDialog();
|
||||||
|
const sendTextDialog = new SendTextDialog();
|
||||||
|
const receiveTextDialog = new ReceiveTextDialog();
|
||||||
|
const pairDeviceDialog = new PairDeviceDialog();
|
||||||
|
const clearDevicesDialog = new EditPairedDevicesDialog();
|
||||||
|
const publicRoomDialog = new PublicRoomDialog();
|
||||||
|
const base64ZipDialog = new Base64ZipDialog();
|
||||||
|
const toast = new Toast();
|
||||||
|
const notifications = new Notifications();
|
||||||
|
const networkStatusUI = new NetworkStatusUI();
|
||||||
|
const webShareTargetUI = new WebShareTargetUI();
|
||||||
|
const webFileHandlersUI = new WebFileHandlersUI();
|
||||||
|
const noSleepUI = new NoSleepUI();
|
||||||
|
const broadCast = new BrowserTabsConnector();
|
||||||
|
const server = new ServerConnection();
|
||||||
|
const peers = new PeersManager(server);
|
||||||
|
console.log("UI hydrated.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairDrop = new PairDrop();
|
|
@ -1,26 +1,15 @@
|
||||||
window.URL = window.URL || window.webkitURL;
|
|
||||||
window.isRtcSupported = !!(window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection);
|
|
||||||
|
|
||||||
if (!window.isRtcSupported) alert("WebRTC must be enabled for PairDrop to work");
|
|
||||||
|
|
||||||
window.hiddenProperty = 'hidden' in document ? 'hidden' :
|
|
||||||
'webkitHidden' in document ? 'webkitHidden' :
|
|
||||||
'mozHidden' in document ? 'mozHidden' :
|
|
||||||
null;
|
|
||||||
window.visibilityChangeEvent = 'visibilitychange' in document ? 'visibilitychange' :
|
|
||||||
'webkitvisibilitychange' in document ? 'webkitvisibilitychange' :
|
|
||||||
'mozvisibilitychange' in document ? 'mozvisibilitychange' :
|
|
||||||
null;
|
|
||||||
|
|
||||||
class ServerConnection {
|
class ServerConnection {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._connect();
|
|
||||||
Events.on('pagehide', _ => this._disconnect());
|
Events.on('pagehide', _ => this._disconnect());
|
||||||
document.addEventListener(window.visibilityChangeEvent, _ => this._onVisibilityChange());
|
Events.on(window.visibilityChangeEvent, _ => this._onVisibilityChange());
|
||||||
if (navigator.connection) navigator.connection.addEventListener('change', _ => this._reconnect());
|
|
||||||
|
if (navigator.connection) {
|
||||||
|
navigator.connection.addEventListener('change', _ => this._reconnect());
|
||||||
|
}
|
||||||
|
|
||||||
Events.on('room-secrets', e => this.send({ type: 'room-secrets', roomSecrets: e.detail }));
|
Events.on('room-secrets', e => this.send({ type: 'room-secrets', roomSecrets: e.detail }));
|
||||||
Events.on('join-ip-room', e => this.send({ type: 'join-ip-room'}));
|
Events.on('join-ip-room', _ => this.send({ type: 'join-ip-room'}));
|
||||||
Events.on('room-secrets-deleted', e => this.send({ type: 'room-secrets-deleted', roomSecrets: e.detail}));
|
Events.on('room-secrets-deleted', e => this.send({ type: 'room-secrets-deleted', roomSecrets: e.detail}));
|
||||||
Events.on('regenerate-room-secret', e => this.send({ type: 'regenerate-room-secret', roomSecret: e.detail}));
|
Events.on('regenerate-room-secret', e => this.send({ type: 'regenerate-room-secret', roomSecret: e.detail}));
|
||||||
Events.on('pair-device-initiate', _ => this._onPairDeviceInitiate());
|
Events.on('pair-device-initiate', _ => this._onPairDeviceInitiate());
|
||||||
|
@ -33,6 +22,49 @@ class ServerConnection {
|
||||||
|
|
||||||
Events.on('offline', _ => clearTimeout(this._reconnectTimer));
|
Events.on('offline', _ => clearTimeout(this._reconnectTimer));
|
||||||
Events.on('online', _ => this._connect());
|
Events.on('online', _ => this._connect());
|
||||||
|
|
||||||
|
this._getConfig().then(() => this._connect());
|
||||||
|
}
|
||||||
|
|
||||||
|
_getConfig() {
|
||||||
|
console.log("Loading config...")
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let xhr = new XMLHttpRequest();
|
||||||
|
xhr.addEventListener("load", () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
// Config received
|
||||||
|
let config = JSON.parse(xhr.responseText);
|
||||||
|
console.log("Config loaded:", config)
|
||||||
|
this._config = config;
|
||||||
|
Events.fire('config', config);
|
||||||
|
resolve()
|
||||||
|
} else if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
|
retry(xhr);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
xhr.addEventListener("error", _ => {
|
||||||
|
retry(xhr);
|
||||||
|
});
|
||||||
|
|
||||||
|
function retry(request) {
|
||||||
|
setTimeout(function () {
|
||||||
|
openAndSend(request)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAndSend() {
|
||||||
|
xhr.open('GET', 'config');
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
openAndSend(xhr);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_setWsConfig(wsConfig) {
|
||||||
|
this._wsConfig = wsConfig;
|
||||||
|
Events.fire('ws-config', wsConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
_connect() {
|
_connect() {
|
||||||
|
@ -69,7 +101,7 @@ class ServerConnection {
|
||||||
|
|
||||||
_onPairDeviceJoin(pairKey) {
|
_onPairDeviceJoin(pairKey) {
|
||||||
if (!this._isConnected()) {
|
if (!this._isConnected()) {
|
||||||
setTimeout(_ => this._onPairDeviceJoin(pairKey), 1000);
|
setTimeout(() => this._onPairDeviceJoin(pairKey), 1000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.send({ type: 'pair-device-join', pairKey: pairKey });
|
this.send({ type: 'pair-device-join', pairKey: pairKey });
|
||||||
|
@ -85,7 +117,7 @@ class ServerConnection {
|
||||||
|
|
||||||
_onJoinPublicRoom(roomId, createIfInvalid) {
|
_onJoinPublicRoom(roomId, createIfInvalid) {
|
||||||
if (!this._isConnected()) {
|
if (!this._isConnected()) {
|
||||||
setTimeout(_ => this._onJoinPublicRoom(roomId), 1000);
|
setTimeout(() => this._onJoinPublicRoom(roomId), 1000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.send({ type: 'join-public-room', publicRoomId: roomId, createIfInvalid: createIfInvalid });
|
this.send({ type: 'join-public-room', publicRoomId: roomId, createIfInvalid: createIfInvalid });
|
||||||
|
@ -93,22 +125,18 @@ class ServerConnection {
|
||||||
|
|
||||||
_onLeavePublicRoom() {
|
_onLeavePublicRoom() {
|
||||||
if (!this._isConnected()) {
|
if (!this._isConnected()) {
|
||||||
setTimeout(_ => this._onLeavePublicRoom(), 1000);
|
setTimeout(() => this._onLeavePublicRoom(), 1000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.send({ type: 'leave-public-room' });
|
this.send({ type: 'leave-public-room' });
|
||||||
}
|
}
|
||||||
|
|
||||||
_setRtcConfig(config) {
|
|
||||||
window.rtcConfig = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
_onMessage(msg) {
|
_onMessage(msg) {
|
||||||
msg = JSON.parse(msg);
|
msg = JSON.parse(msg);
|
||||||
if (msg.type !== 'ping') console.log('WS receive:', msg);
|
if (msg.type !== 'ping') console.log('WS receive:', msg);
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'rtc-config':
|
case 'ws-config':
|
||||||
this._setRtcConfig(msg.config);
|
this._setWsConfig(msg.wsConfig);
|
||||||
break;
|
break;
|
||||||
case 'peers':
|
case 'peers':
|
||||||
this._onPeers(msg);
|
this._onPeers(msg);
|
||||||
|
@ -158,6 +186,25 @@ class ServerConnection {
|
||||||
case 'public-room-left':
|
case 'public-room-left':
|
||||||
Events.fire('public-room-left');
|
Events.fire('public-room-left');
|
||||||
break;
|
break;
|
||||||
|
case 'request':
|
||||||
|
case 'header':
|
||||||
|
case 'partition':
|
||||||
|
case 'partition-received':
|
||||||
|
case 'progress':
|
||||||
|
case 'files-transfer-response':
|
||||||
|
case 'file-transfer-complete':
|
||||||
|
case 'message-transfer-complete':
|
||||||
|
case 'text':
|
||||||
|
case 'display-name-changed':
|
||||||
|
case 'ws-chunk':
|
||||||
|
// ws-fallback
|
||||||
|
if (this._wsConfig.wsFallback) {
|
||||||
|
Events.fire('ws-relay', JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.log("WS receive: message type is for websocket fallback only but websocket fallback is not activated on this instance.")
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.error('WS receive: unknown message type', msg);
|
console.error('WS receive: unknown message type', msg);
|
||||||
}
|
}
|
||||||
|
@ -175,17 +222,20 @@ class ServerConnection {
|
||||||
|
|
||||||
_onDisplayName(msg) {
|
_onDisplayName(msg) {
|
||||||
// Add peerId and peerIdHash to sessionStorage to authenticate as the same device on page reload
|
// Add peerId and peerIdHash to sessionStorage to authenticate as the same device on page reload
|
||||||
sessionStorage.setItem('peer_id', msg.message.peerId);
|
sessionStorage.setItem('peer_id', msg.peerId);
|
||||||
sessionStorage.setItem('peer_id_hash', msg.message.peerIdHash);
|
sessionStorage.setItem('peer_id_hash', msg.peerIdHash);
|
||||||
|
|
||||||
// Add peerId to localStorage to mark it for other PairDrop tabs on the same browser
|
// Add peerId to localStorage to mark it for other PairDrop tabs on the same browser
|
||||||
BrowserTabsConnector.addPeerIdToLocalStorage().then(peerId => {
|
BrowserTabsConnector
|
||||||
|
.addPeerIdToLocalStorage()
|
||||||
|
.then(peerId => {
|
||||||
if (!peerId) return;
|
if (!peerId) return;
|
||||||
console.log("successfully added peerId to localStorage");
|
console.log("successfully added peerId to localStorage");
|
||||||
|
|
||||||
// Only now join rooms
|
// Only now join rooms
|
||||||
Events.fire('join-ip-room');
|
Events.fire('join-ip-room');
|
||||||
PersistentStorage.getAllRoomSecrets().then(roomSecrets => {
|
PersistentStorage.getAllRoomSecrets()
|
||||||
|
.then(roomSecrets => {
|
||||||
Events.fire('room-secrets', roomSecrets);
|
Events.fire('room-secrets', roomSecrets);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -194,24 +244,33 @@ class ServerConnection {
|
||||||
}
|
}
|
||||||
|
|
||||||
_endpoint() {
|
_endpoint() {
|
||||||
// hack to detect if deployment or development environment
|
|
||||||
const protocol = location.protocol.startsWith('https') ? 'wss' : 'ws';
|
const protocol = location.protocol.startsWith('https') ? 'wss' : 'ws';
|
||||||
const webrtc = window.isRtcSupported ? '/webrtc' : '/fallback';
|
// Check whether the instance specifies another signaling server otherwise use the current instance for signaling
|
||||||
let ws_url = new URL(protocol + '://' + location.host + location.pathname + 'server' + webrtc);
|
let wsServerDomain = this._config.signalingServer
|
||||||
|
? this._config.signalingServer
|
||||||
|
: location.host + location.pathname;
|
||||||
|
|
||||||
|
let wsUrl = new URL(protocol + '://' + wsServerDomain + 'server');
|
||||||
|
|
||||||
|
wsUrl.searchParams.append('webrtc_supported', window.isRtcSupported ? 'true' : 'false');
|
||||||
|
|
||||||
const peerId = sessionStorage.getItem('peer_id');
|
const peerId = sessionStorage.getItem('peer_id');
|
||||||
const peerIdHash = sessionStorage.getItem('peer_id_hash');
|
const peerIdHash = sessionStorage.getItem('peer_id_hash');
|
||||||
if (peerId && peerIdHash) {
|
if (peerId && peerIdHash) {
|
||||||
ws_url.searchParams.append('peer_id', peerId);
|
wsUrl.searchParams.append('peer_id', peerId);
|
||||||
ws_url.searchParams.append('peer_id_hash', peerIdHash);
|
wsUrl.searchParams.append('peer_id_hash', peerIdHash);
|
||||||
}
|
}
|
||||||
return ws_url.toString();
|
|
||||||
|
return wsUrl.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
_disconnect() {
|
_disconnect() {
|
||||||
this.send({ type: 'disconnect' });
|
this.send({ type: 'disconnect' });
|
||||||
|
|
||||||
const peerId = sessionStorage.getItem('peer_id');
|
const peerId = sessionStorage.getItem('peer_id');
|
||||||
BrowserTabsConnector.removePeerIdFromLocalStorage(peerId).then(_ => {
|
BrowserTabsConnector
|
||||||
|
.removePeerIdFromLocalStorage(peerId)
|
||||||
|
.then(_ => {
|
||||||
console.log("successfully removed peerId from localStorage");
|
console.log("successfully removed peerId from localStorage");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -229,7 +288,7 @@ class ServerConnection {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this._isReconnect = true;
|
this._isReconnect = true;
|
||||||
Events.fire('ws-disconnected');
|
Events.fire('ws-disconnected');
|
||||||
this._reconnectTimer = setTimeout(_ => this._connect(), 1000);
|
this._reconnectTimer = setTimeout(() => this._connect(), 1000);
|
||||||
}, 100); //delay for 100ms to prevent flickering on page reload
|
}, 100); //delay for 100ms to prevent flickering on page reload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -281,6 +340,9 @@ class Peer {
|
||||||
this._send(JSON.stringify(message));
|
this._send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Is overwritten in expanding classes
|
||||||
|
_send(message) {}
|
||||||
|
|
||||||
sendDisplayName(displayName) {
|
sendDisplayName(displayName) {
|
||||||
this.sendJSON({type: 'display-name-changed', displayName: displayName});
|
this.sendJSON({type: 'display-name-changed', displayName: displayName});
|
||||||
}
|
}
|
||||||
|
@ -297,16 +359,27 @@ class Peer {
|
||||||
return this._roomIds['secret'];
|
return this._roomIds['secret'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_regenerationOfPairSecretNeeded() {
|
||||||
|
return this._getPairSecret() && this._getPairSecret().length !== 256
|
||||||
|
}
|
||||||
|
|
||||||
_getRoomTypes() {
|
_getRoomTypes() {
|
||||||
return Object.keys(this._roomIds);
|
return Object.keys(this._roomIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateRoomIds(roomType, roomId) {
|
_updateRoomIds(roomType, roomId) {
|
||||||
|
const roomTypeIsSecret = roomType === "secret";
|
||||||
|
const roomIdIsNotPairSecret = this._getPairSecret() !== roomId;
|
||||||
|
|
||||||
// if peer is another browser tab, peer is not identifiable with roomSecret as browser tabs share all roomSecrets
|
// if peer is another browser tab, peer is not identifiable with roomSecret as browser tabs share all roomSecrets
|
||||||
// -> do not delete duplicates and do not regenerate room secrets
|
// -> do not delete duplicates and do not regenerate room secrets
|
||||||
if (!this._isSameBrowser() && roomType === "secret" && this._isPaired() && this._getPairSecret() !== roomId) {
|
if (!this._isSameBrowser()
|
||||||
|
&& roomTypeIsSecret
|
||||||
|
&& this._isPaired()
|
||||||
|
&& roomIdIsNotPairSecret) {
|
||||||
// multiple roomSecrets with same peer -> delete old roomSecret
|
// multiple roomSecrets with same peer -> delete old roomSecret
|
||||||
PersistentStorage.deleteRoomSecret(this._getPairSecret())
|
PersistentStorage
|
||||||
|
.deleteRoomSecret(this._getPairSecret())
|
||||||
.then(deletedRoomSecret => {
|
.then(deletedRoomSecret => {
|
||||||
if (deletedRoomSecret) console.log("Successfully deleted duplicate room secret with same peer: ", deletedRoomSecret);
|
if (deletedRoomSecret) console.log("Successfully deleted duplicate room secret with same peer: ", deletedRoomSecret);
|
||||||
});
|
});
|
||||||
|
@ -314,8 +387,13 @@ class Peer {
|
||||||
|
|
||||||
this._roomIds[roomType] = roomId;
|
this._roomIds[roomType] = roomId;
|
||||||
|
|
||||||
if (!this._isSameBrowser() && roomType === "secret" && this._isPaired() && this._getPairSecret().length !== 256 && this._isCaller) {
|
if (!this._isSameBrowser()
|
||||||
// increase security by initiating the increase of the roomSecret length from 64 chars (<v1.7.0) to 256 chars (v1.7.0+)
|
&& roomTypeIsSecret
|
||||||
|
&& this._isPaired()
|
||||||
|
&& this._regenerationOfPairSecretNeeded()
|
||||||
|
&& this._isCaller) {
|
||||||
|
// increase security by initiating the increase of the roomSecret length
|
||||||
|
// from 64 chars (<v1.7.0) to 256 chars (v1.7.0+)
|
||||||
console.log('RoomSecret is regenerated to increase security')
|
console.log('RoomSecret is regenerated to increase security')
|
||||||
Events.fire('regenerate-room-secret', this._getPairSecret());
|
Events.fire('regenerate-room-secret', this._getPairSecret());
|
||||||
}
|
}
|
||||||
|
@ -336,7 +414,8 @@ class Peer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PersistentStorage.getRoomSecretEntry(this._getPairSecret())
|
PersistentStorage
|
||||||
|
.getRoomSecretEntry(this._getPairSecret())
|
||||||
.then(roomSecretEntry => {
|
.then(roomSecretEntry => {
|
||||||
const autoAccept = roomSecretEntry
|
const autoAccept = roomSecretEntry
|
||||||
? roomSecretEntry.entry.auto_accept
|
? roomSecretEntry.entry.auto_accept
|
||||||
|
@ -367,13 +446,16 @@ class Peer {
|
||||||
if (width && height) {
|
if (width && height) {
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
} else if (width) {
|
}
|
||||||
|
else if (width) {
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = Math.floor(imageHeight * width / imageWidth)
|
canvas.height = Math.floor(imageHeight * width / imageWidth)
|
||||||
} else if (height) {
|
}
|
||||||
|
else if (height) {
|
||||||
canvas.width = Math.floor(imageWidth * height / imageHeight);
|
canvas.width = Math.floor(imageWidth * height / imageHeight);
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
canvas.width = imageWidth;
|
canvas.width = imageWidth;
|
||||||
canvas.height = imageHeight
|
canvas.height = imageHeight
|
||||||
}
|
}
|
||||||
|
@ -385,9 +467,11 @@ class Peer {
|
||||||
resolve(dataUrl);
|
resolve(dataUrl);
|
||||||
}
|
}
|
||||||
image.onerror = _ => reject(`Could not create an image thumbnail from type ${file.type}`);
|
image.onerror = _ => reject(`Could not create an image thumbnail from type ${file.type}`);
|
||||||
}).then(dataUrl => {
|
})
|
||||||
|
.then(dataUrl => {
|
||||||
return dataUrl;
|
return dataUrl;
|
||||||
}).catch(e => console.error(e));
|
})
|
||||||
|
.catch(e => console.error(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestFileTransfer(files) {
|
async requestFileTransfer(files) {
|
||||||
|
@ -622,7 +706,8 @@ class Peer {
|
||||||
this._busy = false;
|
this._busy = false;
|
||||||
Events.fire('notify-user', Localization.getTranslation("notifications.file-transfer-completed"));
|
Events.fire('notify-user', Localization.getTranslation("notifications.file-transfer-completed"));
|
||||||
Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app
|
Events.fire('files-sent'); // used by 'Snapdrop & PairDrop for Android' app
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
this._dequeueFile();
|
this._dequeueFile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -673,9 +758,12 @@ class Peer {
|
||||||
|
|
||||||
class RTCPeer extends Peer {
|
class RTCPeer extends Peer {
|
||||||
|
|
||||||
constructor(serverConnection, isCaller, peerId, roomType, roomId) {
|
constructor(serverConnection, isCaller, peerId, roomType, roomId, rtcConfig) {
|
||||||
super(serverConnection, isCaller, peerId, roomType, roomId);
|
super(serverConnection, isCaller, peerId, roomType, roomId);
|
||||||
|
|
||||||
this.rtcSupported = true;
|
this.rtcSupported = true;
|
||||||
|
this.rtcConfig = rtcConfig
|
||||||
|
|
||||||
if (!this._isCaller) return; // we will listen for a caller
|
if (!this._isCaller) return; // we will listen for a caller
|
||||||
this._connect();
|
this._connect();
|
||||||
}
|
}
|
||||||
|
@ -685,13 +773,14 @@ class RTCPeer extends Peer {
|
||||||
|
|
||||||
if (this._isCaller) {
|
if (this._isCaller) {
|
||||||
this._openChannel();
|
this._openChannel();
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
this._conn.ondatachannel = e => this._onChannelOpened(e);
|
this._conn.ondatachannel = e => this._onChannelOpened(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_openConnection() {
|
_openConnection() {
|
||||||
this._conn = new RTCPeerConnection(window.rtcConfig);
|
this._conn = new RTCPeerConnection(this.rtcConfig);
|
||||||
this._conn.onicecandidate = e => this._onIceCandidate(e);
|
this._conn.onicecandidate = e => this._onIceCandidate(e);
|
||||||
this._conn.onicecandidateerror = e => this._onError(e);
|
this._conn.onicecandidateerror = e => this._onError(e);
|
||||||
this._conn.onconnectionstatechange = _ => this._onConnectionStateChange();
|
this._conn.onconnectionstatechange = _ => this._onConnectionStateChange();
|
||||||
|
@ -708,14 +797,16 @@ class RTCPeer extends Peer {
|
||||||
channel.onopen = e => this._onChannelOpened(e);
|
channel.onopen = e => this._onChannelOpened(e);
|
||||||
channel.onerror = e => this._onError(e);
|
channel.onerror = e => this._onError(e);
|
||||||
|
|
||||||
this._conn.createOffer()
|
this._conn
|
||||||
|
.createOffer()
|
||||||
.then(d => this._onDescription(d))
|
.then(d => this._onDescription(d))
|
||||||
.catch(e => this._onError(e));
|
.catch(e => this._onError(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
_onDescription(description) {
|
_onDescription(description) {
|
||||||
// description.sdp = description.sdp.replace('b=AS:30', 'b=AS:1638400');
|
// description.sdp = description.sdp.replace('b=AS:30', 'b=AS:1638400');
|
||||||
this._conn.setLocalDescription(description)
|
this._conn
|
||||||
|
.setLocalDescription(description)
|
||||||
.then(_ => this._sendSignal({ sdp: description }))
|
.then(_ => this._sendSignal({ sdp: description }))
|
||||||
.catch(e => this._onError(e));
|
.catch(e => this._onError(e));
|
||||||
}
|
}
|
||||||
|
@ -729,16 +820,20 @@ class RTCPeer extends Peer {
|
||||||
if (!this._conn) this._connect();
|
if (!this._conn) this._connect();
|
||||||
|
|
||||||
if (message.sdp) {
|
if (message.sdp) {
|
||||||
this._conn.setRemoteDescription(message.sdp)
|
this._conn
|
||||||
|
.setRemoteDescription(message.sdp)
|
||||||
.then(_ => {
|
.then(_ => {
|
||||||
if (message.sdp.type === 'offer') {
|
if (message.sdp.type === 'offer') {
|
||||||
return this._conn.createAnswer()
|
return this._conn
|
||||||
|
.createAnswer()
|
||||||
.then(d => this._onDescription(d));
|
.then(d => this._onDescription(d));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(e => this._onError(e));
|
.catch(e => this._onError(e));
|
||||||
} else if (message.ice) {
|
}
|
||||||
this._conn.addIceCandidate(new RTCIceCandidate(message.ice))
|
else if (message.ice) {
|
||||||
|
this._conn
|
||||||
|
.addIceCandidate(new RTCIceCandidate(message.ice))
|
||||||
.catch(e => this._onError(e));
|
.catch(e => this._onError(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -879,6 +974,48 @@ class RTCPeer extends Peer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WSPeer extends Peer {
|
||||||
|
|
||||||
|
constructor(serverConnection, isCaller, peerId, roomType, roomId) {
|
||||||
|
super(serverConnection, isCaller, peerId, roomType, roomId);
|
||||||
|
|
||||||
|
this.rtcSupported = false;
|
||||||
|
|
||||||
|
if (!this._isCaller) return; // we will listen for a caller
|
||||||
|
this._sendSignal();
|
||||||
|
}
|
||||||
|
|
||||||
|
_send(chunk) {
|
||||||
|
this.sendJSON({
|
||||||
|
type: 'ws-chunk',
|
||||||
|
chunk: arrayBufferToBase64(chunk)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJSON(message) {
|
||||||
|
message.to = this._peerId;
|
||||||
|
message.roomType = this._getRoomTypes()[0];
|
||||||
|
message.roomId = this._roomIds[this._getRoomTypes()[0]];
|
||||||
|
this._server.send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendSignal(connected = false) {
|
||||||
|
this.sendJSON({type: 'signal', connected: connected});
|
||||||
|
}
|
||||||
|
|
||||||
|
onServerMessage(message) {
|
||||||
|
this._peerId = message.sender.id;
|
||||||
|
Events.fire('peer-connected', {peerId: message.sender.id, connectionHash: this.getConnectionHash()})
|
||||||
|
if (message.connected) return;
|
||||||
|
this._sendSignal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectionHash() {
|
||||||
|
// Todo: implement SubtleCrypto asymmetric encryption and create connectionHash from public keys
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PeersManager {
|
class PeersManager {
|
||||||
|
|
||||||
constructor(serverConnection) {
|
constructor(serverConnection) {
|
||||||
|
@ -902,10 +1039,17 @@ class PeersManager {
|
||||||
Events.on('secret-room-deleted', e => this._onSecretRoomDeleted(e.detail));
|
Events.on('secret-room-deleted', e => this._onSecretRoomDeleted(e.detail));
|
||||||
|
|
||||||
Events.on('room-secret-regenerated', e => this._onRoomSecretRegenerated(e.detail));
|
Events.on('room-secret-regenerated', e => this._onRoomSecretRegenerated(e.detail));
|
||||||
Events.on('display-name', e => this._onDisplayName(e.detail.message.displayName));
|
Events.on('display-name', e => this._onDisplayName(e.detail.displayName));
|
||||||
Events.on('self-display-name-changed', e => this._notifyPeersDisplayNameChanged(e.detail));
|
Events.on('self-display-name-changed', e => this._notifyPeersDisplayNameChanged(e.detail));
|
||||||
Events.on('notify-peer-display-name-changed', e => this._notifyPeerDisplayNameChanged(e.detail));
|
Events.on('notify-peer-display-name-changed', e => this._notifyPeerDisplayNameChanged(e.detail));
|
||||||
Events.on('auto-accept-updated', e => this._onAutoAcceptUpdated(e.detail.roomSecret, e.detail.autoAccept));
|
Events.on('auto-accept-updated', e => this._onAutoAcceptUpdated(e.detail.roomSecret, e.detail.autoAccept));
|
||||||
|
Events.on('ws-disconnected', _ => this._onWsDisconnected());
|
||||||
|
Events.on('ws-relay', e => this._onWsRelay(e.detail));
|
||||||
|
Events.on('ws-config', e => this._onWsConfig(e.detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onWsConfig(wsConfig) {
|
||||||
|
this._wsConfig = wsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
_onMessage(message) {
|
_onMessage(message) {
|
||||||
|
@ -913,9 +1057,10 @@ class PeersManager {
|
||||||
this.peers[peerId].onServerMessage(message);
|
this.peers[peerId].onServerMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
_refreshPeer(peer, roomType, roomId) {
|
_refreshPeer(peerId, roomType, roomId) {
|
||||||
if (!peer) return false;
|
if (!this._peerExists(peerId)) return false;
|
||||||
|
|
||||||
|
const peer = this.peers[peerId];
|
||||||
const roomTypesDiffer = Object.keys(peer._roomIds)[0] !== roomType;
|
const roomTypesDiffer = Object.keys(peer._roomIds)[0] !== roomType;
|
||||||
const roomIdsDiffer = peer._roomIds[roomType] !== roomId;
|
const roomIdsDiffer = peer._roomIds[roomType] !== roomId;
|
||||||
|
|
||||||
|
@ -933,26 +1078,42 @@ class PeersManager {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_createOrRefreshPeer(isCaller, peerId, roomType, roomId) {
|
_createOrRefreshPeer(isCaller, peerId, roomType, roomId, rtcSupported) {
|
||||||
const peer = this.peers[peerId];
|
if (this._peerExists(peerId)) {
|
||||||
if (peer) {
|
this._refreshPeer(peerId, roomType, roomId);
|
||||||
this._refreshPeer(peer, roomType, roomId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.peers[peerId] = new RTCPeer(this._server, isCaller, peerId, roomType, roomId);
|
if (window.isRtcSupported && rtcSupported) {
|
||||||
|
this.peers[peerId] = new RTCPeer(this._server, isCaller, peerId, roomType, roomId, this._wsConfig.rtcConfig);
|
||||||
|
}
|
||||||
|
else if (this._wsConfig.wsFallback) {
|
||||||
|
this.peers[peerId] = new WSPeer(this._server, isCaller, peerId, roomType, roomId);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.warn("Websocket fallback is not activated on this instance.\n" +
|
||||||
|
"Activate WebRTC in this browser or ask the admin of this instance to activate the websocket fallback.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onPeerJoined(message) {
|
_onPeerJoined(message) {
|
||||||
this._createOrRefreshPeer(false, message.peer.id, message.roomType, message.roomId);
|
this._createOrRefreshPeer(false, message.peer.id, message.roomType, message.roomId, message.peer.rtcSupported);
|
||||||
}
|
}
|
||||||
|
|
||||||
_onPeers(message) {
|
_onPeers(message) {
|
||||||
message.peers.forEach(peer => {
|
message.peers.forEach(peer => {
|
||||||
this._createOrRefreshPeer(true, peer.id, message.roomType, message.roomId);
|
this._createOrRefreshPeer(true, peer.id, message.roomType, message.roomId, peer.rtcSupported);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_onWsRelay(message) {
|
||||||
|
if (!this._wsConfig.wsFallback) return;
|
||||||
|
|
||||||
|
const messageJSON = JSON.parse(message);
|
||||||
|
if (messageJSON.type === 'ws-chunk') message = base64ToArrayBuffer(messageJSON.chunk);
|
||||||
|
this.peers[messageJSON.sender.id]._onMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
_onRespondToFileTransferRequest(detail) {
|
_onRespondToFileTransferRequest(detail) {
|
||||||
this.peers[detail.to]._respondToFileTransferRequest(detail.accepted);
|
this.peers[detail.to]._respondToFileTransferRequest(detail.accepted);
|
||||||
}
|
}
|
||||||
|
@ -978,6 +1139,9 @@ class PeersManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
_onPeerLeft(message) {
|
_onPeerLeft(message) {
|
||||||
|
if (this._peerExists(message.peerId) && this._webRtcSupported(message.peerId)) {
|
||||||
|
console.log('WSPeer left:', message.peerId);
|
||||||
|
}
|
||||||
if (message.disconnect === true) {
|
if (message.disconnect === true) {
|
||||||
// if user actively disconnected from PairDrop server, disconnect all peer to peer connections immediately
|
// if user actively disconnected from PairDrop server, disconnect all peer to peer connections immediately
|
||||||
this._disconnectOrRemoveRoomTypeByPeerId(message.peerId, message.roomType);
|
this._disconnectOrRemoveRoomTypeByPeerId(message.peerId, message.roomType);
|
||||||
|
@ -985,7 +1149,9 @@ class PeersManager {
|
||||||
// If no peers are connected anymore, we can safely assume that no other tab on the same browser is connected:
|
// If no peers are connected anymore, we can safely assume that no other tab on the same browser is connected:
|
||||||
// Tidy up peerIds in localStorage
|
// Tidy up peerIds in localStorage
|
||||||
if (Object.keys(this.peers).length === 0) {
|
if (Object.keys(this.peers).length === 0) {
|
||||||
BrowserTabsConnector.removeOtherPeerIdsFromLocalStorage().then(peerIds => {
|
BrowserTabsConnector
|
||||||
|
.removeOtherPeerIdsFromLocalStorage()
|
||||||
|
.then(peerIds => {
|
||||||
if (!peerIds) return;
|
if (!peerIds) return;
|
||||||
console.log("successfully removed other peerIds from localStorage");
|
console.log("successfully removed other peerIds from localStorage");
|
||||||
});
|
});
|
||||||
|
@ -997,6 +1163,24 @@ class PeersManager {
|
||||||
this._notifyPeerDisplayNameChanged(peerId);
|
this._notifyPeerDisplayNameChanged(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_peerExists(peerId) {
|
||||||
|
return !!this.peers[peerId];
|
||||||
|
}
|
||||||
|
|
||||||
|
_webRtcSupported(peerId) {
|
||||||
|
return this.peers[peerId].rtcSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
_onWsDisconnected() {
|
||||||
|
if (!this._wsConfig || !this._wsConfig.wsFallback) return;
|
||||||
|
|
||||||
|
for (const peerId in this.peers) {
|
||||||
|
if (!this._webRtcSupported(peerId)) {
|
||||||
|
Events.fire('peer-disconnected', peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_onPeerDisconnected(peerId) {
|
_onPeerDisconnected(peerId) {
|
||||||
const peer = this.peers[peerId];
|
const peer = this.peers[peerId];
|
||||||
delete this.peers[peerId];
|
delete this.peers[peerId];
|
||||||
|
@ -1038,13 +1222,16 @@ class PeersManager {
|
||||||
|
|
||||||
if (peer._getRoomTypes().length > 1) {
|
if (peer._getRoomTypes().length > 1) {
|
||||||
peer._removeRoomType(roomType);
|
peer._removeRoomType(roomType);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
Events.fire('peer-disconnected', peerId);
|
Events.fire('peer-disconnected', peerId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onRoomSecretRegenerated(message) {
|
_onRoomSecretRegenerated(message) {
|
||||||
PersistentStorage.updateRoomSecret(message.oldRoomSecret, message.newRoomSecret).then(_ => {
|
PersistentStorage
|
||||||
|
.updateRoomSecret(message.oldRoomSecret, message.newRoomSecret)
|
||||||
|
.then(_ => {
|
||||||
console.log("successfully regenerated room secret");
|
console.log("successfully regenerated room secret");
|
||||||
Events.fire("room-secrets", [message.newRoomSecret]);
|
Events.fire("room-secrets", [message.newRoomSecret]);
|
||||||
})
|
})
|
||||||
|
@ -1173,17 +1360,3 @@ class FileDigester {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Events {
|
|
||||||
static fire(type, detail = {}) {
|
|
||||||
window.dispatchEvent(new CustomEvent(type, { detail: detail }));
|
|
||||||
}
|
|
||||||
|
|
||||||
static on(type, callback, options = false) {
|
|
||||||
return window.addEventListener(type, callback, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
static off(type, callback, options = false) {
|
|
||||||
return window.removeEventListener(type, callback, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
299
public/scripts/persistent-storage.js
Normal file
|
@ -0,0 +1,299 @@
|
||||||
|
class PersistentStorage {
|
||||||
|
constructor() {
|
||||||
|
if (!('indexedDB' in window)) {
|
||||||
|
PersistentStorage.logBrowserNotCapable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const DBOpenRequest = window.indexedDB.open('pairdrop_store', 4);
|
||||||
|
DBOpenRequest.onerror = e => {
|
||||||
|
PersistentStorage.logBrowserNotCapable();
|
||||||
|
console.log('Error initializing database: ');
|
||||||
|
console.log(e)
|
||||||
|
};
|
||||||
|
DBOpenRequest.onsuccess = _ => {
|
||||||
|
console.log('Database initialised.');
|
||||||
|
};
|
||||||
|
DBOpenRequest.onupgradeneeded = e => {
|
||||||
|
const db = e.target.result;
|
||||||
|
const txn = e.target.transaction;
|
||||||
|
|
||||||
|
db.onerror = e => console.log('Error loading database: ' + e);
|
||||||
|
|
||||||
|
console.log(`Upgrading IndexedDB database from version ${e.oldVersion} to version ${e.newVersion}`);
|
||||||
|
|
||||||
|
if (e.oldVersion === 0) {
|
||||||
|
// initiate v1
|
||||||
|
db.createObjectStore('keyval');
|
||||||
|
let roomSecretsObjectStore1 = db.createObjectStore('room_secrets', {autoIncrement: true});
|
||||||
|
roomSecretsObjectStore1.createIndex('secret', 'secret', { unique: true });
|
||||||
|
}
|
||||||
|
if (e.oldVersion <= 1) {
|
||||||
|
// migrate to v2
|
||||||
|
db.createObjectStore('share_target_files');
|
||||||
|
}
|
||||||
|
if (e.oldVersion <= 2) {
|
||||||
|
// migrate to v3
|
||||||
|
db.deleteObjectStore('share_target_files');
|
||||||
|
db.createObjectStore('share_target_files', {autoIncrement: true});
|
||||||
|
}
|
||||||
|
if (e.oldVersion <= 3) {
|
||||||
|
// migrate to v4
|
||||||
|
let roomSecretsObjectStore4 = txn.objectStore('room_secrets');
|
||||||
|
roomSecretsObjectStore4.createIndex('display_name', 'display_name');
|
||||||
|
roomSecretsObjectStore4.createIndex('auto_accept', 'auto_accept');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static logBrowserNotCapable() {
|
||||||
|
console.log("This browser does not support IndexedDB. Paired devices will be gone after the browser is closed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static set(key, value) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||||
|
DBOpenRequest.onsuccess = e => {
|
||||||
|
const db = e.target.result;
|
||||||
|
const transaction = db.transaction('keyval', 'readwrite');
|
||||||
|
const objectStore = transaction.objectStore('keyval');
|
||||||
|
const objectStoreRequest = objectStore.put(value, key);
|
||||||
|
objectStoreRequest.onsuccess = _ => {
|
||||||
|
console.log(`Request successful. Added key-pair: ${key} - ${value}`);
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
DBOpenRequest.onerror = e => {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static get(key) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||||
|
DBOpenRequest.onsuccess = e => {
|
||||||
|
const db = e.target.result;
|
||||||
|
const transaction = db.transaction('keyval', 'readonly');
|
||||||
|
const objectStore = transaction.objectStore('keyval');
|
||||||
|
const objectStoreRequest = objectStore.get(key);
|
||||||
|
objectStoreRequest.onsuccess = _ => {
|
||||||
|
console.log(`Request successful. Retrieved key-pair: ${key} - ${objectStoreRequest.result}`);
|
||||||
|
resolve(objectStoreRequest.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DBOpenRequest.onerror = e => {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static delete(key) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||||
|
DBOpenRequest.onsuccess = e => {
|
||||||
|
const db = e.target.result;
|
||||||
|
const transaction = db.transaction('keyval', 'readwrite');
|
||||||
|
const objectStore = transaction.objectStore('keyval');
|
||||||
|
const objectStoreRequest = objectStore.delete(key);
|
||||||
|
objectStoreRequest.onsuccess = _ => {
|
||||||
|
console.log(`Request successful. Deleted key: ${key}`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
DBOpenRequest.onerror = e => {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static addRoomSecret(roomSecret, displayName, deviceName) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||||
|
DBOpenRequest.onsuccess = e => {
|
||||||
|
const db = e.target.result;
|
||||||
|
const transaction = db.transaction('room_secrets', 'readwrite');
|
||||||
|
const objectStore = transaction.objectStore('room_secrets');
|
||||||
|
const objectStoreRequest = objectStore.add({
|
||||||
|
'secret': roomSecret,
|
||||||
|
'display_name': displayName,
|
||||||
|
'device_name': deviceName,
|
||||||
|
'auto_accept': false
|
||||||
|
});
|
||||||
|
objectStoreRequest.onsuccess = e => {
|
||||||
|
console.log(`Request successful. RoomSecret added: ${e.target.result}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DBOpenRequest.onerror = e => {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getAllRoomSecrets() {
|
||||||
|
try {
|
||||||
|
const roomSecrets = await this.getAllRoomSecretEntries();
|
||||||
|
let secrets = [];
|
||||||
|
for (let i = 0; i < roomSecrets.length; i++) {
|
||||||
|
secrets.push(roomSecrets[i].secret);
|
||||||
|
}
|
||||||
|
console.log(`Request successful. Retrieved ${secrets.length} room_secrets`);
|
||||||
|
return(secrets);
|
||||||
|
} catch (e) {
|
||||||
|
this.logBrowserNotCapable();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getAllRoomSecretEntries() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||||
|
DBOpenRequest.onsuccess = (e) => {
|
||||||
|
const db = e.target.result;
|
||||||
|
const transaction = db.transaction('room_secrets', 'readonly');
|
||||||
|
const objectStore = transaction.objectStore('room_secrets');
|
||||||
|
const objectStoreRequest = objectStore.getAll();
|
||||||
|
objectStoreRequest.onsuccess = e => {
|
||||||
|
resolve(e.target.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DBOpenRequest.onerror = (e) => {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRoomSecretEntry(roomSecret) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||||
|
DBOpenRequest.onsuccess = e => {
|
||||||
|
const db = e.target.result;
|
||||||
|
const transaction = db.transaction('room_secrets', 'readonly');
|
||||||
|
const objectStore = transaction.objectStore('room_secrets');
|
||||||
|
const objectStoreRequestKey = objectStore.index("secret").getKey(roomSecret);
|
||||||
|
objectStoreRequestKey.onsuccess = e => {
|
||||||
|
const key = e.target.result;
|
||||||
|
if (!key) {
|
||||||
|
console.log(`Nothing to retrieve. Entry for room_secret not existing: ${roomSecret}`);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const objectStoreRequestRetrieval = objectStore.get(key);
|
||||||
|
objectStoreRequestRetrieval.onsuccess = e => {
|
||||||
|
console.log(`Request successful. Retrieved entry for room_secret: ${key}`);
|
||||||
|
resolve({
|
||||||
|
"entry": e.target.result,
|
||||||
|
"key": key
|
||||||
|
});
|
||||||
|
}
|
||||||
|
objectStoreRequestRetrieval.onerror = (e) => {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
DBOpenRequest.onerror = (e) => {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static deleteRoomSecret(roomSecret) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||||
|
DBOpenRequest.onsuccess = (e) => {
|
||||||
|
const db = e.target.result;
|
||||||
|
const transaction = db.transaction('room_secrets', 'readwrite');
|
||||||
|
const objectStore = transaction.objectStore('room_secrets');
|
||||||
|
const objectStoreRequestKey = objectStore.index("secret").getKey(roomSecret);
|
||||||
|
objectStoreRequestKey.onsuccess = e => {
|
||||||
|
if (!e.target.result) {
|
||||||
|
console.log(`Nothing to delete. room_secret not existing: ${roomSecret}`);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = e.target.result;
|
||||||
|
const objectStoreRequestDeletion = objectStore.delete(key);
|
||||||
|
objectStoreRequestDeletion.onsuccess = _ => {
|
||||||
|
console.log(`Request successful. Deleted room_secret: ${key}`);
|
||||||
|
resolve(roomSecret);
|
||||||
|
}
|
||||||
|
objectStoreRequestDeletion.onerror = e => {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
DBOpenRequest.onerror = e => {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static clearRoomSecrets() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||||
|
DBOpenRequest.onsuccess = (e) => {
|
||||||
|
const db = e.target.result;
|
||||||
|
const transaction = db.transaction('room_secrets', 'readwrite');
|
||||||
|
const objectStore = transaction.objectStore('room_secrets');
|
||||||
|
const objectStoreRequest = objectStore.clear();
|
||||||
|
objectStoreRequest.onsuccess = _ => {
|
||||||
|
console.log('Request successful. All room_secrets cleared');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
DBOpenRequest.onerror = e => {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static updateRoomSecretNames(roomSecret, displayName, deviceName) {
|
||||||
|
return this.updateRoomSecret(roomSecret, undefined, displayName, deviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
static updateRoomSecretAutoAccept(roomSecret, autoAccept) {
|
||||||
|
return this.updateRoomSecret(roomSecret, undefined, undefined, undefined, autoAccept);
|
||||||
|
}
|
||||||
|
|
||||||
|
static updateRoomSecret(roomSecret, updatedRoomSecret = undefined, updatedDisplayName = undefined, updatedDeviceName = undefined, updatedAutoAccept = undefined) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const DBOpenRequest = window.indexedDB.open('pairdrop_store');
|
||||||
|
DBOpenRequest.onsuccess = e => {
|
||||||
|
const db = e.target.result;
|
||||||
|
this.getRoomSecretEntry(roomSecret)
|
||||||
|
.then(roomSecretEntry => {
|
||||||
|
if (!roomSecretEntry) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const transaction = db.transaction('room_secrets', 'readwrite');
|
||||||
|
const objectStore = transaction.objectStore('room_secrets');
|
||||||
|
// Do not use `updatedRoomSecret ?? roomSecretEntry.entry.secret` to ensure compatibility with older browsers
|
||||||
|
const updatedRoomSecretEntry = {
|
||||||
|
'secret': updatedRoomSecret !== undefined ? updatedRoomSecret : roomSecretEntry.entry.secret,
|
||||||
|
'display_name': updatedDisplayName !== undefined ? updatedDisplayName : roomSecretEntry.entry.display_name,
|
||||||
|
'device_name': updatedDeviceName !== undefined ? updatedDeviceName : roomSecretEntry.entry.device_name,
|
||||||
|
'auto_accept': updatedAutoAccept !== undefined ? updatedAutoAccept : roomSecretEntry.entry.auto_accept
|
||||||
|
};
|
||||||
|
|
||||||
|
const objectStoreRequestUpdate = objectStore.put(updatedRoomSecretEntry, roomSecretEntry.key);
|
||||||
|
|
||||||
|
objectStoreRequestUpdate.onsuccess = e => {
|
||||||
|
console.log(`Request successful. Updated room_secret: ${roomSecretEntry.key}`);
|
||||||
|
resolve({
|
||||||
|
"entry": updatedRoomSecretEntry,
|
||||||
|
"key": roomSecretEntry.key
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
objectStoreRequestUpdate.onerror = (e) => {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => reject(e));
|
||||||
|
};
|
||||||
|
|
||||||
|
DBOpenRequest.onerror = e => reject(e);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,78 +0,0 @@
|
||||||
(function(){
|
|
||||||
|
|
||||||
const prefersDarkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
const prefersLightTheme = window.matchMedia('(prefers-color-scheme: light)').matches;
|
|
||||||
|
|
||||||
const $themeAuto = document.getElementById('theme-auto');
|
|
||||||
const $themeLight = document.getElementById('theme-light');
|
|
||||||
const $themeDark = document.getElementById('theme-dark');
|
|
||||||
|
|
||||||
let currentTheme = localStorage.getItem('theme');
|
|
||||||
|
|
||||||
if (currentTheme === 'dark') {
|
|
||||||
setModeToDark();
|
|
||||||
} else if (currentTheme === 'light') {
|
|
||||||
setModeToLight();
|
|
||||||
}
|
|
||||||
|
|
||||||
$themeAuto.addEventListener('click', _ => {
|
|
||||||
if (currentTheme) {
|
|
||||||
setModeToAuto();
|
|
||||||
} else {
|
|
||||||
setModeToDark();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$themeLight.addEventListener('click', _ => {
|
|
||||||
if (currentTheme !== 'light') {
|
|
||||||
setModeToLight();
|
|
||||||
} else {
|
|
||||||
setModeToAuto();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$themeDark.addEventListener('click', _ => {
|
|
||||||
if (currentTheme !== 'dark') {
|
|
||||||
setModeToDark();
|
|
||||||
} else {
|
|
||||||
setModeToLight();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function setModeToDark() {
|
|
||||||
document.body.classList.remove('light-theme');
|
|
||||||
document.body.classList.add('dark-theme');
|
|
||||||
localStorage.setItem('theme', 'dark');
|
|
||||||
currentTheme = 'dark';
|
|
||||||
|
|
||||||
$themeAuto.classList.remove("selected");
|
|
||||||
$themeLight.classList.remove("selected");
|
|
||||||
$themeDark.classList.add("selected");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setModeToLight() {
|
|
||||||
document.body.classList.remove('dark-theme');
|
|
||||||
document.body.classList.add('light-theme');
|
|
||||||
localStorage.setItem('theme', 'light');
|
|
||||||
currentTheme = 'light';
|
|
||||||
|
|
||||||
$themeAuto.classList.remove("selected");
|
|
||||||
$themeLight.classList.add("selected");
|
|
||||||
$themeDark.classList.remove("selected");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setModeToAuto() {
|
|
||||||
document.body.classList.remove('dark-theme');
|
|
||||||
document.body.classList.remove('light-theme');
|
|
||||||
if (prefersDarkTheme) {
|
|
||||||
document.body.classList.add('dark-theme');
|
|
||||||
} else if (prefersLightTheme) {
|
|
||||||
document.body.classList.add('light-theme');
|
|
||||||
}
|
|
||||||
localStorage.removeItem('theme');
|
|
||||||
currentTheme = undefined;
|
|
||||||
|
|
||||||
$themeAuto.classList.add("selected");
|
|
||||||
$themeLight.classList.remove("selected");
|
|
||||||
$themeDark.classList.remove("selected");
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
286
public/scripts/ui-main.js
Normal file
|
@ -0,0 +1,286 @@
|
||||||
|
// Selector shortcuts
|
||||||
|
const $ = query => document.getElementById(query);
|
||||||
|
const $$ = query => document.querySelector(query);
|
||||||
|
|
||||||
|
// Event listener shortcuts
|
||||||
|
class Events {
|
||||||
|
static fire(type, detail = {}) {
|
||||||
|
window.dispatchEvent(new CustomEvent(type, { detail: detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
static on(type, callback, options) {
|
||||||
|
return window.addEventListener(type, callback, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
static off(type, callback, options) {
|
||||||
|
return window.removeEventListener(type, callback, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UIs needed on start
|
||||||
|
class ThemeUI {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.prefersDarkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
this.prefersLightTheme = window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||||
|
|
||||||
|
this.$themeAutoBtn = document.getElementById('theme-auto');
|
||||||
|
this.$themeLightBtn = document.getElementById('theme-light');
|
||||||
|
this.$themeDarkBtn = document.getElementById('theme-dark');
|
||||||
|
|
||||||
|
let currentTheme = this.getCurrentTheme();
|
||||||
|
if (currentTheme === 'dark') {
|
||||||
|
this.setModeToDark();
|
||||||
|
} else if (currentTheme === 'light') {
|
||||||
|
this.setModeToLight();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$themeAutoBtn.addEventListener('click', _ => this.onClickAuto());
|
||||||
|
this.$themeLightBtn.addEventListener('click', _ => this.onClickLight());
|
||||||
|
this.$themeDarkBtn.addEventListener('click', _ => this.onClickDark());
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentTheme() {
|
||||||
|
return localStorage.getItem('theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentTheme(theme) {
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickAuto() {
|
||||||
|
if (this.getCurrentTheme()) {
|
||||||
|
this.setModeToAuto();
|
||||||
|
} else {
|
||||||
|
this.setModeToDark();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickLight() {
|
||||||
|
if (this.getCurrentTheme() !== 'light') {
|
||||||
|
this.setModeToLight();
|
||||||
|
} else {
|
||||||
|
this.setModeToAuto();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickDark() {
|
||||||
|
if (this.getCurrentTheme() !== 'dark') {
|
||||||
|
this.setModeToDark();
|
||||||
|
} else {
|
||||||
|
this.setModeToLight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setModeToDark() {
|
||||||
|
document.body.classList.remove('light-theme');
|
||||||
|
document.body.classList.add('dark-theme');
|
||||||
|
|
||||||
|
this.setCurrentTheme('dark');
|
||||||
|
|
||||||
|
this.$themeAutoBtn.classList.remove("selected");
|
||||||
|
this.$themeLightBtn.classList.remove("selected");
|
||||||
|
this.$themeDarkBtn.classList.add("selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
setModeToLight() {
|
||||||
|
document.body.classList.remove('dark-theme');
|
||||||
|
document.body.classList.add('light-theme');
|
||||||
|
|
||||||
|
this.setCurrentTheme('light');
|
||||||
|
|
||||||
|
this.$themeAutoBtn.classList.remove("selected");
|
||||||
|
this.$themeLightBtn.classList.add("selected");
|
||||||
|
this.$themeDarkBtn.classList.remove("selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
setModeToAuto() {
|
||||||
|
document.body.classList.remove('dark-theme');
|
||||||
|
document.body.classList.remove('light-theme');
|
||||||
|
if (this.prefersDarkTheme) {
|
||||||
|
document.body.classList.add('dark-theme');
|
||||||
|
}
|
||||||
|
else if (this.prefersLightTheme) {
|
||||||
|
document.body.classList.add('light-theme');
|
||||||
|
}
|
||||||
|
localStorage.removeItem('theme');
|
||||||
|
|
||||||
|
this.$themeAutoBtn.classList.add("selected");
|
||||||
|
this.$themeLightBtn.classList.remove("selected");
|
||||||
|
this.$themeDarkBtn.classList.remove("selected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FooterUI {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.$displayName = $('display-name');
|
||||||
|
this.$discoveryWrapper = $$('footer .discovery-wrapper');
|
||||||
|
|
||||||
|
// Show "Loading…"
|
||||||
|
this.$displayName.setAttribute('placeholder', this.$displayName.dataset.placeholder);
|
||||||
|
|
||||||
|
this.$displayName.addEventListener('keydown', e => this._onKeyDownDisplayName(e));
|
||||||
|
this.$displayName.addEventListener('keyup', e => this._onKeyUpDisplayName(e));
|
||||||
|
this.$displayName.addEventListener('blur', e => this._saveDisplayName(e.target.innerText));
|
||||||
|
|
||||||
|
Events.on('display-name', e => this._onDisplayName(e.detail.displayName));
|
||||||
|
Events.on('self-display-name-changed', e => this._insertDisplayName(e.detail));
|
||||||
|
|
||||||
|
// Load saved display name on page load
|
||||||
|
Events.on('ws-connected', _ => this._loadSavedDisplayName());
|
||||||
|
|
||||||
|
Events.on('evaluate-footer-badges', _ => this._evaluateFooterBadges());
|
||||||
|
}
|
||||||
|
|
||||||
|
_evaluateFooterBadges() {
|
||||||
|
if (this.$discoveryWrapper.querySelectorAll('div:last-of-type > span[hidden]').length < 2) {
|
||||||
|
this.$discoveryWrapper.classList.remove('row');
|
||||||
|
this.$discoveryWrapper.classList.add('column');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.$discoveryWrapper.classList.remove('column');
|
||||||
|
this.$discoveryWrapper.classList.add('row');
|
||||||
|
}
|
||||||
|
Events.fire('redraw-canvas');
|
||||||
|
Events.fire('fade-in-ui');
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadSavedDisplayName() {
|
||||||
|
this._getSavedDisplayName()
|
||||||
|
.then(displayName => {
|
||||||
|
console.log("Retrieved edited display name:", displayName)
|
||||||
|
if (displayName) {
|
||||||
|
Events.fire('self-display-name-changed', displayName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_onDisplayName(displayName){
|
||||||
|
// set display name
|
||||||
|
this.$displayName.setAttribute('placeholder', displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_insertDisplayName(displayName) {
|
||||||
|
this.$displayName.textContent = displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onKeyDownDisplayName(e) {
|
||||||
|
if (e.key === "Enter" || e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.target.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onKeyUpDisplayName(e) {
|
||||||
|
// fix for Firefox inserting a linebreak into div on edit which prevents the placeholder from showing automatically when it is empty
|
||||||
|
if (/^(\n|\r|\r\n)$/.test(e.target.innerText)) e.target.innerText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async _saveDisplayName(newDisplayName) {
|
||||||
|
newDisplayName = newDisplayName.replace(/(\n|\r|\r\n)/, '')
|
||||||
|
const savedDisplayName = await this._getSavedDisplayName();
|
||||||
|
if (newDisplayName === savedDisplayName) return;
|
||||||
|
|
||||||
|
if (newDisplayName) {
|
||||||
|
PersistentStorage.set('editedDisplayName', newDisplayName)
|
||||||
|
.then(_ => {
|
||||||
|
Events.fire('notify-user', Localization.getTranslation("notifications.display-name-changed-permanently"));
|
||||||
|
})
|
||||||
|
.catch(_ => {
|
||||||
|
console.log("This browser does not support IndexedDB. Use localStorage instead.");
|
||||||
|
localStorage.setItem('editedDisplayName', newDisplayName);
|
||||||
|
Events.fire('notify-user', Localization.getTranslation("notifications.display-name-changed-temporarily"));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
Events.fire('self-display-name-changed', newDisplayName);
|
||||||
|
Events.fire('broadcast-send', {type: 'self-display-name-changed', detail: newDisplayName});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
PersistentStorage.delete('editedDisplayName')
|
||||||
|
.catch(_ => {
|
||||||
|
console.log("This browser does not support IndexedDB. Use localStorage instead.")
|
||||||
|
localStorage.removeItem('editedDisplayName');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
Events.fire('notify-user', Localization.getTranslation("notifications.display-name-random-again"));
|
||||||
|
Events.fire('self-display-name-changed', '');
|
||||||
|
Events.fire('broadcast-send', {type: 'self-display-name-changed', detail: ''});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getSavedDisplayName() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
PersistentStorage.get('editedDisplayName')
|
||||||
|
.then(displayName => {
|
||||||
|
if (!displayName) displayName = "";
|
||||||
|
resolve(displayName);
|
||||||
|
})
|
||||||
|
.catch(_ => {
|
||||||
|
let displayName = localStorage.getItem('editedDisplayName');
|
||||||
|
if (!displayName) displayName = "";
|
||||||
|
resolve(displayName);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundCanvas {
|
||||||
|
constructor() {
|
||||||
|
this.c = $$('canvas');
|
||||||
|
this.cCtx = this.c.getContext('2d');
|
||||||
|
this.$footer = $$('footer');
|
||||||
|
|
||||||
|
// fade-in on load
|
||||||
|
Events.on('fade-in-ui', _ => this._fadeIn());
|
||||||
|
|
||||||
|
// redraw canvas
|
||||||
|
Events.on('resize', _ => this.init());
|
||||||
|
Events.on('redraw-canvas', _ => this.init());
|
||||||
|
Events.on('translation-loaded', _ => this.init());
|
||||||
|
}
|
||||||
|
|
||||||
|
_fadeIn() {
|
||||||
|
this.c.classList.remove('opacity-0');
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let oldW = this.w;
|
||||||
|
let oldH = this.h;
|
||||||
|
let oldOffset = this.offset
|
||||||
|
this.w = document.documentElement.clientWidth;
|
||||||
|
this.h = document.documentElement.clientHeight;
|
||||||
|
this.offset = this.$footer.offsetHeight - 27;
|
||||||
|
if (this.h >= 800) this.offset += 10;
|
||||||
|
|
||||||
|
if (oldW === this.w && oldH === this.h && oldOffset === this.offset) return; // nothing has changed
|
||||||
|
|
||||||
|
this.c.width = this.w;
|
||||||
|
this.c.height = this.h;
|
||||||
|
this.x0 = this.w / 2;
|
||||||
|
this.y0 = this.h - this.offset;
|
||||||
|
this.dw = Math.round(Math.max(this.w, this.h, 1000) / 13);
|
||||||
|
|
||||||
|
this.drawCircles(this.cCtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
drawCircle(ctx, radius) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
let opacity = Math.max(0, 0.3 * (1 - 1.2 * radius / Math.max(this.w, this.h)));
|
||||||
|
ctx.strokeStyle = `rgba(165, 165, 165, ${opacity})`;
|
||||||
|
ctx.arc(this.x0, this.y0, radius, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCircles(ctx) {
|
||||||
|
ctx.clearRect(0, 0, this.w, this.h);
|
||||||
|
for (let i = 0; i < 13; i++) {
|
||||||
|
this.drawCircle(ctx, this.dw * i + 33 + 66);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1058
public/scripts/ui.js
|
@ -37,6 +37,31 @@ if (!navigator.clipboard) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Polyfills
|
||||||
|
window.isRtcSupported = !!(window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection);
|
||||||
|
|
||||||
|
window.hiddenProperty = 'hidden' in document
|
||||||
|
? 'hidden'
|
||||||
|
: 'webkitHidden' in document
|
||||||
|
? 'webkitHidden'
|
||||||
|
: 'mozHidden' in document
|
||||||
|
? 'mozHidden'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
window.visibilityChangeEvent = 'visibilitychange' in document
|
||||||
|
? 'visibilitychange'
|
||||||
|
: 'webkitvisibilitychange' in document
|
||||||
|
? 'webkitvisibilitychange'
|
||||||
|
: 'mozvisibilitychange' in document
|
||||||
|
? 'mozvisibilitychange'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
window.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||||
|
window.android = /android/i.test(navigator.userAgent);
|
||||||
|
window.isMobile = window.iOS || window.android;
|
||||||
|
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
const zipper = (() => {
|
const zipper = (() => {
|
||||||
|
|
||||||
let zipWriter;
|
let zipWriter;
|
||||||
|
@ -52,7 +77,8 @@ const zipper = (() => {
|
||||||
const blobURL = URL.createObjectURL(await zipWriter.close());
|
const blobURL = URL.createObjectURL(await zipWriter.close());
|
||||||
zipWriter = null;
|
zipWriter = null;
|
||||||
return blobURL;
|
return blobURL;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
throw new Error("Zip file closed");
|
throw new Error("Zip file closed");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -61,7 +87,8 @@ const zipper = (() => {
|
||||||
const file = new File([await zipWriter.close()], filename, {type: "application/zip"});
|
const file = new File([await zipWriter.close()], filename, {type: "application/zip"});
|
||||||
zipWriter = null;
|
zipWriter = null;
|
||||||
return file;
|
return file;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
throw new Error("Zip file closed");
|
throw new Error("Zip file closed");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -411,3 +438,23 @@ function changeFavicon(src) {
|
||||||
document.querySelector('[rel="icon"]').href = src;
|
document.querySelector('[rel="icon"]').href = src;
|
||||||
document.querySelector('[rel="shortcut icon"]').href = src;
|
document.querySelector('[rel="shortcut icon"]').href = src;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer) {
|
||||||
|
let binary = '';
|
||||||
|
let bytes = new Uint8Array(buffer);
|
||||||
|
let len = bytes.byteLength;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return window.btoa( binary );
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToArrayBuffer(base64) {
|
||||||
|
let binary_string = window.atob(base64);
|
||||||
|
let len = binary_string.length;
|
||||||
|
let bytes = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binary_string.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
|
@ -1,20 +1,24 @@
|
||||||
const cacheVersion = 'v1.9.4';
|
const cacheVersion = 'v1.9.4';
|
||||||
const cacheTitle = `pairdrop-cache-${cacheVersion}`;
|
const cacheTitle = `pairdrop-cache-${cacheVersion}`;
|
||||||
const forceFetch = false; // FOR DEVELOPMENT: Set to true to always update assets instead of using cached versions
|
const forceFetch = false; // FOR DEVELOPMENT: Set to true to always update assets instead of using cached versions
|
||||||
const urlsToCache = [
|
const relativePathsToCache = [
|
||||||
'./',
|
'./',
|
||||||
'index.html',
|
'index.html',
|
||||||
'manifest.json',
|
'manifest.json',
|
||||||
'styles.css',
|
'styles/styles-main.css',
|
||||||
|
'styles/deferred-styles.css',
|
||||||
'scripts/localization.js',
|
'scripts/localization.js',
|
||||||
|
'scripts/main.js',
|
||||||
'scripts/network.js',
|
'scripts/network.js',
|
||||||
'scripts/NoSleep.min.js',
|
'scripts/no-sleep.min.js',
|
||||||
'scripts/QRCode.min.js',
|
'scripts/persistent-storage.js',
|
||||||
'scripts/theme.js',
|
'scripts/qr-code.min.js',
|
||||||
'scripts/ui.js',
|
'scripts/ui.js',
|
||||||
|
'scripts/ui-main.js',
|
||||||
'scripts/util.js',
|
'scripts/util.js',
|
||||||
'scripts/zip.min.js',
|
'scripts/zip.min.js',
|
||||||
'sounds/blop.mp3',
|
'sounds/blop.mp3',
|
||||||
|
'sounds/blop.ogg',
|
||||||
'images/favicon-96x96.png',
|
'images/favicon-96x96.png',
|
||||||
'images/favicon-96x96-notification.png',
|
'images/favicon-96x96-notification.png',
|
||||||
'images/android-chrome-192x192.png',
|
'images/android-chrome-192x192.png',
|
||||||
|
@ -32,17 +36,23 @@ const urlsToCache = [
|
||||||
'lang/ja.json',
|
'lang/ja.json',
|
||||||
'lang/nb.json',
|
'lang/nb.json',
|
||||||
'lang/nl.json',
|
'lang/nl.json',
|
||||||
|
'lang/tr.json',
|
||||||
'lang/ro.json',
|
'lang/ro.json',
|
||||||
'lang/ru.json',
|
'lang/ru.json',
|
||||||
'lang/zh-CN.json'
|
'lang/zh-CN.json'
|
||||||
];
|
];
|
||||||
|
const relativePathsNotToCache = [
|
||||||
|
'config'
|
||||||
|
]
|
||||||
|
|
||||||
self.addEventListener('install', function(event) {
|
self.addEventListener('install', function(event) {
|
||||||
// Perform install steps
|
// Perform install steps
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(cacheTitle)
|
caches.open(cacheTitle)
|
||||||
.then(function(cache) {
|
.then(function(cache) {
|
||||||
return cache.addAll(urlsToCache).then(_ => {
|
return cache
|
||||||
|
.addAll(relativePathsToCache)
|
||||||
|
.then(_ => {
|
||||||
console.log('All files cached.');
|
console.log('All files cached.');
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
@ -51,13 +61,24 @@ self.addEventListener('install', function(event) {
|
||||||
|
|
||||||
// fetch the resource from the network
|
// fetch the resource from the network
|
||||||
const fromNetwork = (request, timeout) =>
|
const fromNetwork = (request, timeout) =>
|
||||||
new Promise((fulfill, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const timeoutId = setTimeout(reject, timeout);
|
const timeoutId = setTimeout(reject, timeout);
|
||||||
fetch(request).then(response => {
|
fetch(request)
|
||||||
|
.then(response => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
fulfill(response);
|
resolve(response);
|
||||||
update(request).then(() => console.log("Cache successfully updated for", request.url));
|
|
||||||
}, reject);
|
if (doNotCacheRequest(request)) return;
|
||||||
|
|
||||||
|
update(request)
|
||||||
|
.then(() => console.log("Cache successfully updated for", request.url))
|
||||||
|
.catch(reason => console.log("Cache could not be updated for", request.url, "Reason:", reason));
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// Handle any errors that occurred during the fetch
|
||||||
|
console.error(`Could not fetch ${request.url}. Are you online?`);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// fetch the resource from the browser cache
|
// fetch the resource from the browser cache
|
||||||
|
@ -68,17 +89,32 @@ const fromCache = request =>
|
||||||
cache.match(request)
|
cache.match(request)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const rootUrl = location.href.substring(0, location.href.length - "service-worker.js".length);
|
||||||
|
const rootUrlLength = rootUrl.length;
|
||||||
|
|
||||||
|
const doNotCacheRequest = request => {
|
||||||
|
const requestRelativePath = request.url.substring(rootUrlLength);
|
||||||
|
return relativePathsNotToCache.indexOf(requestRelativePath) !== -1
|
||||||
|
};
|
||||||
|
|
||||||
// cache the current page to make it available for offline
|
// cache the current page to make it available for offline
|
||||||
const update = request =>
|
const update = request => new Promise((resolve, reject) => {
|
||||||
|
if (doNotCacheRequest(request)) {
|
||||||
|
reject("Url is specifically prevented from being cached in the serviceworker.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
caches
|
caches
|
||||||
.open(cacheTitle)
|
.open(cacheTitle)
|
||||||
.then(cache =>
|
.then(cache =>
|
||||||
fetch(request)
|
fetch(request, {cache: "no-store"})
|
||||||
.then(async response => {
|
.then(response => {
|
||||||
await cache.put(request, response);
|
cache
|
||||||
|
.put(request, response)
|
||||||
|
.then(() => resolve());
|
||||||
})
|
})
|
||||||
.catch(() => console.log(`Cache could not be updated. ${request.url}`))
|
.catch(reason => reject(reason))
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// general strategy when making a request (eg if online try to fetch it
|
// general strategy when making a request (eg if online try to fetch it
|
||||||
// from cache, if something fails fetch from network. Update cache everytime files are fetched.
|
// from cache, if something fails fetch from network. Update cache everytime files are fetched.
|
||||||
|
@ -90,13 +126,16 @@ self.addEventListener('fetch', function(event) {
|
||||||
const share_url = await evaluateRequestData(event.request);
|
const share_url = await evaluateRequestData(event.request);
|
||||||
return Response.redirect(encodeURI(share_url), 302);
|
return Response.redirect(encodeURI(share_url), 302);
|
||||||
})());
|
})());
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
// Regular requests not related to Web Share Target.
|
// Regular requests not related to Web Share Target.
|
||||||
if (forceFetch) {
|
if (forceFetch) {
|
||||||
event.respondWith(fromNetwork(event.request, 10000));
|
event.respondWith(fromNetwork(event.request, 10000));
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fromCache(event.request).then(rsp => {
|
fromCache(event.request)
|
||||||
|
.then(rsp => {
|
||||||
// if fromCache resolves to undefined fetch from network instead
|
// if fromCache resolves to undefined fetch from network instead
|
||||||
return rsp || fromNetwork(event.request, 10000);
|
return rsp || fromNetwork(event.request, 10000);
|
||||||
})
|
})
|
||||||
|
@ -109,7 +148,8 @@ self.addEventListener('fetch', function(event) {
|
||||||
// on activation, we clean up the previously registered service workers
|
// on activation, we clean up the previously registered service workers
|
||||||
self.addEventListener('activate', evt => {
|
self.addEventListener('activate', evt => {
|
||||||
return evt.waitUntil(
|
return evt.waitUntil(
|
||||||
caches.keys().then(cacheNames => {
|
caches.keys()
|
||||||
|
.then(cacheNames => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
cacheNames.map(cacheName => {
|
cacheNames.map(cacheName => {
|
||||||
if (cacheName !== cacheTitle) {
|
if (cacheName !== cacheTitle) {
|
||||||
|
@ -157,7 +197,8 @@ const evaluateRequestData = function (request) {
|
||||||
DBOpenRequest.onerror = _ => {
|
DBOpenRequest.onerror = _ => {
|
||||||
resolve(pairDropUrl);
|
resolve(pairDropUrl);
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
let urlArgument = '?share-target=text';
|
let urlArgument = '?share-target=text';
|
||||||
|
|
||||||
if (title) urlArgument += `&title=${title}`;
|
if (title) urlArgument += `&title=${title}`;
|
||||||
|
|
1589
public/styles.css
735
public/styles/deferred-styles.css
Normal file
|
@ -0,0 +1,735 @@
|
||||||
|
/* All styles in this sheet are not needed on page load and deferred */
|
||||||
|
|
||||||
|
/* Paste mode */
|
||||||
|
#cancel-paste-mode {
|
||||||
|
z-index: 21;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 56px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: rgb(238, 238, 238);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text Input */
|
||||||
|
.textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
display: block;
|
||||||
|
overflow: auto;
|
||||||
|
resize: none;
|
||||||
|
line-height: 16px;
|
||||||
|
max-height: 300px;
|
||||||
|
word-break: break-word;
|
||||||
|
word-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peers */
|
||||||
|
|
||||||
|
x-peers:has(> x-peer) {
|
||||||
|
--peers-per-row: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-height: 505px) and (max-height: 649px) and (max-width: 426px),
|
||||||
|
screen and (min-height: 486px) and (max-height: 631px) and (min-width: 426px) {
|
||||||
|
x-peers:has(> x-peer) {
|
||||||
|
--peers-per-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(7)) {
|
||||||
|
--peers-per-row: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(10)) {
|
||||||
|
--peers-per-row: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(13)) {
|
||||||
|
--peers-per-row: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(16)) {
|
||||||
|
--peers-per-row: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(19)) {
|
||||||
|
--peers-per-row: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(22)) {
|
||||||
|
--peers-per-row: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(25)) {
|
||||||
|
--peers-per-row: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-height: 649px) and (max-width: 425px),
|
||||||
|
screen and (min-height: 631px) and (min-width: 426px) {
|
||||||
|
x-peers:has(> x-peer) {
|
||||||
|
--peers-per-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(10)) {
|
||||||
|
--peers-per-row: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(13)) {
|
||||||
|
--peers-per-row: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(16)) {
|
||||||
|
--peers-per-row: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(19)) {
|
||||||
|
--peers-per-row: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(22)) {
|
||||||
|
--peers-per-row: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(25)) {
|
||||||
|
--peers-per-row: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:has(> x-peer:nth-of-type(28)) {
|
||||||
|
--peers-per-row: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peer */
|
||||||
|
|
||||||
|
x-peer {
|
||||||
|
padding: 8px;
|
||||||
|
align-content: start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer input[type="file"] {
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer label {
|
||||||
|
width: var(--peer-width);
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer x-icon {
|
||||||
|
--icon-size: 40px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
transition: transform 150ms;
|
||||||
|
will-change: transform;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer .icon-wrapper {
|
||||||
|
width: var(--icon-size);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-color);
|
||||||
|
background-image: linear-gradient(45deg, var(--accent-color) 40%, color-mix(in srgb, var(--accent-color) 70%, white) 100%);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer.type-secret .icon-wrapper {
|
||||||
|
--accent-color: var(--paired-device-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer:not(.type-ip):not(.type-secret).type-public-id .icon-wrapper {
|
||||||
|
--accent-color: var(--public-room-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-wrapper {
|
||||||
|
align-self: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 7px auto 0;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
width: 15px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 1px;
|
||||||
|
margin-right: 1px;
|
||||||
|
--highlight-color: var(--badge-color);
|
||||||
|
background-color: var(--highlight-color);
|
||||||
|
background-image: linear-gradient(180deg, var(--highlight-color) 0%, color-mix(in srgb, var(--highlight-color) 90%, black));
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-room-ip {
|
||||||
|
--highlight-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-room-secret {
|
||||||
|
--highlight-color: var(--paired-device-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-room-public-id {
|
||||||
|
--highlight-color: var(--public-room-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer:not(.type-ip) .highlight-room-ip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer:not(.type-secret) .highlight-room-secret {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer:not(.type-public-id) .highlight-room-public-id {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer:not([status]):hover x-icon,
|
||||||
|
x-peer:not([status]):focus x-icon {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer[status] x-icon {
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer.ws-peer {
|
||||||
|
margin-top: -1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer.ws-peer .progress {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer.ws-peer .icon-wrapper{
|
||||||
|
border: solid 3px var(--ws-peer-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer.ws-peer .highlight-wrapper {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#websocket-fallback {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#websocket-fallback > span:nth-of-type(2) {
|
||||||
|
border-bottom: solid 2px var(--ws-peer-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-descriptor {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-descriptor > div {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status,
|
||||||
|
.device-name {
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer:not([status]) .status,
|
||||||
|
x-peer[status] .device-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peer[status] {
|
||||||
|
pointer-events: 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Dialog */
|
||||||
|
|
||||||
|
x-dialog x-background {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 30;
|
||||||
|
transition: opacity 300ms;
|
||||||
|
will-change: opacity;
|
||||||
|
overflow: overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-dialog x-paper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: calc(100vw - 10px);
|
||||||
|
z-index: 3;
|
||||||
|
border-radius: 30px;
|
||||||
|
max-width: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: transform 300ms;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pair-device-dialog x-paper,
|
||||||
|
#edit-paired-devices-dialog x-paper,
|
||||||
|
#public-room-dialog x-paper,
|
||||||
|
#language-select-dialog x-paper {
|
||||||
|
position: absolute;
|
||||||
|
top: max(50%, 350px);
|
||||||
|
margin-top: -328.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-paper > .row:first-of-type {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-paper > .row:first-of-type h2 {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pair-device-dialog,
|
||||||
|
#edit-paired-devices-dialog {
|
||||||
|
--accent-color: var(--paired-device-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#public-room-dialog {
|
||||||
|
--accent-color: var(--public-room-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pair-device-dialog ::-moz-selection,
|
||||||
|
#pair-device-dialog ::selection {
|
||||||
|
color: black;
|
||||||
|
background: var(--paired-device-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#public-room-dialog ::-moz-selection,
|
||||||
|
#public-room-dialog ::selection {
|
||||||
|
color: black;
|
||||||
|
background: var(--public-room-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
x-dialog:not([show]) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-dialog:not([show]) x-paper {
|
||||||
|
transform: scale(0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
x-dialog a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pair Devices Dialog & Public Room Dialog */
|
||||||
|
|
||||||
|
.input-key-container {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-key-container > input {
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
font-size: 30px;
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: -webkit-box !important;
|
||||||
|
display: -webkit-flex !important;
|
||||||
|
display: -moz-flex !important;
|
||||||
|
display: -ms-flexbox !important;
|
||||||
|
display: flex !important;
|
||||||
|
-webkit-justify-content: center;
|
||||||
|
-ms-justify-content: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-key-container > input {
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-key-container.six-chars > input:nth-of-type(4) {
|
||||||
|
margin-left: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 45px;
|
||||||
|
letter-spacing: min(calc((100vw - 80px - 99px) / 100 * 7), 20px);
|
||||||
|
text-indent: calc(0.5 * (11px + min(calc((100vw - 80px - 99px) / 100 * 6), 28px)));
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-qr-code {
|
||||||
|
width: fit-content;
|
||||||
|
align-self: center;
|
||||||
|
margin-top: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-instructions {
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-dialog h2 {
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-dialog hr {
|
||||||
|
height: 1px;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-note {
|
||||||
|
margin-top: 23px;
|
||||||
|
margin-bottom: 31px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-note hr {
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hr-note > div {
|
||||||
|
height: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.hr-note > div > span {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: rgb(var(--text-color));
|
||||||
|
background-color: var(--dialog-bg-color);
|
||||||
|
border: var(--border-color) solid 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pair-device-dialog x-background {
|
||||||
|
padding: 16px!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edit Paired Devices Dialog */
|
||||||
|
.paired-devices-wrapper:empty:before {
|
||||||
|
content: attr(data-empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paired-devices-wrapper:empty {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paired-devices-wrapper {
|
||||||
|
margin-top: -5px;
|
||||||
|
border-bottom: solid 4px var(--paired-device-color);
|
||||||
|
max-height: 65vh;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paired-device {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paired-device:not(:last-child) {
|
||||||
|
border-bottom: solid 4px var(--paired-device-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paired-device > .display-name,
|
||||||
|
.paired-device > .device-name {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
align-self: center;
|
||||||
|
border-bottom: solid 2px rgba(128, 128, 128, 0.5);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.paired-device span {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paired-device > .button-wrapper {
|
||||||
|
display: flex;
|
||||||
|
height: 36px;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paired-device > .button-wrapper > label,
|
||||||
|
.paired-device > .button-wrapper > button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
justify-content: center;
|
||||||
|
width: 50%;
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 6px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paired-device > .button-wrapper > :not(:last-child) {
|
||||||
|
border-right: solid 1px rgba(128, 128, 128, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paired-device > .button-wrapper > :not(:first-child) {
|
||||||
|
border-left: solid 1px rgba(128, 128, 128, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paired-device * {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* button row*/
|
||||||
|
x-paper > .button-row {
|
||||||
|
height: 50px;
|
||||||
|
margin: 5px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-paper > .button-row > .btn {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not([dir="rtl"]) x-paper > .button-row > .btn:not(:first-child) {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not([dir="rtl"]) x-paper > .button-row > .btn:not(:last-child) {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] x-paper > .button-row > .btn:not(:first-child) {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] x-paper > .button-row > .btn:not(:last-child) {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-buttons > button > span {
|
||||||
|
margin: 0 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-buttons > button {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-description {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-description span {
|
||||||
|
display: inline;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-style: italic;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-stem {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-right: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Send Text Dialog */
|
||||||
|
x-dialog .dialog-subheader {
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-name-wrapper {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#send-text-dialog,
|
||||||
|
#receive-text-dialog {
|
||||||
|
font-size: 16px; /* prevents auto-zoom on edit */
|
||||||
|
--shadow-color-rgb: var(--shadow-color-secondary-rgb);
|
||||||
|
--shadow-color-cover-rgb: var(--shadow-color-secondary-cover-rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
#edit-paired-devices-dialog {
|
||||||
|
--shadow-color-rgb: var(--shadow-color-dialog-rgb);
|
||||||
|
--shadow-color-cover-rgb: var(--shadow-color-dialog-cover-rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
#text-input:before {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Receive Text Dialog */
|
||||||
|
|
||||||
|
#receive-text-dialog #text {
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 400px;
|
||||||
|
padding: 10px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
#receive-text-dialog #text a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#receive-text-dialog h3 {
|
||||||
|
/* Select the received text when double-clicking the dialog */
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base64-paste-btn,
|
||||||
|
#base64-paste-dialog .textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 40vh;
|
||||||
|
border: solid 12px #438cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base64-paste-dialog .textarea {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base64-paste-dialog .textarea::before {
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peer loading Indicator */
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Color Themes
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Colored Elements */
|
||||||
|
|
||||||
|
x-dialog x-paper {
|
||||||
|
background-color: var(--dialog-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
color: rgb(var(--text-color)) !important;
|
||||||
|
background-color: var(--bg-color-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea * {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
color: unset !important;
|
||||||
|
background: unset !important;
|
||||||
|
border: unset !important;
|
||||||
|
opacity: unset !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
font-size: inherit !important;
|
||||||
|
font-style: unset !important;
|
||||||
|
font-weight: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image/Video/Audio Preview */
|
||||||
|
.file-preview {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview > img,
|
||||||
|
.file-preview > audio,
|
||||||
|
.file-preview > video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 40vh;
|
||||||
|
margin: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
970
public/styles/styles-main.css
Normal file
|
@ -0,0 +1,970 @@
|
||||||
|
/* All styles in this sheet are needed on page load */
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
overflow-y: hidden;
|
||||||
|
/* Only allow selection on message and pair key */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
transition: color 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fw {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p1 {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-reverse {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
position: absolute;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 8px 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100vw;
|
||||||
|
z-index: 20;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header > * {
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-self: flex-start;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
header > div .icon-button {
|
||||||
|
height: 40px;
|
||||||
|
transition: all 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
header > div > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
header > div:not(:hover) .icon-button:not(.selected) {
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#theme-wrapper:hover::before {
|
||||||
|
border-radius: 20px;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.1;
|
||||||
|
transition: opacity 300ms;
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header > div:hover .icon-button.selected::before {
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
header > div:hover .icon-button.selected:hover::before {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
header > div .icon-button:not(.selected) {
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
header > div > div {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Open Sans";
|
||||||
|
src: url('../fonts/OpenSans/static/OpenSans-Medium.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Open Sans", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-variant-ligatures: common-ligatures;
|
||||||
|
font-kerning: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
line-height: 40px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -.012em;
|
||||||
|
line-height: 32px;
|
||||||
|
color: var(--primary-color);}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 16px 0;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-subheading {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-body1,
|
||||||
|
body {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-body2 {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
.icon-button {
|
||||||
|
text-decoration: none;
|
||||||
|
color: currentColor;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
min-width: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-noscript {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflowing {
|
||||||
|
background:
|
||||||
|
/* Shadow covers */
|
||||||
|
linear-gradient(rgb(var(--shadow-color-cover-rgb)) 30%, rgba(var(--shadow-color-cover-rgb), 0)),
|
||||||
|
linear-gradient(rgba(var(--shadow-color-cover-rgb), 0), rgb(var(--shadow-color-cover-rgb)) 70%) 0 100%,
|
||||||
|
/* Shadows */
|
||||||
|
radial-gradient(farthest-side at 50% 0, rgba(var(--shadow-color-rgb), .2), rgba(var(--shadow-color-rgb), 0)),
|
||||||
|
radial-gradient(farthest-side at 50% 100%, rgba(var(--shadow-color-rgb), .2), rgba(var(--shadow-color-rgb), 0))
|
||||||
|
0 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 60px, 100% 60px, 100% 24px, 100% 24px;
|
||||||
|
background-attachment: local, local, scroll, scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
margin-top: 56px;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overscroll-behavior-x: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Peers */
|
||||||
|
|
||||||
|
#x-peers-filler {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
flex-grow: 1;
|
||||||
|
align-items: start !important;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
z-index: 2;
|
||||||
|
transition: background-color 0.5s ease;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior-x: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
--peers-per-row: 6; /* default if browser does not support :has selector */
|
||||||
|
--x-peers-width: min(100vw, calc(var(--peers-per-row) * (var(--peer-width) + 25px) - 16px));
|
||||||
|
width: var(--x-peers-width);
|
||||||
|
margin-right: 20px;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty Peers List */
|
||||||
|
|
||||||
|
x-no-peers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px;
|
||||||
|
height: 137px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-no-peers h2,
|
||||||
|
x-no-peers a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:not(:empty)+x-no-peers {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-no-peers::before {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -.012em;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-no-peers[drop-bg]::before {
|
||||||
|
content: attr(data-drop-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
x-no-peers[drop-bg] * {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
|
||||||
|
footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
cursor: default;
|
||||||
|
margin: auto 5px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer .logo {
|
||||||
|
--icon-size: 80px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-top: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-wrapper {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 15px auto auto;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
padding: 2px;
|
||||||
|
background-color: rgb(var(--bg-color));
|
||||||
|
transition: background-color 0.5s ease;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-wrapper.column {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-wrapper.row {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*You can be discovered wrapper*/
|
||||||
|
.discovery-wrapper > div:first-of-type {
|
||||||
|
padding-left: 4px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.discovery-wrapper .badge {
|
||||||
|
word-break: keep-all;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding-right: 0.3rem;
|
||||||
|
padding-left: 0.3em;
|
||||||
|
background-color: var(--badge-color);
|
||||||
|
color: white;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-gradient {
|
||||||
|
background-image: linear-gradient(180deg, color-mix(in srgb, var(--badge-color) 80%, white) 0%, var(--badge-color) 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-room-ip {
|
||||||
|
--badge-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-room-secret {
|
||||||
|
--badge-color: var(--paired-device-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-room-public-id {
|
||||||
|
--badge-color: var(--public-room-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.known-as-wrapper {
|
||||||
|
font-size: 16px; /* prevents auto-zoom on edit */
|
||||||
|
}
|
||||||
|
|
||||||
|
#display-name {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
max-width: 15em;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: text;
|
||||||
|
margin-bottom: -6px;
|
||||||
|
padding-bottom: 0.1rem;
|
||||||
|
border-radius: 1.3rem/30%;
|
||||||
|
border-right: solid 1rem transparent;
|
||||||
|
border-left: solid 1rem transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#edit-pen {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not([dir="rtl"]) #display-name,
|
||||||
|
html:not([dir="rtl"]) #edit-pen {
|
||||||
|
margin-left: -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] #display-name,
|
||||||
|
html[dir="rtl"] #edit-pen {
|
||||||
|
margin-right: -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] #edit-pen {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialogs needed on page load */
|
||||||
|
x-dialog:not([show]) x-background {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-family: "Open Sans", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
padding: 2px 16px 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
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(--accent-color);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn[disabled] {
|
||||||
|
color: var(--btn-disabled-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.btn,
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:before,
|
||||||
|
.icon-button:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
opacity: 0;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
transition: opacity 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:not([disabled]):hover:before,
|
||||||
|
.icon-button:hover:before {
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn[selected],
|
||||||
|
.icon-button[selected] {
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus:before,
|
||||||
|
.icon-button:focus:before {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-rounded {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-grey {
|
||||||
|
background-color: var(--bg-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button::-moz-focus-inner {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Icon Button */
|
||||||
|
.icon-button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:before {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Info Animation */
|
||||||
|
|
||||||
|
#about {
|
||||||
|
color: white;
|
||||||
|
z-index: 32;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about header {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about:not(:target) header {
|
||||||
|
transition-delay: 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about:target header {
|
||||||
|
transition-delay: 100ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about > * {
|
||||||
|
transition: opacity 300ms ease 300ms;
|
||||||
|
will-change: opacity;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about:not(:target) > header,
|
||||||
|
#about:not(:target) > section {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about .logo {
|
||||||
|
--icon-size: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about .title-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about .title-wrapper > div {
|
||||||
|
margin-left: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about x-background {
|
||||||
|
position: absolute;
|
||||||
|
--size: max(max(230vw, 230vh), calc(150vh + 150vw));
|
||||||
|
--size-half: calc(var(--size)/2);
|
||||||
|
top: calc(28px - var(--size-half));
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
z-index: -1;
|
||||||
|
background: var(--primary-color);
|
||||||
|
background-image: radial-gradient(circle at calc(50% - 36px), var(--accent-color) 0%, color-mix(in srgb, var(--accent-color) 40%, black) 80%);
|
||||||
|
--crop-size: 0px;
|
||||||
|
clip-path: circle(var(--crop-size));
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not([dir="rtl"]) #about x-background {
|
||||||
|
right: calc(36px - var(--size-half));
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] #about x-background {
|
||||||
|
left: calc(36px - var(--size-half));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Hack such that initial scale(0) isn't animated */
|
||||||
|
#about x-background {
|
||||||
|
will-change: clip-path;
|
||||||
|
transition: clip-path 800ms cubic-bezier(0.77, 0, 0.175, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#about:target x-background {
|
||||||
|
--crop-size: var(--size);
|
||||||
|
}
|
||||||
|
|
||||||
|
#about .row a {
|
||||||
|
margin: 8px 8px -16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#about section {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.circles {
|
||||||
|
width: 100vw;
|
||||||
|
position: absolute;
|
||||||
|
z-index: -10;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
top: 50px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 344px;
|
||||||
|
background-color: rgb(var(--text-color));
|
||||||
|
color: var(--dialog-bg-color);
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 24px;
|
||||||
|
z-index: 40;
|
||||||
|
transition: opacity 200ms, transform 300ms ease-out;
|
||||||
|
cursor: default;
|
||||||
|
line-height: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-toast:not([show]):not(:hover) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Instructions */
|
||||||
|
|
||||||
|
x-instructions {
|
||||||
|
position: relative;
|
||||||
|
opacity: 0.5;
|
||||||
|
text-align: center;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-instructions:not([drop-peer]):not([drop-bg]):before {
|
||||||
|
content: attr(mobile);
|
||||||
|
}
|
||||||
|
|
||||||
|
x-instructions[drop-peer]:before {
|
||||||
|
content: attr(data-drop-peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
x-instructions[drop-bg]:not([drop-peer]):before {
|
||||||
|
content: attr(data-drop-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
x-instructions p {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-peers:empty~x-instructions {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
x-peer {
|
||||||
|
transform: scale(0.95);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent Cumulative Layout Shift */
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fade-in 600ms;
|
||||||
|
animation-fill-mode: backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-animation-on-load {
|
||||||
|
animation-iteration-count: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-0 {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Styles */
|
||||||
|
|
||||||
|
@media screen and (min-height: 800px) {
|
||||||
|
footer {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
x-instructions:not([drop-peer]):not([drop-bg]):before {
|
||||||
|
content: attr(desktop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Constants */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--icon-size: 24px;
|
||||||
|
--peer-width: 120px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Color Themes
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Default colors */
|
||||||
|
body {
|
||||||
|
/* Constant colors */
|
||||||
|
--primary-color: #4285f4;
|
||||||
|
--paired-device-color: #00a69c;
|
||||||
|
--public-room-color: #db8500;
|
||||||
|
--accent-color: var(--primary-color);
|
||||||
|
--ws-peer-color: #ff6b6b;
|
||||||
|
--btn-disabled-color: #5B5B66;
|
||||||
|
/* shadows */
|
||||||
|
--shadow-color-rgb: var(--text-color);
|
||||||
|
--shadow-color-cover-rgb: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme colors */
|
||||||
|
body {
|
||||||
|
--text-color: 51,51,51;
|
||||||
|
--dialog-bg-color: #fff;
|
||||||
|
--bg-color: 255,255,255;
|
||||||
|
--bg-color-secondary: #f2f2f2;
|
||||||
|
--border-color: rgb(169, 169, 169);
|
||||||
|
--badge-color: #a5a5a5;
|
||||||
|
|
||||||
|
--shadow-color-secondary-rgb: 0,0,0;
|
||||||
|
--shadow-color-secondary-cover-rgb: 242,242,242;
|
||||||
|
--shadow-color-dialog-rgb: 0,0,0;
|
||||||
|
--shadow-color-dialog-cover-rgb: 242,242,242;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme colors */
|
||||||
|
body.dark-theme {
|
||||||
|
--text-color: 238,238,238;
|
||||||
|
--dialog-bg-color: #121212;
|
||||||
|
--bg-color: 0,0,0;
|
||||||
|
--bg-color-secondary: #262628;
|
||||||
|
--border-color: rgb(91, 91, 91);
|
||||||
|
--badge-color: #717171;
|
||||||
|
|
||||||
|
--shadow-color-secondary-rgb: 255,255,255;
|
||||||
|
--shadow-color-secondary-cover-rgb: 38,38,38;
|
||||||
|
--shadow-color-dialog-rgb: 255,255,255;
|
||||||
|
--shadow-color-dialog-cover-rgb: 38,38,38;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles for users who prefer dark mode at the OS level */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|
||||||
|
/* defaults to dark theme */
|
||||||
|
body {
|
||||||
|
--text-color: 238,238,238;
|
||||||
|
--dialog-bg-color: #121212;
|
||||||
|
--bg-color-secondary: #262628;
|
||||||
|
--bg-color: 0,0,0;
|
||||||
|
--border-color: rgb(91, 91, 91);
|
||||||
|
--badge-color: #717171;
|
||||||
|
|
||||||
|
--shadow-color-secondary-rgb: 255,255,255;
|
||||||
|
--shadow-color-secondary-cover-rgb: 38,38,38;
|
||||||
|
--shadow-color-dialog-rgb: 255,255,255;
|
||||||
|
--shadow-color-dialog-cover-rgb: 38,38,38;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Override dark mode with light mode styles if the user decides to swap */
|
||||||
|
body.light-theme {
|
||||||
|
--text-color: 51,51,51;
|
||||||
|
--dialog-bg-color: #fff;
|
||||||
|
--bg-color: 255,255,255;
|
||||||
|
--bg-color-secondary: #f2f2f2;
|
||||||
|
--border-color: rgb(169, 169, 169);
|
||||||
|
--badge-color: #a5a5a5;
|
||||||
|
|
||||||
|
--shadow-color-secondary-rgb: 0,0,0;
|
||||||
|
--shadow-color-secondary-cover-rgb: 242,242,242;
|
||||||
|
--shadow-color-dialog-rgb: 0,0,0;
|
||||||
|
--shadow-color-dialog-cover-rgb: 242,242,242;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colored Elements */
|
||||||
|
body {
|
||||||
|
color: rgb(var(--text-color));
|
||||||
|
background-color: rgb(var(--bg-color));
|
||||||
|
transition: background-color 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
x-dialog x-paper {
|
||||||
|
background-color: var(--dialog-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
color: rgb(var(--text-color)) !important;
|
||||||
|
background-color: var(--bg-color-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea * {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
color: unset !important;
|
||||||
|
background: unset !important;
|
||||||
|
border: unset !important;
|
||||||
|
opacity: unset !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
font-size: inherit !important;
|
||||||
|
font-style: unset !important;
|
||||||
|
font-weight: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient for wifi-tether icon */
|
||||||
|
#primaryGradient .start-color {
|
||||||
|
stop-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (stop-color: color-mix(in srgb, blue 50%, black)) {
|
||||||
|
#primaryGradient .start-color {
|
||||||
|
stop-color: color-mix(in srgb, var(--primary-color) 80%, white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#primaryGradient .stop-color {
|
||||||
|
stop-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
Edge specific styles
|
||||||
|
*/
|
||||||
|
@supports (-ms-ime-align: auto) {
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Browser specific styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
/* mobile viewport bug fix */
|
||||||
|
min-height: -moz-available; /* WebKit-based browsers will ignore this. */
|
||||||
|
min-height: -webkit-fill-available; /* Mozilla-based browsers will ignore this. */
|
||||||
|
min-height: fill-available;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: -moz-available; /* WebKit-based browsers will ignore this. */
|
||||||
|
min-height: -webkit-fill-available; /* Mozilla-based browsers will ignore this. */
|
||||||
|
min-height: fill-available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* webkit scrollbar style*/
|
||||||
|
|
||||||
|
::-webkit-scrollbar{
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb{
|
||||||
|
background: #bfbfbf;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-selection,
|
||||||
|
::selection {
|
||||||
|
color: black;
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* make elements with attribute contenteditable editable on older iOS devices.
|
||||||
|
See note here: https://developer.mozilla.org/en-US/docs/Web/CSS/user-select */
|
||||||
|
[contenteditable] {
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
Before Width: | Height: | Size: 8.3 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 7 KiB |
Before Width: | Height: | Size: 7 KiB |
Before Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 232 KiB |
Before Width: | Height: | Size: 232 KiB |
Before Width: | Height: | Size: 1 MiB |
Before Width: | Height: | Size: 232 KiB |
Before Width: | Height: | Size: 1.7 MiB |
Before Width: | Height: | Size: 180 KiB |
Before Width: | Height: | Size: 201 KiB |
|
@ -1,251 +0,0 @@
|
||||||
<?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>
|
|
Before Width: | Height: | Size: 16 KiB |