mirror of https://github.com/jetkvm/kvm.git
release 0.3.9 (#337)
This commit is contained in:
commit
e748346e2b
|
@ -12,6 +12,7 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
name: Build
|
name: Build
|
||||||
|
if: "github.event.review.state == 'approved' || github.event.event_type != 'pull_request_review'"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
@ -19,12 +20,12 @@ jobs:
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: v21.1.0
|
node-version: v21.1.0
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: "**/package-lock.json"
|
||||||
- name: Set up Golang
|
- name: Set up Golang
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.24.0'
|
go-version: "1.24.0"
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: |
|
run: |
|
||||||
make frontend
|
make frontend
|
||||||
|
@ -35,108 +36,4 @@ jobs:
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: jetkvm-app
|
name: jetkvm-app
|
||||||
path: bin/jetkvm_app
|
path: bin/jetkvm_app
|
||||||
deploy_and_test:
|
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
|
||||||
name: Smoke test
|
|
||||||
needs: build
|
|
||||||
concurrency:
|
|
||||||
group: smoketest-jk
|
|
||||||
steps:
|
|
||||||
- name: Download artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: jetkvm-app
|
|
||||||
- name: Configure WireGuard and check connectivity
|
|
||||||
run: |
|
|
||||||
WG_KEY_FILE=$(mktemp)
|
|
||||||
echo -n "$CI_WG_PRIVATE" > $WG_KEY_FILE && \
|
|
||||||
sudo apt-get update && sudo apt-get install -y wireguard-tools && \
|
|
||||||
sudo ip link add dev wg-ci type wireguard && \
|
|
||||||
sudo ip addr add $CI_WG_IPS dev wg-ci && \
|
|
||||||
sudo wg set wg-ci listen-port 51820 \
|
|
||||||
private-key $WG_KEY_FILE \
|
|
||||||
peer $CI_WG_PUBLIC \
|
|
||||||
allowed-ips $CI_WG_ALLOWED_IPS \
|
|
||||||
endpoint $CI_WG_ENDPOINT \
|
|
||||||
persistent-keepalive 15 && \
|
|
||||||
sudo ip link set up dev wg-ci && \
|
|
||||||
sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci
|
|
||||||
ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1)
|
|
||||||
env:
|
|
||||||
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
|
|
||||||
CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }}
|
|
||||||
CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }}
|
|
||||||
CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }}
|
|
||||||
CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }}
|
|
||||||
CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }}
|
|
||||||
CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }}
|
|
||||||
- name: Configure SSH
|
|
||||||
run: |
|
|
||||||
# Write SSH private key to a file
|
|
||||||
SSH_PRIVATE_KEY=$(mktemp)
|
|
||||||
echo "$CI_SSH_PRIVATE" > $SSH_PRIVATE_KEY
|
|
||||||
chmod 0600 $SSH_PRIVATE_KEY
|
|
||||||
# Configure SSH
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
cat <<EOF >> ~/.ssh/config
|
|
||||||
Host jkci
|
|
||||||
HostName $CI_HOST
|
|
||||||
User $CI_USER
|
|
||||||
StrictHostKeyChecking no
|
|
||||||
UserKnownHostsFile /dev/null
|
|
||||||
IdentityFile $SSH_PRIVATE_KEY
|
|
||||||
EOF
|
|
||||||
env:
|
|
||||||
CI_USER: ${{ vars.JETKVM_CI_USER }}
|
|
||||||
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
|
|
||||||
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
|
|
||||||
- name: Deploy application
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
# Copy the binary to the remote host
|
|
||||||
echo "+ Copying the application to the remote host"
|
|
||||||
cat jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
|
|
||||||
# Deploy and run the application on the remote host
|
|
||||||
echo "+ Deploying the application on the remote host"
|
|
||||||
ssh jkci ash <<EOF
|
|
||||||
# Extract the binary
|
|
||||||
gzip -d /userdata/jetkvm/jetkvm_app.update.gz
|
|
||||||
# Flush filesystem buffers to ensure all data is written to disk
|
|
||||||
sync
|
|
||||||
# Clear the filesystem caches to force a read from disk
|
|
||||||
echo 1 > /proc/sys/vm/drop_caches
|
|
||||||
# Reboot the application
|
|
||||||
reboot -d 5 -f &
|
|
||||||
EOF
|
|
||||||
sleep 10
|
|
||||||
echo "Deployment complete, waiting for JetKVM to come back online "
|
|
||||||
function check_online() {
|
|
||||||
for i in {1..60}; do
|
|
||||||
if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then
|
|
||||||
echo "JetKVM is back online"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo -n "."
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
echo "JetKVM did not come back online within 60 seconds"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
check_online
|
|
||||||
env:
|
|
||||||
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
|
|
||||||
- name: Run smoke tests
|
|
||||||
run: |
|
|
||||||
echo "+ Checking the status of the device"
|
|
||||||
curl -v http://$CI_HOST/device/status && echo
|
|
||||||
echo "+ Collecting logs"
|
|
||||||
ssh jkci "cat /userdata/jetkvm/last.log" > last.log
|
|
||||||
cat last.log
|
|
||||||
env:
|
|
||||||
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
|
|
||||||
- name: Upload logs
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: device-logs
|
|
||||||
path: last.log
|
|
|
@ -27,6 +27,9 @@ jobs:
|
||||||
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
|
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
|
||||||
with:
|
with:
|
||||||
go-version: 1.23.x
|
go-version: 1.23.x
|
||||||
|
- name: Create empty resource directory
|
||||||
|
run: |
|
||||||
|
mkdir -p static && touch static/.gitkeep
|
||||||
- name: Lint
|
- name: Lint
|
||||||
uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1
|
uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1
|
||||||
with:
|
with:
|
||||||
|
|
|
@ -0,0 +1,122 @@
|
||||||
|
name: smoketest
|
||||||
|
on:
|
||||||
|
repository_dispatch:
|
||||||
|
types: [smoketest]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ghbot_payload:
|
||||||
|
name: Ghbot payload
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: "GH_CHECK_RUN_ID=${{ github.event.client_payload.check_run_id }}"
|
||||||
|
run: |
|
||||||
|
echo "== START GHBOT_PAYLOAD =="
|
||||||
|
cat <<'GHPAYLOAD_EOF' | base64
|
||||||
|
${{ toJson(github.event.client_payload) }}
|
||||||
|
GHPAYLOAD_EOF
|
||||||
|
echo "== END GHBOT_PAYLOAD =="
|
||||||
|
deploy_and_test:
|
||||||
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
|
name: Smoke test
|
||||||
|
concurrency:
|
||||||
|
group: smoketest-jk
|
||||||
|
steps:
|
||||||
|
- name: Download artifact
|
||||||
|
run: |
|
||||||
|
wget -O /tmp/jk.zip "${{ github.event.client_payload.artifact_download_url }}"
|
||||||
|
unzip /tmp/jk.zip
|
||||||
|
- name: Configure WireGuard and check connectivity
|
||||||
|
run: |
|
||||||
|
WG_KEY_FILE=$(mktemp)
|
||||||
|
echo -n "$CI_WG_PRIVATE" > $WG_KEY_FILE && \
|
||||||
|
sudo apt-get update && sudo apt-get install -y wireguard-tools && \
|
||||||
|
sudo ip link add dev wg-ci type wireguard && \
|
||||||
|
sudo ip addr add $CI_WG_IPS dev wg-ci && \
|
||||||
|
sudo wg set wg-ci listen-port 51820 \
|
||||||
|
private-key $WG_KEY_FILE \
|
||||||
|
peer $CI_WG_PUBLIC \
|
||||||
|
allowed-ips $CI_WG_ALLOWED_IPS \
|
||||||
|
endpoint $CI_WG_ENDPOINT \
|
||||||
|
persistent-keepalive 15 && \
|
||||||
|
sudo ip link set up dev wg-ci && \
|
||||||
|
sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci
|
||||||
|
ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1)
|
||||||
|
env:
|
||||||
|
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
|
||||||
|
CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }}
|
||||||
|
CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }}
|
||||||
|
CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }}
|
||||||
|
CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }}
|
||||||
|
CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }}
|
||||||
|
CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }}
|
||||||
|
- name: Configure SSH
|
||||||
|
run: |
|
||||||
|
# Write SSH private key to a file
|
||||||
|
SSH_PRIVATE_KEY=$(mktemp)
|
||||||
|
echo "$CI_SSH_PRIVATE" > $SSH_PRIVATE_KEY
|
||||||
|
chmod 0600 $SSH_PRIVATE_KEY
|
||||||
|
# Configure SSH
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
cat <<EOF >> ~/.ssh/config
|
||||||
|
Host jkci
|
||||||
|
HostName $CI_HOST
|
||||||
|
User $CI_USER
|
||||||
|
StrictHostKeyChecking no
|
||||||
|
UserKnownHostsFile /dev/null
|
||||||
|
IdentityFile $SSH_PRIVATE_KEY
|
||||||
|
EOF
|
||||||
|
env:
|
||||||
|
CI_USER: ${{ vars.JETKVM_CI_USER }}
|
||||||
|
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
|
||||||
|
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
|
||||||
|
- name: Deploy application
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# Copy the binary to the remote host
|
||||||
|
echo "+ Copying the application to the remote host"
|
||||||
|
cat jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
|
||||||
|
# Deploy and run the application on the remote host
|
||||||
|
echo "+ Deploying the application on the remote host"
|
||||||
|
ssh jkci ash <<EOF
|
||||||
|
# Extract the binary
|
||||||
|
gzip -d /userdata/jetkvm/jetkvm_app.update.gz
|
||||||
|
# Flush filesystem buffers to ensure all data is written to disk
|
||||||
|
sync
|
||||||
|
# Clear the filesystem caches to force a read from disk
|
||||||
|
echo 1 > /proc/sys/vm/drop_caches
|
||||||
|
# Reboot the application
|
||||||
|
reboot -d 5 -f &
|
||||||
|
EOF
|
||||||
|
sleep 10
|
||||||
|
echo "Deployment complete, waiting for JetKVM to come back online "
|
||||||
|
function check_online() {
|
||||||
|
for i in {1..60}; do
|
||||||
|
if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then
|
||||||
|
echo "JetKVM is back online"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo -n "."
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "JetKVM did not come back online within 60 seconds"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
check_online
|
||||||
|
env:
|
||||||
|
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
|
||||||
|
- name: Run smoke tests
|
||||||
|
run: |
|
||||||
|
echo "+ Checking the status of the device"
|
||||||
|
curl -v http://$CI_HOST/device/status && echo
|
||||||
|
echo "+ Waiting for 10 seconds to allow all services to start"
|
||||||
|
sleep 10
|
||||||
|
echo "+ Collecting logs"
|
||||||
|
ssh jkci "cat /userdata/jetkvm/last.log" > last.log
|
||||||
|
cat last.log
|
||||||
|
env:
|
||||||
|
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
|
||||||
|
- name: Upload logs
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: device-logs
|
||||||
|
path: last.log
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
name: ui-lint
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "ui/**"
|
||||||
|
- "package.json"
|
||||||
|
- "package-lock.json"
|
||||||
|
- ".github/workflows/ui-lint.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ui-lint:
|
||||||
|
name: UI Lint
|
||||||
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: v21.1.0
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: "ui/package-lock.json"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd ui
|
||||||
|
npm ci
|
||||||
|
- name: Lint UI
|
||||||
|
run: |
|
||||||
|
cd ui
|
||||||
|
npm run lint
|
|
@ -1,12 +1,22 @@
|
||||||
---
|
---
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
# - goimports
|
- forbidigo
|
||||||
# - misspell
|
- goimports
|
||||||
|
- misspell
|
||||||
# - revive
|
# - revive
|
||||||
|
- whitespace
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
exclude-rules:
|
exclude-rules:
|
||||||
- path: _test.go
|
- path: _test.go
|
||||||
linters:
|
linters:
|
||||||
- errcheck
|
- errcheck
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
forbidigo:
|
||||||
|
forbid:
|
||||||
|
- p: ^fmt\.Print.*$
|
||||||
|
msg: Do not commit print statements. Use logger package.
|
||||||
|
- p: ^log\.(Fatal|Panic|Print)(f|ln)?.*$
|
||||||
|
msg: Do not commit log statements. Use logger package.
|
||||||
|
|
|
@ -23,7 +23,7 @@ We welcome contributions from the community! Whether it's improving the firmware
|
||||||
|
|
||||||
## I need help
|
## I need help
|
||||||
|
|
||||||
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW).
|
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://jetkvm.com/discord).
|
||||||
|
|
||||||
## I want to report an issue
|
## I want to report an issue
|
||||||
|
|
||||||
|
|
244
cloud.go
244
cloud.go
|
@ -7,9 +7,12 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coder/websocket/wsjson"
|
"github.com/coder/websocket/wsjson"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
|
||||||
|
@ -32,10 +35,118 @@ const (
|
||||||
// CloudOidcRequestTimeout is the timeout for OIDC token verification requests
|
// CloudOidcRequestTimeout is the timeout for OIDC token verification requests
|
||||||
// should be lower than the websocket response timeout set in cloud-api
|
// should be lower than the websocket response timeout set in cloud-api
|
||||||
CloudOidcRequestTimeout = 10 * time.Second
|
CloudOidcRequestTimeout = 10 * time.Second
|
||||||
// CloudWebSocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
|
// WebsocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
|
||||||
CloudWebSocketPingInterval = 15 * time.Second
|
WebsocketPingInterval = 15 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricCloudConnectionStatus = promauto.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_cloud_connection_status",
|
||||||
|
Help: "The status of the cloud connection",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
metricCloudConnectionEstablishedTimestamp = promauto.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_cloud_connection_established_timestamp",
|
||||||
|
Help: "The timestamp when the cloud connection was established",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
metricConnectionLastPingTimestamp = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_connection_last_ping_timestamp",
|
||||||
|
Help: "The timestamp when the last ping response was received",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionLastPingDuration = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_connection_last_ping_duration",
|
||||||
|
Help: "The duration of the last ping response",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionPingDuration = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "jetkvm_connection_ping_duration",
|
||||||
|
Help: "The duration of the ping response",
|
||||||
|
Buckets: []float64{
|
||||||
|
0.1, 0.5, 1, 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionTotalPingCount = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "jetkvm_connection_total_ping_count",
|
||||||
|
Help: "The total number of pings sent to the connection",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionSessionRequestCount = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "jetkvm_connection_session_total_request_count",
|
||||||
|
Help: "The total number of session requests received",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "jetkvm_connection_session_request_duration",
|
||||||
|
Help: "The duration of session requests",
|
||||||
|
Buckets: []float64{
|
||||||
|
0.1, 0.5, 1, 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionLastSessionRequestTimestamp = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_connection_last_session_request_timestamp",
|
||||||
|
Help: "The timestamp of the last session request",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionLastSessionRequestDuration = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_connection_last_session_request_duration",
|
||||||
|
Help: "The duration of the last session request",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricCloudConnectionFailureCount = promauto.NewCounter(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "jetkvm_cloud_connection_failure_count",
|
||||||
|
Help: "The number of times the cloud connection has failed",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cloudDisconnectChan chan error
|
||||||
|
cloudDisconnectLock = &sync.Mutex{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func wsResetMetrics(established bool, sourceType string, source string) {
|
||||||
|
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1)
|
||||||
|
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1)
|
||||||
|
|
||||||
|
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).Set(-1)
|
||||||
|
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(-1)
|
||||||
|
|
||||||
|
if sourceType != "cloud" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if established {
|
||||||
|
metricCloudConnectionEstablishedTimestamp.SetToCurrentTime()
|
||||||
|
metricCloudConnectionStatus.Set(1)
|
||||||
|
} else {
|
||||||
|
metricCloudConnectionEstablishedTimestamp.Set(-1)
|
||||||
|
metricCloudConnectionStatus.Set(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleCloudRegister(c *gin.Context) {
|
func handleCloudRegister(c *gin.Context) {
|
||||||
var req CloudRegisterRequest
|
var req CloudRegisterRequest
|
||||||
|
|
||||||
|
@ -90,11 +201,6 @@ func handleCloudRegister(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.CloudToken == "" {
|
|
||||||
cloudLogger.Info("Starting websocket client due to adoption")
|
|
||||||
go RunWebsocketClient()
|
|
||||||
}
|
|
||||||
|
|
||||||
config.CloudToken = tokenResp.SecretToken
|
config.CloudToken = tokenResp.SecretToken
|
||||||
|
|
||||||
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
|
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
|
||||||
|
@ -125,24 +231,47 @@ func handleCloudRegister(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"message": "Cloud registration successful"})
|
c.JSON(200, gin.H{"message": "Cloud registration successful"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func disconnectCloud(reason error) {
|
||||||
|
cloudDisconnectLock.Lock()
|
||||||
|
defer cloudDisconnectLock.Unlock()
|
||||||
|
|
||||||
|
if cloudDisconnectChan == nil {
|
||||||
|
cloudLogger.Tracef("cloud disconnect channel is not set, no need to disconnect")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// just in case the channel is closed, we don't want to panic
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
cloudLogger.Infof("cloud disconnect channel is closed, no need to disconnect: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
cloudDisconnectChan <- reason
|
||||||
|
}
|
||||||
|
|
||||||
func runWebsocketClient() error {
|
func runWebsocketClient() error {
|
||||||
if config.CloudToken == "" {
|
if config.CloudToken == "" {
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
return fmt.Errorf("cloud token is not set")
|
return fmt.Errorf("cloud token is not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
wsURL, err := url.Parse(config.CloudURL)
|
wsURL, err := url.Parse(config.CloudURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse config.CloudURL: %w", err)
|
return fmt.Errorf("failed to parse config.CloudURL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if wsURL.Scheme == "http" {
|
if wsURL.Scheme == "http" {
|
||||||
wsURL.Scheme = "ws"
|
wsURL.Scheme = "ws"
|
||||||
} else {
|
} else {
|
||||||
wsURL.Scheme = "wss"
|
wsURL.Scheme = "wss"
|
||||||
}
|
}
|
||||||
|
|
||||||
header := http.Header{}
|
header := http.Header{}
|
||||||
header.Set("X-Device-ID", GetDeviceID())
|
header.Set("X-Device-ID", GetDeviceID())
|
||||||
|
header.Set("X-App-Version", builtAppVersion)
|
||||||
header.Set("Authorization", "Bearer "+config.CloudToken)
|
header.Set("Authorization", "Bearer "+config.CloudToken)
|
||||||
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
|
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
|
||||||
|
|
||||||
defer cancelDial()
|
defer cancelDial()
|
||||||
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
|
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
|
||||||
HTTPHeader: header,
|
HTTPHeader: header,
|
||||||
|
@ -152,47 +281,15 @@ func runWebsocketClient() error {
|
||||||
}
|
}
|
||||||
defer c.CloseNow() //nolint:errcheck
|
defer c.CloseNow() //nolint:errcheck
|
||||||
cloudLogger.Infof("websocket connected to %s", wsURL)
|
cloudLogger.Infof("websocket connected to %s", wsURL)
|
||||||
runCtx, cancelRun := context.WithCancel(context.Background())
|
|
||||||
defer cancelRun()
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
time.Sleep(CloudWebSocketPingInterval)
|
|
||||||
err := c.Ping(runCtx)
|
|
||||||
if err != nil {
|
|
||||||
cloudLogger.Warnf("websocket ping error: %v", err)
|
|
||||||
cancelRun()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
for {
|
|
||||||
typ, msg, err := c.Read(runCtx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if typ != websocket.MessageText {
|
|
||||||
// ignore non-text messages
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var req WebRTCSessionRequest
|
|
||||||
err = json.Unmarshal(msg, &req)
|
|
||||||
if err != nil {
|
|
||||||
cloudLogger.Warnf("unable to parse ws message: %v", string(msg))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cloudLogger.Infof("new session request: %v", req.OidcGoogle)
|
// set the metrics when we successfully connect to the cloud.
|
||||||
cloudLogger.Tracef("session request info: %v", req)
|
wsResetMetrics(true, "cloud", "")
|
||||||
|
|
||||||
err = handleSessionRequest(runCtx, c, req)
|
// we don't have a source for the cloud connection
|
||||||
if err != nil {
|
return handleWebRTCSignalWsMessages(c, true, "")
|
||||||
cloudLogger.Infof("error starting new session: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
|
func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
|
||||||
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
|
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
|
||||||
defer cancelOIDC()
|
defer cancelOIDC()
|
||||||
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
|
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
|
||||||
|
@ -220,10 +317,35 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
|
||||||
return fmt.Errorf("google identity mismatch")
|
return fmt.Errorf("google identity mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest, isCloudConnection bool, source string) error {
|
||||||
|
var sourceType string
|
||||||
|
if isCloudConnection {
|
||||||
|
sourceType = "cloud"
|
||||||
|
} else {
|
||||||
|
sourceType = "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
|
||||||
|
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v)
|
||||||
|
metricConnectionSessionRequestDuration.WithLabelValues(sourceType, source).Observe(v)
|
||||||
|
}))
|
||||||
|
defer timer.ObserveDuration()
|
||||||
|
|
||||||
|
// If the message is from the cloud, we need to authenticate the session.
|
||||||
|
if isCloudConnection {
|
||||||
|
if err := authenticateSession(ctx, c, req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
session, err := newSession(SessionConfig{
|
session, err := newSession(SessionConfig{
|
||||||
ICEServers: req.ICEServers,
|
ws: c,
|
||||||
|
IsCloud: isCloudConnection,
|
||||||
LocalIP: req.IP,
|
LocalIP: req.IP,
|
||||||
IsCloud: true,
|
ICEServers: req.ICEServers,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
|
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
|
||||||
|
@ -247,15 +369,40 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
|
||||||
cloudLogger.Info("new session accepted")
|
cloudLogger.Info("new session accepted")
|
||||||
cloudLogger.Tracef("new session accepted: %v", session)
|
cloudLogger.Tracef("new session accepted: %v", session)
|
||||||
currentSession = session
|
currentSession = session
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"sd": sd})
|
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunWebsocketClient() {
|
func RunWebsocketClient() {
|
||||||
for {
|
for {
|
||||||
|
// reset the metrics when we start the websocket client.
|
||||||
|
wsResetMetrics(false, "cloud", "")
|
||||||
|
|
||||||
|
// If the cloud token is not set, we don't need to run the websocket client.
|
||||||
|
if config.CloudToken == "" {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the network is not up, well, we can't connect to the cloud.
|
||||||
|
if !networkState.Up {
|
||||||
|
cloudLogger.Warn("waiting for network to be up, will retry in 3 seconds")
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail.
|
||||||
|
if isTimeSyncNeeded() && !timeSyncSuccess {
|
||||||
|
cloudLogger.Warn("system time is not synced, will retry in 3 seconds")
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
err := runWebsocketClient()
|
err := runWebsocketClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cloudLogger.Errorf("websocket client error: %v", err)
|
cloudLogger.Errorf("websocket client error: %v", err)
|
||||||
|
metricCloudConnectionStatus.Set(0)
|
||||||
|
metricCloudConnectionFailureCount.Inc()
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -305,6 +452,9 @@ func rpcDeregisterDevice() error {
|
||||||
return fmt.Errorf("failed to save configuration after deregistering: %w", err)
|
return fmt.Errorf("failed to save configuration after deregistering: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cloudLogger.Infof("device deregistered, disconnecting from cloud")
|
||||||
|
disconnectCloud(fmt.Errorf("device deregistered"))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -771,9 +771,14 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
|
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
|
||||||
|
currentCloudURL := config.CloudURL
|
||||||
config.CloudURL = apiUrl
|
config.CloudURL = apiUrl
|
||||||
config.CloudAppURL = appUrl
|
config.CloudAppURL = appUrl
|
||||||
|
|
||||||
|
if currentCloudURL != apiUrl {
|
||||||
|
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
|
||||||
|
}
|
||||||
|
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
1
log.go
1
log.go
|
@ -6,3 +6,4 @@ import "github.com/pion/logging"
|
||||||
// ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
|
// ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
|
||||||
var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm")
|
var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm")
|
||||||
var cloudLogger = logging.NewDefaultLoggerFactory().NewLogger("cloud")
|
var cloudLogger = logging.NewDefaultLoggerFactory().NewLogger("cloud")
|
||||||
|
var websocketLogger = logging.NewDefaultLoggerFactory().NewLogger("websocket")
|
||||||
|
|
8
main.go
8
main.go
|
@ -72,11 +72,9 @@ func Main() {
|
||||||
if config.TLSMode != "" {
|
if config.TLSMode != "" {
|
||||||
go RunWebSecureServer()
|
go RunWebSecureServer()
|
||||||
}
|
}
|
||||||
// If the cloud token isn't set, the client won't be started by default.
|
// As websocket client already checks if the cloud token is set, we can start it here.
|
||||||
// However, if the user adopts the device via the web interface, handleCloudRegister will start the client.
|
go RunWebsocketClient()
|
||||||
if config.CloudToken != "" {
|
|
||||||
go RunWebsocketClient()
|
|
||||||
}
|
|
||||||
initSerialPort()
|
initSerialPort()
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
33
ntp.go
33
ntp.go
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/beevik/ntp"
|
"github.com/beevik/ntp"
|
||||||
|
@ -20,13 +21,41 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
builtTimestamp string
|
||||||
timeSyncRetryInterval = 0 * time.Second
|
timeSyncRetryInterval = 0 * time.Second
|
||||||
|
timeSyncSuccess = false
|
||||||
defaultNTPServers = []string{
|
defaultNTPServers = []string{
|
||||||
"time.cloudflare.com",
|
"time.cloudflare.com",
|
||||||
"time.apple.com",
|
"time.apple.com",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func isTimeSyncNeeded() bool {
|
||||||
|
if builtTimestamp == "" {
|
||||||
|
logger.Warnf("Built timestamp is not set, time sync is needed")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
ts, err := strconv.Atoi(builtTimestamp)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Failed to parse built timestamp: %v", err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// builtTimestamp is UNIX timestamp in seconds
|
||||||
|
builtTime := time.Unix(int64(ts), 0)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
logger.Tracef("Built time: %v, now: %v", builtTime, now)
|
||||||
|
|
||||||
|
if now.Sub(builtTime) < 0 {
|
||||||
|
logger.Warnf("System time is behind the built time, time sync is needed")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func TimeSyncLoop() {
|
func TimeSyncLoop() {
|
||||||
for {
|
for {
|
||||||
if !networkState.checked {
|
if !networkState.checked {
|
||||||
|
@ -40,6 +69,9 @@ func TimeSyncLoop() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check if time sync is needed, but do nothing for now
|
||||||
|
isTimeSyncNeeded()
|
||||||
|
|
||||||
logger.Infof("Syncing system time")
|
logger.Infof("Syncing system time")
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := SyncSystemTime()
|
err := SyncSystemTime()
|
||||||
|
@ -56,6 +88,7 @@ func TimeSyncLoop() {
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
timeSyncSuccess = true
|
||||||
logger.Infof("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))
|
logger.Infof("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))
|
||||||
time.Sleep(timeSyncInterval) // after the first sync is done
|
time.Sleep(timeSyncInterval) // after the first sync is done
|
||||||
}
|
}
|
||||||
|
|
10
ota.go
10
ota.go
|
@ -126,7 +126,15 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
||||||
return fmt.Errorf("error creating request: %w", err)
|
return fmt.Errorf("error creating request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
client := http.Client{
|
||||||
|
// allow a longer timeout for the download but keep the TLS handshake short
|
||||||
|
Timeout: 10 * time.Minute,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSHandshakeTimeout: 1 * time.Minute,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error downloading file: %w", err)
|
return fmt.Errorf("error downloading file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
|
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
|
||||||
"github.com/prometheus/common/version"
|
"github.com/prometheus/common/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
var promHandler http.Handler
|
|
||||||
|
|
||||||
func initPrometheus() {
|
func initPrometheus() {
|
||||||
// A Prometheus metrics endpoint.
|
// A Prometheus metrics endpoint.
|
||||||
version.Version = builtAppVersion
|
version.Version = builtAppVersion
|
||||||
|
|
|
@ -66,7 +66,6 @@ func runATXControl() {
|
||||||
newLedPWRState != ledPWRState ||
|
newLedPWRState != ledPWRState ||
|
||||||
newBtnRSTState != btnRSTState ||
|
newBtnRSTState != btnRSTState ||
|
||||||
newBtnPWRState != btnPWRState {
|
newBtnPWRState != btnPWRState {
|
||||||
|
|
||||||
logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v",
|
logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v",
|
||||||
newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState)
|
newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState)
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,8 @@ module.exports = {
|
||||||
"plugin:react-hooks/recommended",
|
"plugin:react-hooks/recommended",
|
||||||
"plugin:react/recommended",
|
"plugin:react/recommended",
|
||||||
"plugin:react/jsx-runtime",
|
"plugin:react/jsx-runtime",
|
||||||
|
"plugin:import/recommended",
|
||||||
|
"prettier",
|
||||||
],
|
],
|
||||||
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
|
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
|
@ -20,5 +22,45 @@ module.exports = {
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
"import/order": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @description
|
||||||
|
*
|
||||||
|
* This keeps imports separate from one another, ensuring that imports are separated
|
||||||
|
* by their relative groups. As you move through the groups, imports become closer
|
||||||
|
* to the current file.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```
|
||||||
|
* import fs from 'fs';
|
||||||
|
*
|
||||||
|
* import package from 'npm-package';
|
||||||
|
*
|
||||||
|
* import xyz from '~/project-file';
|
||||||
|
*
|
||||||
|
* import index from '../';
|
||||||
|
*
|
||||||
|
* import sibling from './foo';
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
groups: ["builtin", "external", "internal", "parent", "sibling"],
|
||||||
|
"newlines-between": "always",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
"import/resolver": {
|
||||||
|
alias: {
|
||||||
|
map: [
|
||||||
|
["@components", "./src/components"],
|
||||||
|
["@routes", "./src/routes"],
|
||||||
|
["@assets", "./src/assets"],
|
||||||
|
["@", "./src"],
|
||||||
|
],
|
||||||
|
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,11 +5,7 @@
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"arrowParens": "avoid",
|
"arrowParens": "avoid",
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"plugins": [
|
"plugins": ["prettier-plugin-tailwindcss"],
|
||||||
"prettier-plugin-tailwindcss"
|
"tailwindFunctions": ["clsx"],
|
||||||
],
|
|
||||||
"tailwindFunctions": [
|
|
||||||
"clsx"
|
|
||||||
],
|
|
||||||
"printWidth": 90
|
"printWidth": 90
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -13,12 +13,13 @@
|
||||||
"build:device": "tsc && vite build --mode=device --emptyOutDir",
|
"build:device": "tsc && vite build --mode=device --emptyOutDir",
|
||||||
"build:staging": "tsc && vite build --mode=cloud-staging",
|
"build:staging": "tsc && vite build --mode=cloud-staging",
|
||||||
"build:prod": "tsc && vite build --mode=cloud-production",
|
"build:prod": "tsc && vite build --mode=cloud-production",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint './src/**/*.{ts,tsx}'",
|
||||||
|
"lint:fix": "eslint './src/**/*.{ts,tsx}' --fix",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@headlessui/tailwindcss": "^0.2.2",
|
"@headlessui/tailwindcss": "^0.2.1",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-clipboard": "^0.1.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
@ -27,46 +28,50 @@
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"cva": "^1.0.0-beta.1",
|
"cva": "^1.0.0-beta.1",
|
||||||
|
"eslint-import-resolver-alias": "^1.1.2",
|
||||||
"focus-trap-react": "^10.2.3",
|
"focus-trap-react": "^10.2.3",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"mini-svg-data-uri": "^1.4.4",
|
"mini-svg-data-uri": "^1.4.4",
|
||||||
"motion": "^12.4.7",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-animate-height": "^3.2.3",
|
"react-animate-height": "^3.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"react-simple-keyboard": "^3.7.112",
|
"react-simple-keyboard": "^3.7.112",
|
||||||
|
"react-use-websocket": "^4.13.0",
|
||||||
"react-xtermjs": "^1.0.9",
|
"react-xtermjs": "^1.0.9",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.0",
|
||||||
"semver": "^7.7.1",
|
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.0",
|
||||||
"validator": "^13.12.0",
|
"validator": "^13.12.0",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/semver": "^7.5.8",
|
||||||
"@types/validator": "^13.12.2",
|
"@types/validator": "^13.12.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
"@typescript-eslint/eslint-plugin": "^8.25.0",
|
||||||
"@typescript-eslint/parser": "^7.2.0",
|
"@typescript-eslint/parser": "^8.25.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.20.0",
|
||||||
"eslint-plugin-react": "^7.34.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.6",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"postcss": "^8.5.3",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"prettier": "^3.5.2",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.13",
|
"postcss": "^8.4.49",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^5.2.0",
|
"vite": "^5.2.0",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,10 @@
|
||||||
|
import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||||
|
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
||||||
|
import { FaKeyboard } from "react-icons/fa6";
|
||||||
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||||
|
import { Fragment, useCallback, useRef } from "react";
|
||||||
|
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import {
|
import {
|
||||||
useHidStore,
|
useHidStore,
|
||||||
|
@ -5,19 +12,13 @@ import {
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useUiStore,
|
useUiStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { MdOutlineContentPasteGo } from "react-icons/md";
|
|
||||||
import Container from "@components/Container";
|
import Container from "@components/Container";
|
||||||
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import PasteModal from "@/components/popovers/PasteModal";
|
import PasteModal from "@/components/popovers/PasteModal";
|
||||||
import { FaKeyboard } from "react-icons/fa6";
|
|
||||||
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
||||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
import MountPopopover from "@/components/popovers/MountPopover";
|
||||||
import MountPopopover from "./popovers/MountPopover";
|
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
||||||
import { Fragment, useCallback, useRef } from "react";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
|
||||||
import ExtensionPopover from "./popovers/ExtensionPopover";
|
|
||||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
|
||||||
|
|
||||||
export default function Actionbar({
|
export default function Actionbar({
|
||||||
requestFullscreen,
|
requestFullscreen,
|
||||||
|
|
|
@ -1,21 +1,22 @@
|
||||||
|
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { Button, LinkButton } from "@components/Button";
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import { GoogleIcon } from "@components/Icons";
|
import { GoogleIcon } from "@components/Icons";
|
||||||
import SimpleNavbar from "@components/SimpleNavbar";
|
import SimpleNavbar from "@components/SimpleNavbar";
|
||||||
import Container from "@components/Container";
|
import Container from "@components/Container";
|
||||||
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
|
|
||||||
import Fieldset from "@components/Fieldset";
|
import Fieldset from "@components/Fieldset";
|
||||||
import GridBackground from "@components/GridBackground";
|
import GridBackground from "@components/GridBackground";
|
||||||
import StepCounter from "@components/StepCounter";
|
import StepCounter from "@components/StepCounter";
|
||||||
import { CLOUD_API } from "@/ui.config";
|
import { CLOUD_API } from "@/ui.config";
|
||||||
|
|
||||||
type AuthLayoutProps = {
|
interface AuthLayoutProps {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
action: string;
|
action: string;
|
||||||
cta: string;
|
cta: string;
|
||||||
ctaHref: string;
|
ctaHref: string;
|
||||||
showCounter?: boolean;
|
showCounter?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function AuthLayout({
|
export default function AuthLayout({
|
||||||
title,
|
title,
|
||||||
|
@ -46,8 +47,8 @@ export default function AuthLayout({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Container>
|
<Container>
|
||||||
<div className="flex items-center justify-center w-full h-full isolate">
|
<div className="isolate flex h-full w-full items-center justify-center">
|
||||||
<div className="max-w-2xl -mt-16 space-y-8">
|
<div className="-mt-16 max-w-2xl space-y-8">
|
||||||
{showCounter ? (
|
{showCounter ? (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<StepCounter currStepIdx={0} nSteps={2} />
|
<StepCounter currStepIdx={0} nSteps={2} />
|
||||||
|
@ -61,11 +62,8 @@ export default function AuthLayout({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Fieldset className="space-y-12">
|
<Fieldset className="space-y-12">
|
||||||
<div className="max-w-sm mx-auto space-y-4">
|
<div className="mx-auto max-w-sm space-y-4">
|
||||||
<form
|
<form action={`${CLOUD_API}/oidc/google`} method="POST">
|
||||||
action={`${CLOUD_API}/oidc/google`}
|
|
||||||
method="POST"
|
|
||||||
>
|
|
||||||
{/*This could be the KVM ID*/}
|
{/*This could be the KVM ID*/}
|
||||||
{deviceId ? (
|
{deviceId ? (
|
||||||
<input type="hidden" name="deviceId" value={deviceId} />
|
<input type="hidden" name="deviceId" value={deviceId} />
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
|
||||||
|
|
||||||
import ExtLink from "@/components/ExtLink";
|
import ExtLink from "@/components/ExtLink";
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||||
import { cva, cx } from "@/cva.config";
|
import { cva, cx } from "@/cva.config";
|
||||||
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
|
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
XS: "h-[28px] px-2 text-xs",
|
XS: "h-[28px] px-2 text-xs",
|
||||||
|
@ -101,7 +102,7 @@ const iconVariants = cva({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type ButtonContentPropsType = {
|
interface ButtonContentPropsType {
|
||||||
text?: string | React.ReactNode;
|
text?: string | React.ReactNode;
|
||||||
LeadingIcon?: React.FC<{ className: string | undefined }> | null;
|
LeadingIcon?: React.FC<{ className: string | undefined }> | null;
|
||||||
TrailingIcon?: React.FC<{ className: string | undefined }> | null;
|
TrailingIcon?: React.FC<{ className: string | undefined }> | null;
|
||||||
|
@ -111,7 +112,7 @@ type ButtonContentPropsType = {
|
||||||
size: keyof typeof sizes;
|
size: keyof typeof sizes;
|
||||||
theme: keyof typeof themes;
|
theme: keyof typeof themes;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
function ButtonContent(props: ButtonContentPropsType) {
|
function ButtonContent(props: ButtonContentPropsType) {
|
||||||
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =
|
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
type CardPropsType = {
|
interface CardPropsType {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const GridCard = ({
|
export const GridCard = ({
|
||||||
children,
|
children,
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
headline: string;
|
headline: string;
|
||||||
description?: string | React.ReactNode;
|
description?: string | React.ReactNode;
|
||||||
Button?: React.ReactNode;
|
Button?: React.ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const CardHeader = ({ headline, description, Button }: Props) => {
|
export const CardHeader = ({ headline, description, Button }: Props) => {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import type { Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
import { cva, cx } from "@/cva.config";
|
import { cva, cx } from "@/cva.config";
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
|
@ -52,7 +53,7 @@ type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
|
||||||
|
|
||||||
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
|
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
|
||||||
function CheckboxWithLabel(
|
function CheckboxWithLabel(
|
||||||
{ label, id, description, as, fullWidth, readOnly, ...props },
|
{ label, id, description, fullWidth, readOnly, ...props },
|
||||||
ref: Ref<HTMLInputElement>,
|
ref: Ref<HTMLInputElement>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
function Container({ children, className }: { children: ReactNode; className?: string }) {
|
function Container({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
|
|
||||||
export type CustomTooltipProps = {
|
export interface CustomTooltipProps {
|
||||||
payload: { payload: { date: number; stat: number }; unit: string }[];
|
payload: { payload: { date: number; stat: number }; unit: string }[];
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function CustomTooltip({ payload }: CustomTooltipProps) {
|
export default function CustomTooltip({ payload }: CustomTooltipProps) {
|
||||||
if (payload?.length) {
|
if (payload?.length) {
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import { GridCard } from "@/components/Card";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import { GridCard } from "@/components/Card";
|
||||||
|
|
||||||
import { cx } from "../cva.config";
|
import { cx } from "../cva.config";
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
IconElm?: React.FC<any>;
|
IconElm?: React.FC<{ className: string | undefined }>;
|
||||||
headline: string;
|
headline: string;
|
||||||
description?: string | React.ReactNode;
|
description?: string | React.ReactNode;
|
||||||
BtnElm?: React.ReactNode;
|
BtnElm?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function EmptyCard({
|
export default function EmptyCard({
|
||||||
IconElm,
|
IconElm,
|
||||||
|
@ -27,10 +29,16 @@ export default function EmptyCard({
|
||||||
>
|
>
|
||||||
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
|
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{IconElm && <IconElm className="w-6 h-6 mx-auto text-blue-600 dark:text-blue-400" />}
|
{IconElm && (
|
||||||
<h4 className="text-base font-bold leading-none text-black dark:text-white">{headline}</h4>
|
<IconElm className="mx-auto h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||||
|
)}
|
||||||
|
<h4 className="text-base font-bold leading-none text-black dark:text-white">
|
||||||
|
{headline}
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className="mx-auto text-sm text-slate-600 dark:text-slate-400">{description}</p>
|
<p className="mx-auto text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{BtnElm}
|
{BtnElm}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
export default function ExtLink({
|
export default function ExtLink({
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { useFeatureFlag } from "../hooks/useFeatureFlag";
|
import { useFeatureFlag } from "../hooks/useFeatureFlag";
|
||||||
|
|
||||||
export function FeatureFlag({
|
export function FeatureFlag({
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
label: string | React.ReactNode;
|
label: string | React.ReactNode;
|
||||||
id?: string;
|
id?: string;
|
||||||
as?: "label" | "span";
|
as?: "label" | "span";
|
||||||
description?: string | React.ReactNode | null;
|
description?: string | React.ReactNode | null;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
}
|
||||||
export default function FieldLabel({
|
export default function FieldLabel({
|
||||||
label,
|
label,
|
||||||
id,
|
id,
|
||||||
|
|
|
@ -9,7 +9,7 @@ export default function Fieldset({
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
fetcher?: FetcherWithComponents<any>;
|
fetcher?: FetcherWithComponents<unknown>;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|
|
@ -2,19 +2,22 @@ import { Fragment, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
|
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||||
import { Menu, MenuButton } from "@headlessui/react";
|
import { Menu, MenuButton } from "@headlessui/react";
|
||||||
|
import { LuMonitorSmartphone } from "react-icons/lu";
|
||||||
|
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import { LuMonitorSmartphone } from "react-icons/lu";
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
|
||||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
import USBStateStatus from "@components/USBStateStatus";
|
import USBStateStatus from "@components/USBStateStatus";
|
||||||
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
||||||
|
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
import { isOnDevice } from "../main";
|
import { isOnDevice } from "../main";
|
||||||
|
|
||||||
import { Button, LinkButton } from "./Button";
|
import { Button, LinkButton } from "./Button";
|
||||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
@ -33,7 +36,7 @@ export default function DashboardNavbar({
|
||||||
picture,
|
picture,
|
||||||
kvmName,
|
kvmName,
|
||||||
}: NavbarProps) {
|
}: NavbarProps) {
|
||||||
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
|
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||||
const setUser = useUserStore(state => state.setUser);
|
const setUser = useUserStore(state => state.setUser);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const onLogout = useCallback(async () => {
|
const onLogout = useCallback(async () => {
|
||||||
|
@ -86,7 +89,7 @@ export default function DashboardNavbar({
|
||||||
<div className="hidden w-[159px] md:block">
|
<div className="hidden w-[159px] md:block">
|
||||||
<USBStateStatus
|
<USBStateStatus
|
||||||
state={usbState}
|
state={usbState}
|
||||||
peerConnectionState={peerConnectionState}
|
peerConnectionState={peerConnectionState}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import {
|
import {
|
||||||
useHidStore,
|
useHidStore,
|
||||||
|
@ -6,7 +8,6 @@ import {
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
|
|
||||||
export default function InfoBar() {
|
export default function InfoBar() {
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import type { Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import { cva } from "@/cva.config";
|
import { cva } from "@/cva.config";
|
||||||
|
|
||||||
|
@ -84,7 +85,7 @@ const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProp
|
||||||
{(label || description) && (
|
{(label || description) && (
|
||||||
<FieldLabel label={label} id={id} description={description} />
|
<FieldLabel label={label} id={id} description={description} />
|
||||||
)}
|
)}
|
||||||
<InputField ref={ref as any} id={id} {...props} />
|
<InputField ref={ref as never} id={id} {...props} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { Button, LinkButton } from "@components/Button";
|
|
||||||
import Card from "@components/Card";
|
|
||||||
import { MdConnectWithoutContact } from "react-icons/md";
|
import { MdConnectWithoutContact } from "react-icons/md";
|
||||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { LuEllipsisVertical } from "react-icons/lu";
|
import { LuEllipsisVertical } from "react-icons/lu";
|
||||||
|
|
||||||
|
import Card from "@components/Card";
|
||||||
|
import { Button, LinkButton } from "@components/Button";
|
||||||
|
|
||||||
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
|
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
|
||||||
// Allow dates or times to be passed
|
// Allow dates or times to be passed
|
||||||
const timeMs = typeof date === "number" ? date : date.getTime();
|
const timeMs = typeof date === "number" ? date : date.getTime();
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
|
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
const Modal = React.memo(function Modal({
|
const Modal = React.memo(function Modal({
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
import EmptyCard from "@/components/EmptyCard";
|
import EmptyCard from "@/components/EmptyCard";
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
|
|
|
@ -9,21 +9,22 @@ const PeerConnectionStatusMap = {
|
||||||
failed: "Connection failed",
|
failed: "Connection failed",
|
||||||
closed: "Closed",
|
closed: "Closed",
|
||||||
new: "Connecting",
|
new: "Connecting",
|
||||||
};
|
} as Record<RTCPeerConnectionState | "error" | "closing", string>;
|
||||||
|
|
||||||
export type PeerConnections = keyof typeof PeerConnectionStatusMap;
|
export type PeerConnections = keyof typeof PeerConnectionStatusMap;
|
||||||
|
|
||||||
type StatusProps = {
|
type StatusProps = Record<
|
||||||
[key in PeerConnections]: {
|
PeerConnections,
|
||||||
|
{
|
||||||
statusIndicatorClassName: string;
|
statusIndicatorClassName: string;
|
||||||
};
|
}
|
||||||
};
|
>;
|
||||||
|
|
||||||
export default function PeerConnectionStatusCard({
|
export default function PeerConnectionStatusCard({
|
||||||
state,
|
state,
|
||||||
title,
|
title,
|
||||||
}: {
|
}: {
|
||||||
state?: PeerConnections;
|
state?: RTCPeerConnectionState | null;
|
||||||
title?: string;
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Card from "./Card";
|
|
||||||
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
import { cva } from "@/cva.config";
|
import { cva } from "@/cva.config";
|
||||||
|
|
||||||
|
import Card from "./Card";
|
||||||
|
|
||||||
|
|
||||||
type SelectMenuProps = Pick<
|
type SelectMenuProps = Pick<
|
||||||
JSX.IntrinsicElements["select"],
|
JSX.IntrinsicElements["select"],
|
||||||
"disabled" | "onChange" | "name" | "value"
|
"disabled" | "onChange" | "name" | "value"
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import Container from "@/components/Container";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import Container from "@/components/Container";
|
||||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
|
|
||||||
type Props = { logoHref?: string; actionElement?: React.ReactNode };
|
interface Props { logoHref?: string; actionElement?: React.ReactNode }
|
||||||
|
|
||||||
export default function SimpleNavbar({ logoHref, actionElement }: Props) {
|
export default function SimpleNavbar({ logoHref, actionElement }: Props) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
|
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
|
||||||
|
|
||||||
export default function StatChart({
|
export default function StatChart({
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import { CheckIcon } from "@heroicons/react/16/solid";
|
import { CheckIcon } from "@heroicons/react/16/solid";
|
||||||
|
|
||||||
import { cva, cx } from "@/cva.config";
|
import { cva, cx } from "@/cva.config";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
nSteps: number;
|
nSteps: number;
|
||||||
currStepIdx: number;
|
currStepIdx: number;
|
||||||
size?: keyof typeof sizes;
|
size?: keyof typeof sizes;
|
||||||
};
|
}
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
SM: "text-xs leading-[12px]",
|
SM: "text-xs leading-[12px]",
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import "react-simple-keyboard/build/css/index.css";
|
import "react-simple-keyboard/build/css/index.css";
|
||||||
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||||
import { cx } from "@/cva.config";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useXTerm } from "react-xtermjs";
|
import { useXTerm } from "react-xtermjs";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
@ -11,6 +8,11 @@ import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||||
|
|
||||||
|
import { cx } from "@/cva.config";
|
||||||
|
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
|
||||||
|
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
|
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
|
||||||
|
|
||||||
// Terminal theme configuration
|
// Terminal theme configuration
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
import { FieldError } from "@/components/InputField";
|
import { FieldError } from "@/components/InputField";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
|
@ -1,23 +1,23 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
|
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
|
||||||
import React from "react";
|
|
||||||
import LoadingSpinner from "@components/LoadingSpinner";
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
import StatusCard from "@components/StatusCards";
|
import StatusCard from "@components/StatusCards";
|
||||||
import { HidState } from "@/hooks/stores";
|
import { HidState } from "@/hooks/stores";
|
||||||
|
|
||||||
type USBStates = HidState["usbState"];
|
type USBStates = HidState["usbState"];
|
||||||
|
|
||||||
type StatusProps = {
|
type StatusProps = Record<
|
||||||
[key in USBStates]: {
|
USBStates,
|
||||||
|
{
|
||||||
icon: React.FC<{ className: string | undefined }>;
|
icon: React.FC<{ className: string | undefined }>;
|
||||||
iconClassName: string;
|
iconClassName: string;
|
||||||
statusIndicatorClassName: string;
|
statusIndicatorClassName: string;
|
||||||
};
|
}
|
||||||
};
|
>;
|
||||||
|
|
||||||
const USBStateMap: {
|
const USBStateMap: Record<USBStates, string> = {
|
||||||
[key in USBStates]: string;
|
|
||||||
} = {
|
|
||||||
configured: "Connected",
|
configured: "Connected",
|
||||||
attached: "Connecting",
|
attached: "Connecting",
|
||||||
addressed: "Connecting",
|
addressed: "Connecting",
|
||||||
|
@ -30,9 +30,8 @@ export default function USBStateStatus({
|
||||||
peerConnectionState,
|
peerConnectionState,
|
||||||
}: {
|
}: {
|
||||||
state: USBStates;
|
state: USBStates;
|
||||||
peerConnectionState?: RTCPeerConnectionState;
|
peerConnectionState?: RTCPeerConnectionState | null;
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
const StatusCardProps: StatusProps = {
|
const StatusCardProps: StatusProps = {
|
||||||
configured: {
|
configured: {
|
||||||
icon: ({ className }) => (
|
icon: ({ className }) => (
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
|
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||||
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { GridCard } from "./Card";
|
import { GridCard } from "./Card";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
|
||||||
|
|
||||||
export default function UpdateInProgressStatusCard() {
|
export default function UpdateInProgressStatusCard() {
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback , useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { SettingsItem } from "../routes/devices.$id.settings";
|
import { SettingsItem } from "../routes/devices.$id.settings";
|
||||||
|
|
||||||
import Checkbox from "./Checkbox";
|
import Checkbox from "./Checkbox";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { SelectMenuBasic } from "./SelectMenuBasic";
|
import { SelectMenuBasic } from "./SelectMenuBasic";
|
||||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||||
import Fieldset from "./Fieldset";
|
import Fieldset from "./Fieldset";
|
||||||
|
|
||||||
export interface USBConfig {
|
export interface USBConfig {
|
||||||
vendor_id: string;
|
vendor_id: string;
|
||||||
product_id: string;
|
product_id: string;
|
||||||
|
@ -119,13 +118,12 @@ export function UsbDeviceSetting() {
|
||||||
|
|
||||||
const onUsbConfigItemChange = useCallback(
|
const onUsbConfigItemChange = useCallback(
|
||||||
(key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
(key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setUsbDeviceConfig(val => {
|
setUsbDeviceConfig(prev => ({
|
||||||
val[key] = e.target.checked;
|
...prev,
|
||||||
handleUsbConfigChange(val);
|
[key]: e.target.checked,
|
||||||
return val;
|
}));
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[handleUsbConfigChange],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePresetChange = useCallback(
|
const handlePresetChange = useCallback(
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo , useCallback , useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { InputFieldWithLabel } from "./InputField";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { UsbConfigState } from "../hooks/stores";
|
import { UsbConfigState } from "../hooks/stores";
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { SettingsItem } from "../routes/devices.$id.settings";
|
import { SettingsItem } from "../routes/devices.$id.settings";
|
||||||
|
|
||||||
|
import { InputFieldWithLabel } from "./InputField";
|
||||||
import { SelectMenuBasic } from "./SelectMenuBasic";
|
import { SelectMenuBasic } from "./SelectMenuBasic";
|
||||||
import Fieldset from "./Fieldset";
|
import Fieldset from "./Fieldset";
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||||
import { ArrowRightIcon } from "@heroicons/react/16/solid";
|
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||||
import { LinkButton } from "@components/Button";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { LuPlay } from "react-icons/lu";
|
||||||
|
|
||||||
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import LoadingSpinner from "@components/LoadingSpinner";
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
import { GridCard } from "@components/Card";
|
import Card, { GridCard } from "@components/Card";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
|
||||||
|
|
||||||
interface OverlayContentProps {
|
interface OverlayContentProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
@ -23,18 +25,18 @@ interface LoadingOverlayProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadingOverlay({ show }: LoadingOverlayProps) {
|
export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 aspect-video h-full w-full"
|
className="aspect-video h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{
|
transition={{
|
||||||
duration: show ? 0.3 : 0.1,
|
duration: show ? 0.3 : 0.1,
|
||||||
ease: "easeInOut"
|
ease: "easeInOut",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OverlayContent>
|
<OverlayContent>
|
||||||
|
@ -53,22 +55,60 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectionErrorOverlayProps {
|
interface LoadingConnectionOverlayProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
|
text: string;
|
||||||
}
|
}
|
||||||
|
export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverlayProps) {
|
||||||
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 z-10 aspect-video h-full w-full"
|
className="aspect-video h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
transition={{
|
transition={{
|
||||||
duration: 0.3,
|
duration: 0.4,
|
||||||
ease: "easeInOut"
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OverlayContent>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-y-1">
|
||||||
|
<div className="animate flex h-12 w-12 items-center justify-center">
|
||||||
|
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</OverlayContent>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionErrorOverlayProps {
|
||||||
|
show: boolean;
|
||||||
|
setupPeerConnection: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectionFailedOverlay({
|
||||||
|
show,
|
||||||
|
setupPeerConnection,
|
||||||
|
}: ConnectionErrorOverlayProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<motion.div
|
||||||
|
className="aspect-video h-full w-full"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "easeInOut",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OverlayContent>
|
<OverlayContent>
|
||||||
|
@ -85,14 +125,75 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
|
||||||
<li>Try restarting both the device and your computer</li>
|
<li>Try restarting both the device and your computer</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-x-2">
|
||||||
<LinkButton
|
<LinkButton
|
||||||
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
||||||
theme="light"
|
theme="primary"
|
||||||
text="Troubleshooting Guide"
|
text="Troubleshooting Guide"
|
||||||
TrailingIcon={ArrowRightIcon}
|
TrailingIcon={ArrowRightIcon}
|
||||||
size="SM"
|
size="SM"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => setupPeerConnection()}
|
||||||
|
LeadingIcon={ArrowPathIcon}
|
||||||
|
text="Try again"
|
||||||
|
size="SM"
|
||||||
|
theme="light"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</OverlayContent>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PeerConnectionDisconnectedOverlay {
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeerConnectionDisconnectedOverlay({
|
||||||
|
show,
|
||||||
|
}: PeerConnectionDisconnectedOverlay) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<motion.div
|
||||||
|
className="aspect-video h-full w-full"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OverlayContent>
|
||||||
|
<div className="flex flex-col items-start gap-y-1">
|
||||||
|
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
|
||||||
|
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2 text-black dark:text-white">
|
||||||
|
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
|
||||||
|
<ul className="list-disc space-y-2 pl-4 text-left">
|
||||||
|
<li>Verify that the device is powered on and properly connected</li>
|
||||||
|
<li>Check all cable connections for any loose or damaged wires</li>
|
||||||
|
<li>Ensure your network connection is stable and active</li>
|
||||||
|
<li>Try restarting both the device and your computer</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-x-2 p-4">
|
||||||
|
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
|
||||||
|
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
Retrying connection...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -118,25 +219,27 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && isNoSignal && (
|
{show && isNoSignal && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 w-full h-full aspect-video"
|
className="absolute inset-0 aspect-video h-full w-full"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{
|
transition={{
|
||||||
duration: 0.3,
|
duration: 0.3,
|
||||||
ease: "easeInOut"
|
ease: "easeInOut",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OverlayContent>
|
<OverlayContent>
|
||||||
<div className="flex flex-col items-start gap-y-1">
|
<div className="flex flex-col items-start gap-y-1">
|
||||||
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
|
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
|
||||||
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
|
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2 text-black dark:text-white">
|
<div className="space-y-2 text-black dark:text-white">
|
||||||
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
|
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
|
||||||
<ul className="list-disc space-y-2 pl-4 text-left">
|
<ul className="list-disc space-y-2 pl-4 text-left">
|
||||||
<li>Ensure the HDMI cable securely connected at both ends</li>
|
<li>Ensure the HDMI cable securely connected at both ends</li>
|
||||||
<li>Ensure source device is powered on and outputting a signal</li>
|
<li>
|
||||||
|
Ensure source device is powered on and outputting a signal
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
If using an adapter, it's compatible and functioning
|
If using an adapter, it's compatible and functioning
|
||||||
correctly
|
correctly
|
||||||
|
@ -169,7 +272,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{
|
transition={{
|
||||||
duration: 0.3,
|
duration: 0.3,
|
||||||
ease: "easeInOut"
|
ease: "easeInOut",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OverlayContent>
|
<OverlayContent>
|
||||||
|
@ -187,7 +290,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
to={"/help/hdmi-error"}
|
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Learn more"
|
text="Learn more"
|
||||||
TrailingIcon={ArrowRightIcon}
|
TrailingIcon={ArrowRightIcon}
|
||||||
|
@ -204,3 +307,54 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NoAutoplayPermissionsOverlayProps {
|
||||||
|
show: boolean;
|
||||||
|
onPlayClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoAutoplayPermissionsOverlay({
|
||||||
|
show,
|
||||||
|
onPlayClick,
|
||||||
|
}: NoAutoplayPermissionsOverlayProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 z-10 aspect-video h-full w-full"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.3,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OverlayContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-2xl font-extrabold text-black dark:text-white">
|
||||||
|
Autoplay permissions required
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="MD"
|
||||||
|
theme="primary"
|
||||||
|
LeadingIcon={LuPlay}
|
||||||
|
text="Manually start stream"
|
||||||
|
onClick={onPlayClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
Please adjust browser settings to enable autoplay
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</OverlayContent>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Keyboard from "react-simple-keyboard";
|
import Keyboard from "react-simple-keyboard";
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import Card from "@components/Card";
|
|
||||||
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
import Card from "@components/Card";
|
||||||
|
// eslint-disable-next-line import/order
|
||||||
|
import { Button } from "@components/Button";
|
||||||
|
|
||||||
import "react-simple-keyboard/build/css/index.css";
|
import "react-simple-keyboard/build/css/index.css";
|
||||||
|
|
||||||
import { useHidStore, useUiStore } from "@/hooks/stores";
|
import { useHidStore, useUiStore } from "@/hooks/stores";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useDeviceSettingsStore,
|
useDeviceSettingsStore,
|
||||||
useHidStore,
|
useHidStore,
|
||||||
|
@ -15,9 +16,12 @@ import Actionbar from "@components/ActionBar";
|
||||||
import InfoBar from "@components/InfoBar";
|
import InfoBar from "@components/InfoBar";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { HDMIErrorOverlay } from "./VideoOverlay";
|
|
||||||
import { ConnectionErrorOverlay } from "./VideoOverlay";
|
import {
|
||||||
import { LoadingOverlay } from "./VideoOverlay";
|
HDMIErrorOverlay,
|
||||||
|
LoadingVideoOverlay,
|
||||||
|
NoAutoplayPermissionsOverlay,
|
||||||
|
} from "./VideoOverlay";
|
||||||
|
|
||||||
export default function WebRTCVideo() {
|
export default function WebRTCVideo() {
|
||||||
// Video and stream related refs and states
|
// Video and stream related refs and states
|
||||||
|
@ -41,15 +45,13 @@ export default function WebRTCVideo() {
|
||||||
|
|
||||||
// RTC related states
|
// RTC related states
|
||||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
|
||||||
|
|
||||||
// HDMI and UI states
|
// HDMI and UI states
|
||||||
const hdmiState = useVideoStore(state => state.hdmiState);
|
const hdmiState = useVideoStore(state => state.hdmiState);
|
||||||
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
|
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
|
||||||
const isLoading = !hdmiError && !isPlaying;
|
const isVideoLoading = !isPlaying;
|
||||||
const isConnectionError = ["error", "failed", "disconnected"].includes(
|
|
||||||
peerConnectionState || "",
|
// console.log("peerConnection?.connectionState", peerConnection?.connectionState);
|
||||||
);
|
|
||||||
|
|
||||||
// Keyboard related states
|
// Keyboard related states
|
||||||
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
|
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
|
||||||
|
@ -82,19 +84,19 @@ export default function WebRTCVideo() {
|
||||||
|
|
||||||
const onVideoPlaying = useCallback(() => {
|
const onVideoPlaying = useCallback(() => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
videoElm.current && updateVideoSizeStore(videoElm.current);
|
if (videoElm.current) updateVideoSizeStore(videoElm.current);
|
||||||
}, [updateVideoSizeStore]);
|
}, [updateVideoSizeStore]);
|
||||||
|
|
||||||
// On mount, get the video size
|
// On mount, get the video size
|
||||||
useEffect(
|
useEffect(
|
||||||
function updateVideoSizeOnMount() {
|
function updateVideoSizeOnMount() {
|
||||||
videoElm.current && updateVideoSizeStore(videoElm.current);
|
if (videoElm.current) updateVideoSizeStore(videoElm.current);
|
||||||
},
|
},
|
||||||
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
|
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mouse-related
|
// Mouse-related
|
||||||
const calcDelta = (pos: number) => Math.abs(pos) < 10 ? pos * 2 : pos;
|
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
|
||||||
const sendRelMouseMovement = useCallback(
|
const sendRelMouseMovement = useCallback(
|
||||||
(x: number, y: number, buttons: number) => {
|
(x: number, y: number, buttons: number) => {
|
||||||
if (settings.mouseMode !== "relative") return;
|
if (settings.mouseMode !== "relative") return;
|
||||||
|
@ -168,7 +170,14 @@ export default function WebRTCVideo() {
|
||||||
const { buttons } = e;
|
const { buttons } = e;
|
||||||
sendAbsMouseMovement(x, y, buttons);
|
sendAbsMouseMovement(x, y, buttons);
|
||||||
},
|
},
|
||||||
[sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode],
|
[
|
||||||
|
sendAbsMouseMovement,
|
||||||
|
videoClientHeight,
|
||||||
|
videoClientWidth,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
settings.mouseMode,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
|
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
|
||||||
|
@ -355,7 +364,68 @@ export default function WebRTCVideo() {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Effect hooks
|
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
|
||||||
|
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video
|
||||||
|
// there is no way to prevent this, so we need to simply force play the video when it's paused.
|
||||||
|
// Fix only works in chrome based browsers.
|
||||||
|
if (e.code === "Space") {
|
||||||
|
if (videoElm.current?.paused == true) {
|
||||||
|
console.log("Force playing video");
|
||||||
|
videoElm.current?.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addStreamToVideoElm = useCallback(
|
||||||
|
(mediaStream: MediaStream) => {
|
||||||
|
if (!videoElm.current) return;
|
||||||
|
const videoElmRefValue = videoElm.current;
|
||||||
|
// console.log("Adding stream to video element", videoElmRefValue);
|
||||||
|
videoElmRefValue.srcObject = mediaStream;
|
||||||
|
updateVideoSizeStore(videoElmRefValue);
|
||||||
|
},
|
||||||
|
[updateVideoSizeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function updateVideoStreamOnNewTrack() {
|
||||||
|
if (!peerConnection) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const signal = abortController.signal;
|
||||||
|
|
||||||
|
peerConnection.addEventListener(
|
||||||
|
"track",
|
||||||
|
(e: RTCTrackEvent) => {
|
||||||
|
// console.log("Adding stream to video element");
|
||||||
|
addStreamToVideoElm(e.streams[0]);
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[addStreamToVideoElm, peerConnection],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function updateVideoStream() {
|
||||||
|
if (!mediaStream) return;
|
||||||
|
console.log("Updating video stream from mediaStream");
|
||||||
|
// We set the as early as possible
|
||||||
|
addStreamToVideoElm(mediaStream);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
setVideoClientSize,
|
||||||
|
mediaStream,
|
||||||
|
updateVideoSizeStore,
|
||||||
|
peerConnection,
|
||||||
|
addStreamToVideoElm,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup Keyboard Events
|
||||||
useEffect(
|
useEffect(
|
||||||
function setupKeyboardEvents() {
|
function setupKeyboardEvents() {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
@ -377,47 +447,23 @@ export default function WebRTCVideo() {
|
||||||
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
|
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
|
// Setup Video Event Listeners
|
||||||
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video
|
|
||||||
// there is no way to prevent this, so we need to simply force play the video when it's paused.
|
|
||||||
// Fix only works in chrome based browsers.
|
|
||||||
if (e.code === "Space") {
|
|
||||||
if (videoElm.current?.paused == true) {
|
|
||||||
console.log("Force playing video");
|
|
||||||
videoElm.current?.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
function setupVideoEventListeners() {
|
function setupVideoEventListeners() {
|
||||||
let videoElmRefValue = null;
|
const videoElmRefValue = videoElm.current;
|
||||||
if (!videoElm.current) return;
|
if (!videoElmRefValue) return;
|
||||||
videoElmRefValue = videoElm.current;
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
|
|
||||||
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
|
// To prevent the video from being paused when the user presses a space in fullscreen mode
|
||||||
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
|
|
||||||
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
|
|
||||||
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
|
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
|
||||||
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
|
|
||||||
signal,
|
// We need to know when the video is playing to update state and video size
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
videoElmRefValue.addEventListener(
|
|
||||||
"contextmenu",
|
|
||||||
(e: MouseEvent) => e.preventDefault(),
|
|
||||||
{ signal },
|
|
||||||
);
|
|
||||||
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
|
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
|
||||||
|
|
||||||
const local = resetMousePosition;
|
|
||||||
window.addEventListener("blur", local, { signal });
|
|
||||||
document.addEventListener("visibilitychange", local, { signal });
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (videoElmRefValue) abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
@ -429,6 +475,41 @@ export default function WebRTCVideo() {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Setup Absolute Mouse Events
|
||||||
|
useEffect(
|
||||||
|
function setAbsoluteMouseModeEventListeners() {
|
||||||
|
const videoElmRefValue = videoElm.current;
|
||||||
|
if (!videoElmRefValue) return;
|
||||||
|
|
||||||
|
if (settings.mouseMode !== "absolute") return;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const signal = abortController.signal;
|
||||||
|
|
||||||
|
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
|
||||||
|
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
|
||||||
|
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
|
||||||
|
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
|
||||||
|
signal,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset the mouse position when the window is blurred or the document is hidden
|
||||||
|
const local = resetMousePosition;
|
||||||
|
window.addEventListener("blur", local, { signal });
|
||||||
|
document.addEventListener("visibilitychange", local, { signal });
|
||||||
|
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
|
||||||
|
videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[absMouseMoveHandler, mouseWheelHandler, resetMousePosition, settings.mouseMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup Relative Mouse Events
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(
|
useEffect(
|
||||||
function setupRelativeMouseEventListeners() {
|
function setupRelativeMouseEventListeners() {
|
||||||
if (settings.mouseMode !== "relative") return;
|
if (settings.mouseMode !== "relative") return;
|
||||||
|
@ -436,50 +517,43 @@ export default function WebRTCVideo() {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
|
|
||||||
// bind to body to capture all mouse events
|
// We bind to the larger container in relative mode because of delta between the acceleration of the local
|
||||||
const body = document.querySelector("body");
|
// mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use.
|
||||||
if (!body) return;
|
// When we get Pointer Lock support, we can remove this.
|
||||||
|
const containerElm = containerRef.current;
|
||||||
|
if (!containerElm) return;
|
||||||
|
|
||||||
body.addEventListener("mousemove", relMouseMoveHandler, { signal });
|
containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal });
|
||||||
body.addEventListener("pointerdown", relMouseMoveHandler, { signal });
|
containerElm.addEventListener("pointerdown", relMouseMoveHandler, { signal });
|
||||||
body.addEventListener("pointerup", relMouseMoveHandler, { signal });
|
containerElm.addEventListener("pointerup", relMouseMoveHandler, { signal });
|
||||||
|
|
||||||
|
containerElm.addEventListener("wheel", mouseWheelHandler, {
|
||||||
|
signal,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
|
||||||
|
containerElm.addEventListener("contextmenu", preventContextMenu, { signal });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
|
|
||||||
body.removeEventListener("mousemove", relMouseMoveHandler);
|
|
||||||
body.removeEventListener("pointerdown", relMouseMoveHandler);
|
|
||||||
body.removeEventListener("pointerup", relMouseMoveHandler);
|
|
||||||
};
|
};
|
||||||
}, [settings.mouseMode, relMouseMoveHandler],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
function updateVideoStream() {
|
|
||||||
if (!mediaStream) return;
|
|
||||||
if (!videoElm.current) return;
|
|
||||||
if (peerConnection?.iceConnectionState !== "connected") return;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (videoElm?.current) {
|
|
||||||
videoElm.current.srcObject = mediaStream;
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
updateVideoSizeStore(videoElm.current);
|
|
||||||
},
|
},
|
||||||
[
|
[settings.mouseMode, relMouseMoveHandler, mouseWheelHandler],
|
||||||
setVideoClientSize,
|
|
||||||
setVideoSize,
|
|
||||||
mediaStream,
|
|
||||||
updateVideoSizeStore,
|
|
||||||
peerConnection?.iceConnectionState,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasNoAutoPlayPermissions = useMemo(() => {
|
||||||
|
if (peerConnection?.connectionState !== "connected") return false;
|
||||||
|
if (isPlaying) return false;
|
||||||
|
if (hdmiError) return false;
|
||||||
|
if (videoHeight === 0 || videoWidth === 0) return false;
|
||||||
|
return true;
|
||||||
|
}, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full w-full grid-rows-layout">
|
<div className="grid h-full w-full grid-rows-layout">
|
||||||
<div className="min-h-[39.5px]">
|
<div className="min-h-[39.5px]">
|
||||||
<fieldset disabled={peerConnectionState !== "connected"}>
|
<fieldset disabled={peerConnection?.connectionState !== "connected"}>
|
||||||
<Actionbar
|
<Actionbar
|
||||||
requestFullscreen={async () =>
|
requestFullscreen={async () =>
|
||||||
videoElm.current?.requestFullscreen({
|
videoElm.current?.requestFullscreen({
|
||||||
|
@ -490,7 +564,12 @@ export default function WebRTCVideo() {
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-full overflow-hidden">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cx("h-full overflow-hidden", {
|
||||||
|
"cursor-none": settings.mouseMode === "relative" && settings.isCursorHidden,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
|
@ -519,23 +598,32 @@ export default function WebRTCVideo() {
|
||||||
className={cx(
|
className={cx(
|
||||||
"outline-50 max-h-full max-w-full object-contain transition-all duration-1000",
|
"outline-50 max-h-full max-w-full object-contain transition-all duration-1000",
|
||||||
{
|
{
|
||||||
"cursor-none": settings.isCursorHidden,
|
"cursor-none":
|
||||||
"opacity-0": isLoading || isConnectionError || hdmiError,
|
settings.mouseMode === "absolute" &&
|
||||||
|
settings.isCursorHidden,
|
||||||
|
"opacity-0": isVideoLoading || hdmiError,
|
||||||
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
|
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
|
||||||
isPlaying,
|
isPlaying,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
{peerConnection?.connectionState == "connected" && (
|
||||||
style={{ animationDuration: "500ms" }}
|
<div
|
||||||
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
|
style={{ animationDuration: "500ms" }}
|
||||||
>
|
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
|
||||||
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
>
|
||||||
<LoadingOverlay show={isLoading} />
|
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||||
<ConnectionErrorOverlay show={isConnectionError} />
|
<LoadingVideoOverlay show={isVideoLoading} />
|
||||||
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
|
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
|
||||||
|
<NoAutoplayPermissionsOverlay
|
||||||
|
show={hasNoAutoPlayPermissions}
|
||||||
|
onPlayClick={() => {
|
||||||
|
videoElm.current?.play();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VirtualKeyboard />
|
<VirtualKeyboard />
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
|
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@components/Button";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||||
|
|
||||||
import { useJsonRpc } from "../../hooks/useJsonRpc";
|
import { useJsonRpc } from "../../hooks/useJsonRpc";
|
||||||
import LoadingSpinner from "../LoadingSpinner";
|
|
||||||
|
|
||||||
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
|
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
|
||||||
|
|
||||||
|
@ -102,11 +104,11 @@ export function ATXPowerControl() {
|
||||||
|
|
||||||
{atxState === null ? (
|
{atxState === null ? (
|
||||||
<Card className="flex h-[120px] items-center justify-center p-3">
|
<Card className="flex h-[120px] items-center justify-center p-3">
|
||||||
<LoadingSpinner className="w-6 h-6 text-blue-500 dark:text-blue-400" />
|
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="h-[120px] animate-fadeIn opacity-0">
|
<Card className="h-[120px] animate-fadeIn opacity-0">
|
||||||
<div className="p-3 space-y-4">
|
<div className="space-y-4 p-3">
|
||||||
{/* Control Buttons */}
|
{/* Control Buttons */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import { LuPower } from "react-icons/lu";
|
import { LuPower } from "react-icons/lu";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@components/Button";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import FieldLabel from "../FieldLabel";
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import LoadingSpinner from "../LoadingSpinner";
|
import FieldLabel from "@components/FieldLabel";
|
||||||
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
|
|
||||||
interface DCPowerState {
|
interface DCPowerState {
|
||||||
isOn: boolean;
|
isOn: boolean;
|
||||||
|
@ -59,11 +60,11 @@ export function DCPowerControl() {
|
||||||
|
|
||||||
{powerState === null ? (
|
{powerState === null ? (
|
||||||
<Card className="flex h-[160px] justify-center p-3">
|
<Card className="flex h-[160px] justify-center p-3">
|
||||||
<LoadingSpinner className="w-6 h-6 text-blue-500 dark:text-blue-400" />
|
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="h-[160px] animate-fadeIn opacity-0">
|
<Card className="h-[160px] animate-fadeIn opacity-0">
|
||||||
<div className="p-3 space-y-4">
|
<div className="space-y-4 p-3">
|
||||||
{/* Power Controls */}
|
{/* Power Controls */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import { LuTerminal } from "react-icons/lu";
|
import { LuTerminal } from "react-icons/lu";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@components/Button";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { SelectMenuBasic } from "../SelectMenuBasic";
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { useUiStore } from "@/hooks/stores";
|
import { useUiStore } from "@/hooks/stores";
|
||||||
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
|
|
||||||
interface SerialSettings {
|
interface SerialSettings {
|
||||||
baudRate: string;
|
baudRate: string;
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
|
||||||
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import Card, { GridCard } from "@components/Card";
|
import Card, { GridCard } from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { Button } from "../Button";
|
|
||||||
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
|
|
||||||
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
|
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
|
||||||
import { DCPowerControl } from "@components/extensions/DCPowerControl";
|
import { DCPowerControl } from "@components/extensions/DCPowerControl";
|
||||||
import { SerialConsole } from "@components/extensions/SerialConsole";
|
import { SerialConsole } from "@components/extensions/SerialConsole";
|
||||||
import notifications from "../../notifications";
|
import { Button } from "@components/Button";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
interface Extension {
|
interface Extension {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: any;
|
icon: React.ElementType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AVAILABLE_EXTENSIONS: Extension[] = [
|
const AVAILABLE_EXTENSIONS: Extension[] = [
|
||||||
|
@ -58,7 +59,9 @@ export default function ExtensionPopover() {
|
||||||
const handleSetActiveExtension = (extension: Extension | null) => {
|
const handleSetActiveExtension = (extension: Extension | null) => {
|
||||||
send("setActiveExtension", { extensionId: extension?.id || "" }, resp => {
|
send("setActiveExtension", { extensionId: extension?.id || "" }, resp => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
notifications.error(`Failed to set active extension: ${resp.error.data || "Unknown error"}`);
|
notifications.error(
|
||||||
|
`Failed to set active extension: ${resp.error.data || "Unknown error"}`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActiveExtension(extension);
|
setActiveExtension(extension);
|
||||||
|
@ -80,7 +83,7 @@ export default function ExtensionPopover() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="p-4 py-3 space-y-4">
|
<div className="space-y-4 p-4 py-3">
|
||||||
<div className="grid h-full grid-rows-headerBody">
|
<div className="grid h-full grid-rows-headerBody">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{activeExtension ? (
|
{activeExtension ? (
|
||||||
|
@ -89,7 +92,7 @@ export default function ExtensionPopover() {
|
||||||
{renderActiveExtension()}
|
{renderActiveExtension()}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
|
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.2s",
|
animationDelay: "0.2s",
|
||||||
|
@ -110,7 +113,7 @@ export default function ExtensionPopover() {
|
||||||
title="Extensions"
|
title="Extensions"
|
||||||
description="Load and manage your extensions"
|
description="Load and manage your extensions"
|
||||||
/>
|
/>
|
||||||
<Card className="opacity-0 animate-fadeIn">
|
<Card className="animate-fadeIn opacity-0">
|
||||||
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
|
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
|
||||||
{AVAILABLE_EXTENSIONS.map(extension => (
|
{AVAILABLE_EXTENSIONS.map(extension => (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -1,11 +1,6 @@
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
import Card, { GridCard } from "@components/Card";
|
|
||||||
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
||||||
import { useMemo, forwardRef, useEffect, useCallback } from "react";
|
import { useMemo, forwardRef, useEffect, useCallback } from "react";
|
||||||
import { formatters } from "@/utils";
|
|
||||||
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
|
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
|
||||||
import {
|
import {
|
||||||
LuArrowUpFromLine,
|
LuArrowUpFromLine,
|
||||||
LuCheckCheck,
|
LuCheckCheck,
|
||||||
|
@ -13,11 +8,17 @@ import {
|
||||||
LuPlus,
|
LuPlus,
|
||||||
LuRadioReceiver,
|
LuRadioReceiver,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
|
||||||
import notifications from "../../notifications";
|
|
||||||
import { useClose } from "@headlessui/react";
|
import { useClose } from "@headlessui/react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button } from "@components/Button";
|
||||||
|
import Card, { GridCard } from "@components/Card";
|
||||||
|
import { formatters } from "@/utils";
|
||||||
|
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
|
||||||
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
||||||
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
|
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
|
||||||
|
@ -194,7 +195,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="space-y-4 p-4 py-3">
|
<div className="space-y-4 p-4 py-3">
|
||||||
<div ref={ref} className="grid h-full grid-rows-headerBody">
|
<div ref={ref} className="grid h-full grid-rows-headerBody">
|
||||||
<div className="h-full space-y-4 ">
|
<div className="h-full space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsPageHeader
|
<SettingsPageHeader
|
||||||
title="Virtual Media"
|
title="Virtual Media"
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { LuCornerDownLeft } from "react-icons/lu";
|
||||||
|
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
||||||
|
import { useClose } from "@headlessui/react";
|
||||||
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { GridCard } from "@components/Card";
|
import { GridCard } from "@components/Card";
|
||||||
import { TextAreaWithLabel } from "@components/TextArea";
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
|
||||||
import notifications from "../../notifications";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { LuCornerDownLeft } from "react-icons/lu";
|
|
||||||
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
|
||||||
import { useClose } from "@headlessui/react";
|
|
||||||
import { chars, keys, modifiers } from "@/keyboardMappings";
|
import { chars, keys, modifiers } from "@/keyboardMappings";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
||||||
return { keys, modifier };
|
return { keys, modifier };
|
||||||
|
@ -59,6 +60,7 @@ export default function PasteModal() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
notifications.error("Failed to paste text");
|
notifications.error("Failed to paste text");
|
||||||
}
|
}
|
||||||
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
|
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
|
||||||
|
@ -71,7 +73,7 @@ export default function PasteModal() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="p-4 py-3 space-y-4">
|
<div className="space-y-4 p-4 py-3">
|
||||||
<div className="grid h-full grid-rows-headerBody">
|
<div className="grid h-full grid-rows-headerBody">
|
||||||
<div className="h-full space-y-4">
|
<div className="h-full space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
@ -81,7 +83,7 @@ export default function PasteModal() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="space-y-2 opacity-0 animate-fadeIn"
|
className="animate-fadeIn space-y-2 opacity-0"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.1s",
|
animationDelay: "0.1s",
|
||||||
|
@ -120,8 +122,8 @@ export default function PasteModal() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{invalidChars.length > 0 && (
|
{invalidChars.length > 0 && (
|
||||||
<div className="flex items-center mt-2 gap-x-2">
|
<div className="mt-2 flex items-center gap-x-2">
|
||||||
<ExclamationCircleIcon className="w-4 h-4 text-red-500 dark:text-red-400" />
|
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
|
||||||
<span className="text-xs text-red-500 dark:text-red-400">
|
<span className="text-xs text-red-500 dark:text-red-400">
|
||||||
The following characters won't be pasted:{" "}
|
The following characters won't be pasted:{" "}
|
||||||
{invalidChars.join(", ")}
|
{invalidChars.join(", ")}
|
||||||
|
@ -135,7 +137,7 @@ export default function PasteModal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-end opacity-0 animate-fadeIn gap-x-2"
|
className="flex animate-fadeIn items-center justify-end gap-x-2 opacity-0"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.2s",
|
animationDelay: "0.2s",
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { InputFieldWithLabel } from "@components/InputField";
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { LuPlus } from "react-icons/lu";
|
import { LuPlus, LuArrowLeft } from "react-icons/lu";
|
||||||
import { Button } from "../../Button";
|
|
||||||
import { LuArrowLeft } from "react-icons/lu";
|
import { InputFieldWithLabel } from "@/components/InputField";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
|
||||||
interface AddDeviceFormProps {
|
interface AddDeviceFormProps {
|
||||||
onAddDevice: (name: string, macAddress: string) => void;
|
onAddDevice: (name: string, macAddress: string) => void;
|
||||||
|
@ -26,7 +26,7 @@ export default function AddDeviceForm({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div
|
<div
|
||||||
className="space-y-4 opacity-0 animate-fadeIn"
|
className="animate-fadeIn space-y-4 opacity-0"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.5s",
|
animationDuration: "0.5s",
|
||||||
animationFillMode: "forwards",
|
animationFillMode: "forwards",
|
||||||
|
@ -73,7 +73,7 @@ export default function AddDeviceForm({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
|
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.2s",
|
animationDelay: "0.2s",
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import Card from "@components/Card";
|
|
||||||
import { FieldError } from "@components/InputField";
|
|
||||||
import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
|
import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
|
||||||
|
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import Card from "@/components/Card";
|
||||||
|
import { FieldError } from "@/components/InputField";
|
||||||
|
|
||||||
export interface StoredDevice {
|
export interface StoredDevice {
|
||||||
name: string;
|
name: string;
|
||||||
macAddress: string;
|
macAddress: string;
|
||||||
|
@ -27,12 +28,14 @@ export default function DeviceList({
|
||||||
}: DeviceListProps) {
|
}: DeviceListProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="opacity-0 animate-fadeIn">
|
<Card className="animate-fadeIn opacity-0">
|
||||||
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
|
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
|
||||||
{storedDevices.map((device, index) => (
|
{storedDevices.map((device, index) => (
|
||||||
<div key={index} className="flex items-center justify-between p-3 gap-x-2">
|
<div key={index} className="flex items-center justify-between gap-x-2 p-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">{device?.name}</p>
|
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">
|
||||||
|
{device?.name}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
{device.macAddress?.toLowerCase()}
|
{device.macAddress?.toLowerCase()}
|
||||||
</p>
|
</p>
|
||||||
|
@ -60,18 +63,13 @@ export default function DeviceList({
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
|
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.2s",
|
animationDelay: "0.2s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
|
||||||
size="SM"
|
|
||||||
theme="blank"
|
|
||||||
text="Close"
|
|
||||||
onClick={onCancelWakeOnLanModal}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
size="SM"
|
size="SM"
|
||||||
theme="primary"
|
theme="primary"
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import Card from "@components/Card";
|
|
||||||
import { PlusCircleIcon } from "@heroicons/react/16/solid";
|
import { PlusCircleIcon } from "@heroicons/react/16/solid";
|
||||||
import { LuPlus } from "react-icons/lu";
|
import { LuPlus } from "react-icons/lu";
|
||||||
import { Button } from "../../Button";
|
|
||||||
|
import Card from "@/components/Card";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
|
||||||
export default function EmptyStateCard({
|
export default function EmptyStateCard({
|
||||||
onCancelWakeOnLanModal,
|
onCancelWakeOnLanModal,
|
||||||
|
@ -11,15 +12,15 @@ export default function EmptyStateCard({
|
||||||
setShowAddForm: (show: boolean) => void;
|
setShowAddForm: (show: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 select-none">
|
<div className="select-none space-y-4">
|
||||||
<Card className="opacity-0 animate-fadeIn">
|
<Card className="animate-fadeIn opacity-0">
|
||||||
<div className="flex items-center justify-center py-8 text-center">
|
<div className="flex items-center justify-center py-8 text-center">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="inline-block">
|
<div className="inline-block">
|
||||||
<Card>
|
<Card>
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
|
<PlusCircleIcon className="h-4 w-4 shrink-0 text-blue-700 dark:text-white" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -34,7 +35,7 @@ export default function EmptyStateCard({
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
|
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.2s",
|
animationDelay: "0.2s",
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useClose } from "@headlessui/react";
|
||||||
|
|
||||||
import { GridCard } from "@components/Card";
|
import { GridCard } from "@components/Card";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useClose } from "@headlessui/react";
|
|
||||||
import EmptyStateCard from "./EmptyStateCard";
|
import EmptyStateCard from "./EmptyStateCard";
|
||||||
import DeviceList, { StoredDevice } from "./DeviceList";
|
import DeviceList, { StoredDevice } from "./DeviceList";
|
||||||
import AddDeviceForm from "./AddDeviceForm";
|
import AddDeviceForm from "./AddDeviceForm";
|
||||||
|
@ -99,7 +101,7 @@ export default function WakeOnLanModal() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="p-4 py-3 space-y-4">
|
<div className="space-y-4 p-4 py-3">
|
||||||
<div className="grid h-full grid-rows-headerBody">
|
<div className="grid h-full grid-rows-headerBody">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsPageHeader
|
<SettingsPageHeader
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import SidebarHeader from "@components/SidebarHeader";
|
|
||||||
import { GridCard } from "@components/Card";
|
|
||||||
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
|
||||||
import StatChart from "@components/StatChart";
|
|
||||||
import { useInterval } from "usehooks-ts";
|
import { useInterval } from "usehooks-ts";
|
||||||
|
|
||||||
|
import SidebarHeader from "@/components/SidebarHeader";
|
||||||
|
import { GridCard } from "@/components/Card";
|
||||||
|
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
||||||
|
import StatChart from "@/components/StatChart";
|
||||||
|
|
||||||
function createChartArray<T, K extends keyof T>(
|
function createChartArray<T, K extends keyof T>(
|
||||||
stream: Map<number, T>,
|
stream: Map<number, T>,
|
||||||
metric: K,
|
metric: K,
|
||||||
|
@ -120,7 +121,7 @@ export default function ConnectionStatsSidebar() {
|
||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||||
{inboundRtpStats.size === 0 ? (
|
{inboundRtpStats.size === 0 ? (
|
||||||
<div className="flex flex-col items-center space-y-1 ">
|
<div className="flex flex-col items-center space-y-1">
|
||||||
<p className="text-slate-700">Waiting for data...</p>
|
<p className="text-slate-700">Waiting for data...</p>
|
||||||
</div>
|
</div>
|
||||||
) : isMetricSupported(inboundRtpStats, "packetsLost") ? (
|
) : isMetricSupported(inboundRtpStats, "packetsLost") ? (
|
||||||
|
@ -130,7 +131,7 @@ export default function ConnectionStatsSidebar() {
|
||||||
unit=" packets"
|
unit=" packets"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center space-y-1 ">
|
<div className="flex flex-col items-center space-y-1">
|
||||||
<p className="text-black">Metric not supported</p>
|
<p className="text-black">Metric not supported</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -149,7 +150,7 @@ export default function ConnectionStatsSidebar() {
|
||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||||
{inboundRtpStats.size === 0 ? (
|
{inboundRtpStats.size === 0 ? (
|
||||||
<div className="flex flex-col items-center space-y-1 ">
|
<div className="flex flex-col items-center space-y-1">
|
||||||
<p className="text-slate-700">Waiting for data...</p>
|
<p className="text-slate-700">Waiting for data...</p>
|
||||||
</div>
|
</div>
|
||||||
) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? (
|
) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? (
|
||||||
|
@ -167,7 +168,7 @@ export default function ConnectionStatsSidebar() {
|
||||||
unit=" ms"
|
unit=" ms"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center space-y-1 ">
|
<div className="flex flex-col items-center space-y-1">
|
||||||
<p className="text-black">Metric not supported</p>
|
<p className="text-black">Metric not supported</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -186,7 +187,7 @@ export default function ConnectionStatsSidebar() {
|
||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||||
{inboundRtpStats.size === 0 ? (
|
{inboundRtpStats.size === 0 ? (
|
||||||
<div className="flex flex-col items-center space-y-1 ">
|
<div className="flex flex-col items-center space-y-1">
|
||||||
<p className="text-slate-700">Waiting for data...</p>
|
<p className="text-slate-700">Waiting for data...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
@ -216,7 +217,7 @@ export default function ConnectionStatsSidebar() {
|
||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
|
||||||
{inboundRtpStats.size === 0 ? (
|
{inboundRtpStats.size === 0 ? (
|
||||||
<div className="flex flex-col items-center space-y-1 ">
|
<div className="flex flex-col items-center space-y-1">
|
||||||
<p className="text-slate-700">Waiting for data...</p>
|
<p className="text-slate-700">Waiting for data...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
|
import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
|
||||||
import { isOnDevice } from "../main";
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
import { isOnDevice } from "../main";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the correct path based on whether the app is running on device or in cloud mode
|
* Generates the correct path based on whether the app is running on device or in cloud mode
|
||||||
*
|
*
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { FeatureFlagContext } from "../providers/FeatureFlagProvider";
|
|
||||||
|
import { FeatureFlagContext } from "@/providers/FeatureFlagContext";
|
||||||
|
|
||||||
export const useFeatureFlag = (minAppVersion: string) => {
|
export const useFeatureFlag = (minAppVersion: string) => {
|
||||||
const context = useContext(FeatureFlagContext);
|
const context = useContext(FeatureFlagContext);
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
|
||||||
import { useRTCStore } from "@/hooks/stores";
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
|
|
||||||
export interface JsonRpcRequest {
|
export interface JsonRpcRequest {
|
||||||
|
@ -56,7 +57,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||||
// The "API" can also "request" data from the client
|
// The "API" can also "request" data from the client
|
||||||
// If the payload has a method, it's a request
|
// If the payload has a method, it's a request
|
||||||
if ("method" in payload) {
|
if ("method" in payload) {
|
||||||
onRequest && onRequest(payload);
|
if (onRequest) onRequest(payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { useHidStore, useRTCStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore } from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
|
||||||
|
|
|
@ -1,19 +1,18 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
|
|
||||||
import { useIsMounted } from "./useIsMounted";
|
import { useIsMounted } from "./useIsMounted";
|
||||||
|
|
||||||
/** The size of the observed element. */
|
/** The size of the observed element. */
|
||||||
type Size = {
|
interface Size {
|
||||||
/** The width of the observed element. */
|
/** The width of the observed element. */
|
||||||
width: number | undefined;
|
width: number | undefined;
|
||||||
/** The height of the observed element. */
|
/** The height of the observed element. */
|
||||||
height: number | undefined;
|
height: number | undefined;
|
||||||
};
|
}
|
||||||
|
|
||||||
/** The options for the ResizeObserver. */
|
/** The options for the ResizeObserver. */
|
||||||
type UseResizeObserverOptions<T extends HTMLElement = HTMLElement> = {
|
interface UseResizeObserverOptions<T extends HTMLElement = HTMLElement> {
|
||||||
/** The ref of the element to observe. */
|
/** The ref of the element to observe. */
|
||||||
ref: RefObject<T>;
|
ref: RefObject<T>;
|
||||||
/**
|
/**
|
||||||
|
@ -26,7 +25,7 @@ type UseResizeObserverOptions<T extends HTMLElement = HTMLElement> = {
|
||||||
* @default 'content-box'
|
* @default 'content-box'
|
||||||
*/
|
*/
|
||||||
box?: "border-box" | "content-box" | "device-pixel-content-box";
|
box?: "border-box" | "content-box" | "device-pixel-content-box";
|
||||||
};
|
}
|
||||||
|
|
||||||
const initialSize: Size = {
|
const initialSize: Size = {
|
||||||
width: undefined,
|
width: undefined,
|
||||||
|
@ -102,7 +101,7 @@ export function useResizeObserver<T extends HTMLElement = HTMLElement>(
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
};
|
};
|
||||||
}, [box, ref.current, isMounted]);
|
}, [box, isMounted, ref]);
|
||||||
|
|
||||||
return { width, height };
|
return { width, height };
|
||||||
}
|
}
|
||||||
|
@ -127,6 +126,6 @@ function extractSize(
|
||||||
|
|
||||||
return Array.isArray(entry[box])
|
return Array.isArray(entry[box])
|
||||||
? entry[box][0][sizeType]
|
? entry[box][0][sizeType]
|
||||||
: // @ts-ignore Support Firefox's non-standard behavior
|
: // @ts-expect-error Support Firefox's non-standard behavior
|
||||||
(entry[box][sizeType] as number);
|
(entry[box][sizeType] as number);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import Root from "./root";
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import {
|
import {
|
||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
|
@ -8,19 +7,22 @@ import {
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
useRouteError,
|
useRouteError,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
|
|
||||||
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
|
|
||||||
import SetupRoute from "@routes/devices.$id.setup";
|
|
||||||
import LoginRoute from "@routes/login";
|
|
||||||
import SignupRoute from "@routes/signup";
|
|
||||||
import AdoptRoute from "@routes/adopt";
|
|
||||||
import DeviceIdRename from "@routes/devices.$id.rename";
|
|
||||||
import DevicesIdDeregister from "@routes/devices.$id.deregister";
|
|
||||||
import NotFoundPage from "@components/NotFoundPage";
|
|
||||||
import EmptyCard from "@components/EmptyCard";
|
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
|
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
|
||||||
|
|
||||||
|
import EmptyCard from "@components/EmptyCard";
|
||||||
|
import NotFoundPage from "@components/NotFoundPage";
|
||||||
|
import DevicesIdDeregister from "@routes/devices.$id.deregister";
|
||||||
|
import DeviceIdRename from "@routes/devices.$id.rename";
|
||||||
|
import AdoptRoute from "@routes/adopt";
|
||||||
|
import SignupRoute from "@routes/signup";
|
||||||
|
import LoginRoute from "@routes/login";
|
||||||
|
import SetupRoute from "@routes/devices.$id.setup";
|
||||||
|
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
|
||||||
|
import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
|
import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
|
||||||
|
|
||||||
|
import Root from "./root";
|
||||||
import Notifications from "./notifications";
|
import Notifications from "./notifications";
|
||||||
import LoginLocalRoute from "./routes/login-local";
|
import LoginLocalRoute from "./routes/login-local";
|
||||||
import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
|
import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
|
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
|
|
||||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
|
||||||
|
|
||||||
interface NotificationOptions {
|
interface NotificationOptions {
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
import { FeatureFlagContextType } from "./FeatureFlagProvider";
|
||||||
|
|
||||||
|
// Create the context
|
||||||
|
|
||||||
|
export const FeatureFlagContext = createContext<FeatureFlagContextType>({
|
||||||
|
appVersion: null,
|
||||||
|
isFeatureEnabled: () => false,
|
||||||
|
});
|
|
@ -1,17 +1,12 @@
|
||||||
import { createContext } from "react";
|
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
|
|
||||||
interface FeatureFlagContextType {
|
import { FeatureFlagContext } from "./FeatureFlagContext";
|
||||||
|
|
||||||
|
export interface FeatureFlagContextType {
|
||||||
appVersion: string | null;
|
appVersion: string | null;
|
||||||
isFeatureEnabled: (minVersion: string) => boolean;
|
isFeatureEnabled: (minVersion: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the context
|
|
||||||
export const FeatureFlagContext = createContext<FeatureFlagContextType>({
|
|
||||||
appVersion: null,
|
|
||||||
isFeatureEnabled: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Provider component that fetches version and provides context
|
// Provider component that fetches version and provides context
|
||||||
export const FeatureFlagProvider = ({
|
export const FeatureFlagProvider = ({
|
||||||
children,
|
children,
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { LoaderFunctionArgs, redirect } from "react-router-dom";
|
import { LoaderFunctionArgs, redirect } from "react-router-dom";
|
||||||
import api from "../api";
|
|
||||||
import { DEVICE_API } from "@/ui.config";
|
import { DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
|
import api from "../api";
|
||||||
|
|
||||||
export interface CloudState {
|
export interface CloudState {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
|
|
|
@ -6,6 +6,8 @@ import {
|
||||||
useActionData,
|
useActionData,
|
||||||
useLoaderData,
|
useLoaderData,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
|
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
|
||||||
|
|
||||||
import { Button, LinkButton } from "@components/Button";
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import { CardHeader } from "@components/CardHeader";
|
import { CardHeader } from "@components/CardHeader";
|
||||||
|
@ -13,7 +15,6 @@ import DashboardNavbar from "@components/Header";
|
||||||
import { User } from "@/hooks/stores";
|
import { User } from "@/hooks/stores";
|
||||||
import { checkAuth } from "@/main";
|
import { checkAuth } from "@/main";
|
||||||
import Fieldset from "@components/Fieldset";
|
import Fieldset from "@components/Fieldset";
|
||||||
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
|
|
||||||
import { CLOUD_API } from "@/ui.config";
|
import { CLOUD_API } from "@/ui.config";
|
||||||
|
|
||||||
interface LoaderData {
|
interface LoaderData {
|
||||||
|
@ -36,6 +37,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
return { message: "There was an error renaming your device. Please try again." };
|
return { message: "There was an error renaming your device. Please try again." };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
return { message: "There was an error renaming your device. Please try again." };
|
return { message: "There was an error renaming your device. Please try again." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,4 @@
|
||||||
import Card, { GridCard } from "@/components/Card";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
|
||||||
import {
|
|
||||||
MountMediaState,
|
|
||||||
RemoteVirtualMediaState,
|
|
||||||
useMountMediaStore,
|
|
||||||
useRTCStore,
|
|
||||||
} from "../hooks/stores";
|
|
||||||
import { cx } from "../cva.config";
|
|
||||||
import {
|
import {
|
||||||
LuGlobe,
|
LuGlobe,
|
||||||
LuLink,
|
LuLink,
|
||||||
|
@ -18,8 +7,15 @@ import {
|
||||||
LuCheck,
|
LuCheck,
|
||||||
LuUpload,
|
LuUpload,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
|
import { PlusCircleIcon , ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||||
|
import { TrashIcon } from "@heroicons/react/16/solid";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import Card, { GridCard } from "@/components/Card";
|
||||||
|
import { Button } from "@components/Button";
|
||||||
|
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||||
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
import { formatters } from "@/utils";
|
import { formatters } from "@/utils";
|
||||||
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
|
||||||
import AutoHeight from "@components/AutoHeight";
|
import AutoHeight from "@components/AutoHeight";
|
||||||
import { InputFieldWithLabel } from "@/components/InputField";
|
import { InputFieldWithLabel } from "@/components/InputField";
|
||||||
import DebianIcon from "@/assets/debian-icon.png";
|
import DebianIcon from "@/assets/debian-icon.png";
|
||||||
|
@ -28,14 +24,20 @@ import FedoraIcon from "@/assets/fedora-icon.png";
|
||||||
import OpenSUSEIcon from "@/assets/opensuse-icon.png";
|
import OpenSUSEIcon from "@/assets/opensuse-icon.png";
|
||||||
import ArchIcon from "@/assets/arch-icon.png";
|
import ArchIcon from "@/assets/arch-icon.png";
|
||||||
import NetBootIcon from "@/assets/netboot-icon.svg";
|
import NetBootIcon from "@/assets/netboot-icon.svg";
|
||||||
import { TrashIcon } from "@heroicons/react/16/solid";
|
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
|
||||||
import notifications from "../notifications";
|
|
||||||
import Fieldset from "@/components/Fieldset";
|
import Fieldset from "@/components/Fieldset";
|
||||||
import { isOnDevice } from "../main";
|
|
||||||
import { DEVICE_API } from "@/ui.config";
|
import { DEVICE_API } from "@/ui.config";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
|
import notifications from "../notifications";
|
||||||
|
import { isOnDevice } from "../main";
|
||||||
|
import { cx } from "../cva.config";
|
||||||
|
import {
|
||||||
|
MountMediaState,
|
||||||
|
RemoteVirtualMediaState,
|
||||||
|
useMountMediaStore,
|
||||||
|
useRTCStore,
|
||||||
|
} from "../hooks/stores";
|
||||||
|
|
||||||
|
|
||||||
export default function MountRoute() {
|
export default function MountRoute() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { useNavigate, useOutletContext } from "react-router-dom";
|
import { useNavigate, useOutletContext } from "react-router-dom";
|
||||||
|
|
||||||
import { GridCard } from "@/components/Card";
|
import { GridCard } from "@/components/Card";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import LogoBlue from "@/assets/logo-blue.svg";
|
import LogoBlue from "@/assets/logo-blue.svg";
|
||||||
import LogoWhite from "@/assets/logo-white.svg";
|
import LogoWhite from "@/assets/logo-white.svg";
|
||||||
|
|
||||||
interface ContextType {
|
interface ContextType {
|
||||||
connectWebRTC: () => Promise<void>;
|
setupPeerConnection: () => Promise<void>;
|
||||||
}
|
}
|
||||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||||
|
|
||||||
|
@ -15,7 +16,7 @@ export default function OtherSessionRoute() {
|
||||||
|
|
||||||
// Function to handle closing the modal
|
// Function to handle closing the modal
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
outletContext?.connectWebRTC().then(() => navigate(".."));
|
outletContext?.setupPeerConnection().then(() => navigate(".."));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -6,8 +6,9 @@ import {
|
||||||
useActionData,
|
useActionData,
|
||||||
useLoaderData,
|
useLoaderData,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { Button, LinkButton } from "@components/Button";
|
|
||||||
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
|
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
|
||||||
|
|
||||||
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
import { CardHeader } from "@components/CardHeader";
|
import { CardHeader } from "@components/CardHeader";
|
||||||
import { InputFieldWithLabel } from "@components/InputField";
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
|
@ -15,9 +16,10 @@ import DashboardNavbar from "@components/Header";
|
||||||
import { User } from "@/hooks/stores";
|
import { User } from "@/hooks/stores";
|
||||||
import { checkAuth } from "@/main";
|
import { checkAuth } from "@/main";
|
||||||
import Fieldset from "@components/Fieldset";
|
import Fieldset from "@components/Fieldset";
|
||||||
import api from "../api";
|
|
||||||
import { CLOUD_API } from "@/ui.config";
|
import { CLOUD_API } from "@/ui.config";
|
||||||
|
|
||||||
|
import api from "../api";
|
||||||
|
|
||||||
interface LoaderData {
|
interface LoaderData {
|
||||||
device: { id: string; name: string; user: { googleId: string } };
|
device: { id: string; name: string; user: { googleId: string } };
|
||||||
user: User;
|
user: User;
|
||||||
|
@ -39,6 +41,7 @@ const action = async ({ params, request }: ActionFunctionArgs) => {
|
||||||
return { message: "There was an error renaming your device. Please try again." };
|
return { message: "There was an error renaming your device. Please try again." };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
return { message: "There was an error renaming your device. Please try again." };
|
return { message: "There was an error renaming your device. Please try again." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,9 +83,9 @@ export default function DeviceIdRename() {
|
||||||
picture={user?.picture}
|
picture={user?.picture}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full h-full">
|
<div className="h-full w-full">
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
|
<div className="mx-auto h-full w-full space-y-6 px-4 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<LinkButton
|
<LinkButton
|
||||||
size="SM"
|
size="SM"
|
||||||
|
@ -100,7 +103,7 @@ export default function DeviceIdRename() {
|
||||||
|
|
||||||
<Fieldset>
|
<Fieldset>
|
||||||
<Form method="POST" className="max-w-sm space-y-4">
|
<Form method="POST" className="max-w-sm space-y-4">
|
||||||
<div className="relative group">
|
<div className="group relative">
|
||||||
<InputFieldWithLabel
|
<InputFieldWithLabel
|
||||||
label="New device name"
|
label="New device name"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { LoaderFunctionArgs, redirect } from "react-router-dom";
|
import { LoaderFunctionArgs, redirect } from "react-router-dom";
|
||||||
|
|
||||||
import { getDeviceUiPath } from "../hooks/useAppNavigation";
|
import { getDeviceUiPath } from "../hooks/useAppNavigation";
|
||||||
|
|
||||||
export function loader({ params }: LoaderFunctionArgs) {
|
export function loader({ params }: LoaderFunctionArgs) {
|
||||||
|
|
|
@ -1,20 +1,22 @@
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
|
||||||
import { useLoaderData, useNavigate } from "react-router-dom";
|
import { useLoaderData, useNavigate } from "react-router-dom";
|
||||||
import { Button, LinkButton } from "../components/Button";
|
|
||||||
import { DEVICE_API } from "../ui.config";
|
|
||||||
import api from "../api";
|
|
||||||
import { LocalDevice } from "./devices.$id";
|
|
||||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
|
||||||
import { GridCard } from "../components/Card";
|
|
||||||
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
||||||
import notifications from "../notifications";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
|
||||||
import { InputFieldWithLabel } from "../components/InputField";
|
import api from "@/api";
|
||||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { SettingsSectionHeader } from "../components/SettingsSectionHeader";
|
import { GridCard } from "@/components/Card";
|
||||||
import { isOnDevice } from "../main";
|
import { Button, LinkButton } from "@/components/Button";
|
||||||
|
import { InputFieldWithLabel } from "@/components/InputField";
|
||||||
|
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
||||||
|
import { SettingsSectionHeader } from "@/components/SettingsSectionHeader";
|
||||||
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
import { DEVICE_API } from "@/ui.config";
|
||||||
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { isOnDevice } from "@/main";
|
||||||
|
|
||||||
|
import { LocalDevice } from "./devices.$id";
|
||||||
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
import { CloudState } from "./adopt";
|
import { CloudState } from "./adopt";
|
||||||
|
|
||||||
export const loader = async () => {
|
export const loader = async () => {
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useLocation, useRevalidator } from "react-router-dom";
|
||||||
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { InputFieldWithLabel } from "@/components/InputField";
|
import { InputFieldWithLabel } from "@/components/InputField";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import { useLocalAuthModalStore } from "@/hooks/stores";
|
import { useLocalAuthModalStore } from "@/hooks/stores";
|
||||||
import { useLocation, useRevalidator } from "react-router-dom";
|
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
|
|
||||||
export default function SecurityAccessLocalAuthRoute() {
|
export default function SecurityAccessLocalAuthRoute() {
|
||||||
|
@ -53,6 +54,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
|
||||||
setError(data.error || "An error occurred while setting the password");
|
setError(data.error || "An error occurred while setting the password");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
setError("An error occurred while setting the password");
|
setError("An error occurred while setting the password");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -92,6 +94,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
|
||||||
setError(data.error || "An error occurred while changing the password");
|
setError(data.error || "An error occurred while changing the password");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
setError("An error occurred while changing the password");
|
setError("An error occurred while changing the password");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -113,6 +116,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
|
||||||
setError(data.error || "An error occurred while disabling the password");
|
setError(data.error || "An error occurred while disabling the password");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
setError("An error occurred while disabling the password");
|
setError("An error occurred while disabling the password");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
|
||||||
|
import { useCallback, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import { GridCard } from "@components/Card";
|
||||||
|
|
||||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||||
import Checkbox from "../components/Checkbox";
|
import Checkbox from "../components/Checkbox";
|
||||||
|
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
import { useCallback, useState, useEffect } from "react";
|
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { TextAreaWithLabel } from "../components/TextArea";
|
import { TextAreaWithLabel } from "../components/TextArea";
|
||||||
import { isOnDevice } from "../main";
|
import { isOnDevice } from "../main";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import { useSettingsStore } from "../hooks/stores";
|
import { useSettingsStore } from "../hooks/stores";
|
||||||
import { GridCard } from "@components/Card";
|
|
||||||
|
|
||||||
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
export default function SettingsAdvancedRoute() {
|
export default function SettingsAdvancedRoute() {
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
export default function SettingsAppearanceRoute() {
|
export default function SettingsAppearanceRoute() {
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { useState , useEffect } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
|
||||||
|
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import Checkbox from "../components/Checkbox";
|
import Checkbox from "../components/Checkbox";
|
||||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||||
import { useDeviceStore } from "../hooks/stores";
|
import { useDeviceStore } from "../hooks/stores";
|
||||||
|
|
||||||
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
export default function SettingsGeneralRoute() {
|
export default function SettingsGeneralRoute() {
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import Card from "@/components/Card";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
|
import Card from "@/components/Card";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
|
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { SettingsItem } from "@routes/devices.$id.settings";
|
import { SettingsItem } from "@routes/devices.$id.settings";
|
||||||
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
|
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
|
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
||||||
|
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
|
||||||
import { UsbInfoSetting } from "../components/UsbInfoSetting";
|
import { UsbInfoSetting } from "../components/UsbInfoSetting";
|
||||||
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
|
||||||
import { FeatureFlag } from "../components/FeatureFlag";
|
import { FeatureFlag } from "../components/FeatureFlag";
|
||||||
|
|
||||||
export default function SettingsHardwareRoute() {
|
export default function SettingsHardwareRoute() {
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { CheckCircleIcon } from "@heroicons/react/16/solid";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import MouseIcon from "@/assets/mouse-icon.svg";
|
import MouseIcon from "@/assets/mouse-icon.svg";
|
||||||
import PointingFinger from "@/assets/pointing-finger.svg";
|
import PointingFinger from "@/assets/pointing-finger.svg";
|
||||||
import { GridCard } from "@/components/Card";
|
import { GridCard } from "@/components/Card";
|
||||||
|
@ -6,11 +9,11 @@ import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { CheckCircleIcon } from "@heroicons/react/16/solid";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { FeatureFlag } from "../components/FeatureFlag";
|
import { FeatureFlag } from "../components/FeatureFlag";
|
||||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||||
import { useFeatureFlag } from "../hooks/useFeatureFlag";
|
import { useFeatureFlag } from "../hooks/useFeatureFlag";
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
type ScrollSensitivity = "low" | "default" | "high";
|
type ScrollSensitivity = "low" | "default" | "high";
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
import Card from "@/components/Card";
|
|
||||||
import {
|
import {
|
||||||
LuSettings,
|
LuSettings,
|
||||||
LuKeyboard,
|
LuKeyboard,
|
||||||
|
@ -10,8 +9,11 @@ import {
|
||||||
LuArrowLeft,
|
LuArrowLeft,
|
||||||
LuPalette,
|
LuPalette,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import { LinkButton } from "../components/Button";
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import Card from "@/components/Card";
|
||||||
|
|
||||||
|
import { LinkButton } from "../components/Button";
|
||||||
import { cx } from "../cva.config";
|
import { cx } from "../cva.config";
|
||||||
import { useUiStore } from "../hooks/stores";
|
import { useUiStore } from "../hooks/stores";
|
||||||
import useKeyboard from "../hooks/useKeyboard";
|
import useKeyboard from "../hooks/useKeyboard";
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { useState, useEffect } from "react";
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { TextAreaWithLabel } from "@/components/TextArea";
|
import { TextAreaWithLabel } from "@/components/TextArea";
|
||||||
|
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useState, useEffect } from "react";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||||
|
|
||||||
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
const defaultEdid =
|
const defaultEdid =
|
||||||
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
|
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
|
||||||
const edids = [
|
const edids = [
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
import SimpleNavbar from "@components/SimpleNavbar";
|
|
||||||
import GridBackground from "@components/GridBackground";
|
|
||||||
import Container from "@components/Container";
|
|
||||||
import StepCounter from "@components/StepCounter";
|
|
||||||
import Fieldset from "@components/Fieldset";
|
|
||||||
import {
|
import {
|
||||||
ActionFunctionArgs,
|
ActionFunctionArgs,
|
||||||
Form,
|
Form,
|
||||||
|
@ -12,12 +7,19 @@ import {
|
||||||
useParams,
|
useParams,
|
||||||
useSearchParams,
|
useSearchParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
|
|
||||||
|
import SimpleNavbar from "@components/SimpleNavbar";
|
||||||
|
import GridBackground from "@components/GridBackground";
|
||||||
|
import Container from "@components/Container";
|
||||||
|
import StepCounter from "@components/StepCounter";
|
||||||
|
import Fieldset from "@components/Fieldset";
|
||||||
import { InputFieldWithLabel } from "@components/InputField";
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { checkAuth } from "@/main";
|
import { checkAuth } from "@/main";
|
||||||
import api from "../api";
|
|
||||||
import { CLOUD_API } from "@/ui.config";
|
import { CLOUD_API } from "@/ui.config";
|
||||||
|
|
||||||
|
import api from "../api";
|
||||||
|
|
||||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||||
await checkAuth();
|
await checkAuth();
|
||||||
const res = await fetch(`${CLOUD_API}/devices/${params.id}`, {
|
const res = await fetch(`${CLOUD_API}/devices/${params.id}`, {
|
||||||
|
|
|
@ -1,4 +1,21 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
LoaderFunctionArgs,
|
||||||
|
Outlet,
|
||||||
|
Params,
|
||||||
|
redirect,
|
||||||
|
useLoaderData,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
useOutlet,
|
||||||
|
useParams,
|
||||||
|
useSearchParams,
|
||||||
|
} from "react-router-dom";
|
||||||
|
import { useInterval } from "usehooks-ts";
|
||||||
|
import FocusTrap from "focus-trap-react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import useWebSocket from "react-use-websocket";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import {
|
import {
|
||||||
DeviceSettingsState,
|
DeviceSettingsState,
|
||||||
|
@ -16,36 +33,28 @@ import {
|
||||||
VideoState,
|
VideoState,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import WebRTCVideo from "@components/WebRTCVideo";
|
import WebRTCVideo from "@components/WebRTCVideo";
|
||||||
import {
|
|
||||||
LoaderFunctionArgs,
|
|
||||||
Outlet,
|
|
||||||
Params,
|
|
||||||
redirect,
|
|
||||||
useLoaderData,
|
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
|
||||||
useOutlet,
|
|
||||||
useParams,
|
|
||||||
useSearchParams,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
||||||
import DashboardNavbar from "@components/Header";
|
import DashboardNavbar from "@components/Header";
|
||||||
import { useInterval } from "usehooks-ts";
|
|
||||||
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
|
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
|
||||||
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
|
|
||||||
import api from "../api";
|
|
||||||
import { DeviceStatus } from "./welcome-local";
|
|
||||||
import FocusTrap from "focus-trap-react";
|
|
||||||
import Terminal from "@components/Terminal";
|
import Terminal from "@components/Terminal";
|
||||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
|
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
|
||||||
|
import api from "../api";
|
||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
|
||||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||||
|
import {
|
||||||
|
ConnectionFailedOverlay,
|
||||||
|
LoadingConnectionOverlay,
|
||||||
|
PeerConnectionDisconnectedOverlay,
|
||||||
|
} from "../components/VideoOverlay";
|
||||||
import { FeatureFlagProvider } from "../providers/FeatureFlagProvider";
|
import { FeatureFlagProvider } from "../providers/FeatureFlagProvider";
|
||||||
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
|
|
||||||
import notifications from "../notifications";
|
import notifications from "../notifications";
|
||||||
|
|
||||||
|
import { DeviceStatus } from "./welcome-local";
|
||||||
|
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
|
||||||
|
|
||||||
interface LocalLoaderResp {
|
interface LocalLoaderResp {
|
||||||
authMode: "password" | "noPassword" | null;
|
authMode: "password" | "noPassword" | null;
|
||||||
}
|
}
|
||||||
|
@ -110,7 +119,6 @@ const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||||
|
|
||||||
export default function KvmIdRoute() {
|
export default function KvmIdRoute() {
|
||||||
const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp;
|
const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp;
|
||||||
|
|
||||||
// Depending on the mode, we set the appropriate variables
|
// Depending on the mode, we set the appropriate variables
|
||||||
const user = "user" in loaderResp ? loaderResp.user : null;
|
const user = "user" in loaderResp ? loaderResp.user : null;
|
||||||
const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null;
|
const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null;
|
||||||
|
@ -123,84 +131,352 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
|
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
|
||||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||||
|
|
||||||
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
|
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
|
||||||
|
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||||
const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
|
const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
|
||||||
const setPeerConnection = useRTCStore(state => state.setPeerConnection);
|
const setPeerConnection = useRTCStore(state => state.setPeerConnection);
|
||||||
const setDiskChannel = useRTCStore(state => state.setDiskChannel);
|
const setDiskChannel = useRTCStore(state => state.setDiskChannel);
|
||||||
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
|
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
|
||||||
const setTransceiver = useRTCStore(state => state.setTransceiver);
|
const setTransceiver = useRTCStore(state => state.setTransceiver);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isLegacySignalingEnabled = useRef(false);
|
||||||
|
|
||||||
|
const [connectionFailed, setConnectionFailed] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { otaState, setOtaState, setModalView } = useUpdateStore();
|
const { otaState, setOtaState, setModalView } = useUpdateStore();
|
||||||
|
|
||||||
const sdp = useCallback(
|
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
|
||||||
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
|
const cleanupAndStopReconnecting = useCallback(
|
||||||
if (!pc) return;
|
function cleanupAndStopReconnecting() {
|
||||||
if (event.candidate !== null) return;
|
console.log("Closing peer connection");
|
||||||
|
|
||||||
try {
|
setConnectionFailed(true);
|
||||||
const sd = btoa(JSON.stringify(pc.localDescription));
|
if (peerConnection) {
|
||||||
|
setPeerConnectionState(peerConnection.connectionState);
|
||||||
const sessionUrl = isOnDevice
|
|
||||||
? `${DEVICE_API}/webrtc/session`
|
|
||||||
: `${CLOUD_API}/webrtc/session`;
|
|
||||||
const res = await api.POST(sessionUrl, {
|
|
||||||
sd,
|
|
||||||
// When on device, we don't need to specify the device id, as it's already known
|
|
||||||
...(isOnDevice ? {} : { id: params.id }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
|
|
||||||
if (isOnDevice) {
|
|
||||||
if (res.status === 401) {
|
|
||||||
return navigate("/login-local");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInCloud) {
|
|
||||||
// The cloud API returns a 401 if the user is not logged in
|
|
||||||
// Most likely the session has expired
|
|
||||||
if (res.status === 401) return navigate("/login");
|
|
||||||
|
|
||||||
// If can be a few things
|
|
||||||
// - In cloud mode, the cloud api would return a 404, if the device hasn't contacted the cloud yet
|
|
||||||
// - In device mode, the device api would timeout, the fetch would throw an error, therefore the catch block would be hit
|
|
||||||
// Regardless, we should close the peer connection and let the useInterval handle reconnecting
|
|
||||||
if (!res.ok) {
|
|
||||||
pc?.close();
|
|
||||||
console.error(`Error setting SDP - Status: ${res.status}}`, json);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pc.setRemoteDescription(
|
|
||||||
new RTCSessionDescription(JSON.parse(atob(json.sd))),
|
|
||||||
).catch(e => console.log(`Error setting remote description: ${e}`));
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error setting SDP: ${error}`);
|
|
||||||
pc?.close();
|
|
||||||
}
|
}
|
||||||
|
connectionFailedRef.current = true;
|
||||||
|
|
||||||
|
peerConnection?.close();
|
||||||
|
signalingAttempts.current = 0;
|
||||||
},
|
},
|
||||||
[navigate, params.id],
|
[peerConnection, setPeerConnectionState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectWebRTC = useCallback(async () => {
|
// We need to track connectionFailed in a ref to avoid stale closure issues
|
||||||
console.log("Attempting to connect WebRTC");
|
// This is necessary because syncRemoteSessionDescription is a callback that captures
|
||||||
const pc = new RTCPeerConnection({
|
// the connectionFailed value at creation time, but we need the latest value
|
||||||
// We only use STUN or TURN servers if we're in the cloud
|
// when the function is actually called. Without this ref, the function would use
|
||||||
...(isInCloud && iceConfig?.iceServers
|
// a stale value of connectionFailed in some conditions.
|
||||||
? { iceServers: [iceConfig?.iceServers] }
|
//
|
||||||
: {}),
|
// We still need the state variable for UI rendering, so we sync the ref with the state.
|
||||||
});
|
// This pattern is a workaround for what useEvent hook would solve more elegantly
|
||||||
|
// (which would give us a callback that always has access to latest state without re-creation).
|
||||||
|
const connectionFailedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
connectionFailedRef.current = connectionFailed;
|
||||||
|
}, [connectionFailed]);
|
||||||
|
|
||||||
|
const signalingAttempts = useRef(0);
|
||||||
|
const setRemoteSessionDescription = useCallback(
|
||||||
|
async function setRemoteSessionDescription(
|
||||||
|
pc: RTCPeerConnection,
|
||||||
|
remoteDescription: RTCSessionDescriptionInit,
|
||||||
|
) {
|
||||||
|
setLoadingMessage("Setting remote description");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
|
||||||
|
console.log("[setRemoteSessionDescription] Remote description set successfully");
|
||||||
|
setLoadingMessage("Establishing secure connection...");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[setRemoteSessionDescription] Failed to set remote description:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
cleanupAndStopReconnecting();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the interval-based check with a more reliable approach
|
||||||
|
let attempts = 0;
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
|
||||||
|
if (pc.sctp?.state === "connected") {
|
||||||
|
console.log("[setRemoteSessionDescription] Remote description set");
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
setLoadingMessage("Connection established");
|
||||||
|
} else if (attempts >= 10) {
|
||||||
|
console.log(
|
||||||
|
"[setRemoteSessionDescription] Failed to establish connection after 10 attempts",
|
||||||
|
{
|
||||||
|
connectionState: pc.connectionState,
|
||||||
|
iceConnectionState: pc.iceConnectionState,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cleanupAndStopReconnecting();
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
} else {
|
||||||
|
console.log("[setRemoteSessionDescription] Waiting for connection, state:", {
|
||||||
|
connectionState: pc.connectionState,
|
||||||
|
iceConnectionState: pc.iceConnectionState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
[cleanupAndStopReconnecting],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ignoreOffer = useRef(false);
|
||||||
|
const isSettingRemoteAnswerPending = useRef(false);
|
||||||
|
const makingOffer = useRef(false);
|
||||||
|
|
||||||
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
|
||||||
|
const { sendMessage, getWebSocket } = useWebSocket(
|
||||||
|
isOnDevice
|
||||||
|
? `${wsProtocol}//${window.location.host}/webrtc/signaling/client`
|
||||||
|
: `${CLOUD_API.replace("http", "ws")}/webrtc/signaling/client?id=${params.id}`,
|
||||||
|
{
|
||||||
|
heartbeat: true,
|
||||||
|
retryOnError: true,
|
||||||
|
reconnectAttempts: 5,
|
||||||
|
reconnectInterval: 1000,
|
||||||
|
onReconnectStop: () => {
|
||||||
|
console.log("Reconnect stopped");
|
||||||
|
cleanupAndStopReconnecting();
|
||||||
|
},
|
||||||
|
|
||||||
|
shouldReconnect(event) {
|
||||||
|
console.log("[Websocket] shouldReconnect", event);
|
||||||
|
// TODO: Why true?
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
onClose(event) {
|
||||||
|
console.log("[Websocket] onClose", event);
|
||||||
|
// We don't want to close everything down, we wait for the reconnect to stop instead
|
||||||
|
},
|
||||||
|
|
||||||
|
onError(event) {
|
||||||
|
console.log("[Websocket] onError", event);
|
||||||
|
// We don't want to close everything down, we wait for the reconnect to stop instead
|
||||||
|
},
|
||||||
|
onOpen() {
|
||||||
|
console.log("[Websocket] onOpen");
|
||||||
|
},
|
||||||
|
|
||||||
|
onMessage: message => {
|
||||||
|
if (message.data === "pong") return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Currently the signaling process is as follows:
|
||||||
|
After open, the other side will send a `device-metadata` message with the device version
|
||||||
|
If the device version is not set, we can assume the device is using the legacy signaling
|
||||||
|
Otherwise, we can assume the device is using the new signaling
|
||||||
|
|
||||||
|
If the device is using the legacy signaling, we close the websocket connection
|
||||||
|
and use the legacy HTTPSignaling function to get the remote session description
|
||||||
|
|
||||||
|
If the device is using the new signaling, we don't need to do anything special, but continue to use the websocket connection
|
||||||
|
to chat with the other peer about the connection
|
||||||
|
*/
|
||||||
|
|
||||||
|
const parsedMessage = JSON.parse(message.data);
|
||||||
|
if (parsedMessage.type === "device-metadata") {
|
||||||
|
const { deviceVersion } = parsedMessage.data;
|
||||||
|
console.log("[Websocket] Received device-metadata message");
|
||||||
|
console.log("[Websocket] Device version", deviceVersion);
|
||||||
|
// If the device version is not set, we can assume the device is using the legacy signaling
|
||||||
|
if (!deviceVersion) {
|
||||||
|
console.log("[Websocket] Device is using legacy signaling");
|
||||||
|
|
||||||
|
// Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling
|
||||||
|
// which does everything over HTTP(at least from the perspective of the client)
|
||||||
|
isLegacySignalingEnabled.current = true;
|
||||||
|
getWebSocket()?.close();
|
||||||
|
} else {
|
||||||
|
console.log("[Websocket] Device is using new signaling");
|
||||||
|
isLegacySignalingEnabled.current = false;
|
||||||
|
}
|
||||||
|
setupPeerConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!peerConnection) return;
|
||||||
|
if (parsedMessage.type === "answer") {
|
||||||
|
console.log("[Websocket] Received answer");
|
||||||
|
const readyForOffer =
|
||||||
|
// If we're making an offer, we don't want to accept an answer
|
||||||
|
!makingOffer &&
|
||||||
|
// If the peer connection is stable or we're setting the remote answer pending, we're ready for an offer
|
||||||
|
(peerConnection?.signalingState === "stable" ||
|
||||||
|
isSettingRemoteAnswerPending.current);
|
||||||
|
|
||||||
|
// If we're not ready for an offer, we don't want to accept an offer
|
||||||
|
ignoreOffer.current = parsedMessage.type === "offer" && !readyForOffer;
|
||||||
|
if (ignoreOffer.current) return;
|
||||||
|
|
||||||
|
// Set so we don't accept an answer while we're setting the remote description
|
||||||
|
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
|
||||||
|
console.log(
|
||||||
|
"[Websocket] Setting remote answer pending",
|
||||||
|
isSettingRemoteAnswerPending.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sd = atob(parsedMessage.data);
|
||||||
|
const remoteSessionDescription = JSON.parse(sd);
|
||||||
|
|
||||||
|
setRemoteSessionDescription(
|
||||||
|
peerConnection,
|
||||||
|
new RTCSessionDescription(remoteSessionDescription),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset the remote answer pending flag
|
||||||
|
isSettingRemoteAnswerPending.current = false;
|
||||||
|
} else if (parsedMessage.type === "new-ice-candidate") {
|
||||||
|
console.log("[Websocket] Received new-ice-candidate");
|
||||||
|
const candidate = parsedMessage.data;
|
||||||
|
peerConnection.addIceCandidate(candidate);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Don't even retry once we declare failure
|
||||||
|
!connectionFailed && isLegacySignalingEnabled.current === false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendWebRTCSignal = useCallback(
|
||||||
|
(type: string, data: unknown) => {
|
||||||
|
// Second argument tells the library not to queue the message, and send it once the connection is established again.
|
||||||
|
// We have event handlers that handle the connection set up, so we don't need to queue the message.
|
||||||
|
sendMessage(JSON.stringify({ type, data }), false);
|
||||||
|
},
|
||||||
|
[sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const legacyHTTPSignaling = useCallback(
|
||||||
|
async (pc: RTCPeerConnection) => {
|
||||||
|
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||||
|
|
||||||
|
// Legacy mode == UI in cloud with updated code connecting to older device version.
|
||||||
|
// In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled
|
||||||
|
const sessionUrl = `${CLOUD_API}/webrtc/session`;
|
||||||
|
|
||||||
|
console.log("Trying to get remote session description");
|
||||||
|
setLoadingMessage(
|
||||||
|
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
|
||||||
|
);
|
||||||
|
const res = await api.POST(sessionUrl, {
|
||||||
|
sd,
|
||||||
|
// When on device, we don't need to specify the device id, as it's already known
|
||||||
|
...(isOnDevice ? {} : { id: params.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (res.status === 401) return navigate(isOnDevice ? "/login-local" : "/login");
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("Error getting SDP", { status: res.status, json });
|
||||||
|
cleanupAndStopReconnecting();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Successfully got Remote Session Description. Setting.");
|
||||||
|
setLoadingMessage("Setting remote session description...");
|
||||||
|
|
||||||
|
const decodedSd = atob(json.sd);
|
||||||
|
const parsedSd = JSON.parse(decodedSd);
|
||||||
|
setRemoteSessionDescription(pc, new RTCSessionDescription(parsedSd));
|
||||||
|
},
|
||||||
|
[cleanupAndStopReconnecting, navigate, params.id, setRemoteSessionDescription],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setupPeerConnection = useCallback(async () => {
|
||||||
|
console.log("[setupPeerConnection] Setting up peer connection");
|
||||||
|
setConnectionFailed(false);
|
||||||
|
setLoadingMessage("Connecting to device...");
|
||||||
|
|
||||||
|
if (peerConnection?.signalingState === "stable") {
|
||||||
|
console.log("[setupPeerConnection] Peer connection already established");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pc: RTCPeerConnection;
|
||||||
|
try {
|
||||||
|
console.log("[setupPeerConnection] Creating peer connection");
|
||||||
|
setLoadingMessage("Creating peer connection...");
|
||||||
|
pc = new RTCPeerConnection({
|
||||||
|
// We only use STUN or TURN servers if we're in the cloud
|
||||||
|
...(isInCloud && iceConfig?.iceServers
|
||||||
|
? { iceServers: [iceConfig?.iceServers] }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setPeerConnectionState(pc.connectionState);
|
||||||
|
console.log("[setupPeerConnection] Peer connection created", pc);
|
||||||
|
setLoadingMessage("Setting up connection to device...");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
|
||||||
|
setTimeout(() => {
|
||||||
|
cleanupAndStopReconnecting();
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Set up event listeners and data channels
|
// Set up event listeners and data channels
|
||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
|
console.log("[setupPeerConnection] Connection state changed", pc.connectionState);
|
||||||
setPeerConnectionState(pc.connectionState);
|
setPeerConnectionState(pc.connectionState);
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onicecandidate = event => sdp(event, pc);
|
pc.onnegotiationneeded = async () => {
|
||||||
|
try {
|
||||||
|
console.log("[setupPeerConnection] Creating offer");
|
||||||
|
makingOffer.current = true;
|
||||||
|
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||||
|
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
|
||||||
|
if (isNewSignalingEnabled) {
|
||||||
|
sendWebRTCSignal("offer", { sd: sd });
|
||||||
|
} else {
|
||||||
|
console.log("Legacy signanling. Waiting for ICE Gathering to complete...");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[setupPeerConnection] Error creating offer: ${e}`,
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
cleanupAndStopReconnecting();
|
||||||
|
} finally {
|
||||||
|
makingOffer.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.onicecandidate = async ({ candidate }) => {
|
||||||
|
if (!candidate) return;
|
||||||
|
if (candidate.candidate === "") return;
|
||||||
|
sendWebRTCSignal("new-ice-candidate", candidate);
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.onicegatheringstatechange = event => {
|
||||||
|
const pc = event.currentTarget as RTCPeerConnection;
|
||||||
|
if (pc.iceGatheringState === "complete") {
|
||||||
|
console.log("ICE Gathering completed");
|
||||||
|
setLoadingMessage("ICE Gathering completed");
|
||||||
|
|
||||||
|
if (isLegacySignalingEnabled.current) {
|
||||||
|
// We can now start the https/ws connection to get the remote session description from the KVM device
|
||||||
|
legacyHTTPSignaling(pc);
|
||||||
|
}
|
||||||
|
} else if (pc.iceGatheringState === "gathering") {
|
||||||
|
console.log("ICE Gathering Started");
|
||||||
|
setLoadingMessage("Gathering ICE candidates...");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
pc.ontrack = function (event) {
|
pc.ontrack = function (event) {
|
||||||
setMediaMediaStream(event.streams[0]);
|
setMediaMediaStream(event.streams[0]);
|
||||||
|
@ -218,16 +494,13 @@ export default function KvmIdRoute() {
|
||||||
setDiskChannel(diskDataChannel);
|
setDiskChannel(diskDataChannel);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
setPeerConnection(pc);
|
||||||
const offer = await pc.createOffer();
|
|
||||||
await pc.setLocalDescription(offer);
|
|
||||||
setPeerConnection(pc);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error creating offer: ${e}`);
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
|
cleanupAndStopReconnecting,
|
||||||
iceConfig?.iceServers,
|
iceConfig?.iceServers,
|
||||||
sdp,
|
legacyHTTPSignaling,
|
||||||
|
peerConnection?.signalingState,
|
||||||
|
sendWebRTCSignal,
|
||||||
setDiskChannel,
|
setDiskChannel,
|
||||||
setMediaMediaStream,
|
setMediaMediaStream,
|
||||||
setPeerConnection,
|
setPeerConnection,
|
||||||
|
@ -236,23 +509,12 @@ export default function KvmIdRoute() {
|
||||||
setTransceiver,
|
setTransceiver,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// WebRTC connection management
|
|
||||||
useInterval(() => {
|
|
||||||
if (
|
|
||||||
["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "")
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (location.pathname.includes("other-session")) return;
|
|
||||||
connectWebRTC();
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
// On boot, if the connection state is undefined, we connect to the WebRTC
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (peerConnection?.connectionState === undefined) {
|
if (peerConnectionState === "failed") {
|
||||||
connectWebRTC();
|
console.log("Connection failed, closing peer connection");
|
||||||
|
cleanupAndStopReconnecting();
|
||||||
}
|
}
|
||||||
}, [connectWebRTC, peerConnection?.connectionState]);
|
}, [peerConnectionState, cleanupAndStopReconnecting]);
|
||||||
|
|
||||||
// Cleanup effect
|
// Cleanup effect
|
||||||
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
|
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
|
||||||
|
@ -277,7 +539,7 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
// TURN server usage detection
|
// TURN server usage detection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (peerConnection?.connectionState !== "connected") return;
|
if (peerConnectionState !== "connected") return;
|
||||||
const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState();
|
const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState();
|
||||||
|
|
||||||
const lastLocalStat = Array.from(localCandidateStats).pop();
|
const lastLocalStat = Array.from(localCandidateStats).pop();
|
||||||
|
@ -289,7 +551,7 @@ export default function KvmIdRoute() {
|
||||||
const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
|
const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
|
||||||
|
|
||||||
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
|
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
|
||||||
}, [peerConnection?.connectionState, setIsTurnServerInUse]);
|
}, [peerConnectionState, setIsTurnServerInUse]);
|
||||||
|
|
||||||
// TURN server usage reporting
|
// TURN server usage reporting
|
||||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
||||||
|
@ -380,10 +642,6 @@ export default function KvmIdRoute() {
|
||||||
});
|
});
|
||||||
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-expect-error
|
|
||||||
window.send = send;
|
|
||||||
|
|
||||||
// When the update is successful, we need to refresh the client javascript and show a success modal
|
// When the update is successful, we need to refresh the client javascript and show a success modal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queryParams.get("updateSuccess")) {
|
if (queryParams.get("updateSuccess")) {
|
||||||
|
@ -420,18 +678,17 @@ export default function KvmIdRoute() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!peerConnection) return;
|
if (!peerConnection) return;
|
||||||
if (!kvmTerminal) {
|
if (!kvmTerminal) {
|
||||||
console.log('Creating data channel "terminal"');
|
// console.log('Creating data channel "terminal"');
|
||||||
setKvmTerminal(peerConnection.createDataChannel("terminal"));
|
setKvmTerminal(peerConnection.createDataChannel("terminal"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serialConsole) {
|
if (!serialConsole) {
|
||||||
console.log('Creating data channel "serial"');
|
// console.log('Creating data channel "serial"');
|
||||||
setSerialConsole(peerConnection.createDataChannel("serial"));
|
setSerialConsole(peerConnection.createDataChannel("serial"));
|
||||||
}
|
}
|
||||||
}, [kvmTerminal, peerConnection, serialConsole]);
|
}, [kvmTerminal, peerConnection, serialConsole]);
|
||||||
|
|
||||||
const outlet = useOutlet();
|
const outlet = useOutlet();
|
||||||
const location = useLocation();
|
|
||||||
const onModalClose = useCallback(() => {
|
const onModalClose = useCallback(() => {
|
||||||
if (location.pathname !== "/other-session") navigateTo("/");
|
if (location.pathname !== "/other-session") navigateTo("/");
|
||||||
}, [navigateTo, location.pathname]);
|
}, [navigateTo, location.pathname]);
|
||||||
|
@ -469,6 +726,43 @@ export default function KvmIdRoute() {
|
||||||
[send, setScrollSensitivity],
|
[send, setScrollSensitivity],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ConnectionStatusElement = useMemo(() => {
|
||||||
|
const hasConnectionFailed =
|
||||||
|
connectionFailed || ["failed", "closed"].includes(peerConnectionState || "");
|
||||||
|
|
||||||
|
const isPeerConnectionLoading =
|
||||||
|
["connecting", "new"].includes(peerConnectionState || "") ||
|
||||||
|
peerConnection === null;
|
||||||
|
|
||||||
|
const isDisconnected = peerConnectionState === "disconnected";
|
||||||
|
|
||||||
|
const isOtherSession = location.pathname.includes("other-session");
|
||||||
|
|
||||||
|
if (isOtherSession) return null;
|
||||||
|
if (peerConnectionState === "connected") return null;
|
||||||
|
if (isDisconnected) {
|
||||||
|
return <PeerConnectionDisconnectedOverlay show={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasConnectionFailed)
|
||||||
|
return (
|
||||||
|
<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPeerConnectionLoading) {
|
||||||
|
return <LoadingConnectionOverlay show={true} text={loadingMessage} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [
|
||||||
|
connectionFailed,
|
||||||
|
loadingMessage,
|
||||||
|
location.pathname,
|
||||||
|
peerConnection,
|
||||||
|
peerConnectionState,
|
||||||
|
setupPeerConnection,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeatureFlagProvider appVersion={appVersion}>
|
<FeatureFlagProvider appVersion={appVersion}>
|
||||||
{!outlet && otaState.updating && (
|
{!outlet && otaState.updating && (
|
||||||
|
@ -507,8 +801,14 @@ export default function KvmIdRoute() {
|
||||||
kvmName={deviceName || "JetKVM Device"}
|
kvmName={deviceName || "JetKVM Device"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-full overflow-hidden">
|
<div className="flex h-full w-full overflow-hidden">
|
||||||
<WebRTCVideo />
|
<div className="pointer-events-none fixed inset-0 isolate z-20 flex h-full w-full items-center justify-center">
|
||||||
|
<div className="my-2 h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||||
|
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{peerConnectionState === "connected" && <WebRTCVideo />}
|
||||||
<SidebarContainer sidebarView={sidebarView} />
|
<SidebarContainer sidebarView={sidebarView} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -523,7 +823,8 @@ export default function KvmIdRoute() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal open={outlet !== null} onClose={onModalClose}>
|
<Modal open={outlet !== null} onClose={onModalClose}>
|
||||||
<Outlet context={{ connectWebRTC }} />
|
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
|
||||||
|
<Outlet context={{ setupPeerConnection }} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import { useLoaderData, useRevalidator } from "react-router-dom";
|
import { useLoaderData, useRevalidator } from "react-router-dom";
|
||||||
|
import { LuMonitorSmartphone } from "react-icons/lu";
|
||||||
|
import { ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||||
|
|
||||||
import DashboardNavbar from "@components/Header";
|
import DashboardNavbar from "@components/Header";
|
||||||
import { LinkButton } from "@components/Button";
|
import { LinkButton } from "@components/Button";
|
||||||
|
@ -7,8 +9,6 @@ import useInterval from "@/hooks/useInterval";
|
||||||
import { checkAuth } from "@/main";
|
import { checkAuth } from "@/main";
|
||||||
import { User } from "@/hooks/stores";
|
import { User } from "@/hooks/stores";
|
||||||
import EmptyCard from "@components/EmptyCard";
|
import EmptyCard from "@components/EmptyCard";
|
||||||
import { LuMonitorSmartphone } from "react-icons/lu";
|
|
||||||
import { ArrowRightIcon } from "@heroicons/react/16/solid";
|
|
||||||
import { CLOUD_API } from "@/ui.config";
|
import { CLOUD_API } from "@/ui.config";
|
||||||
|
|
||||||
interface LoaderData {
|
interface LoaderData {
|
||||||
|
@ -49,8 +49,8 @@ export default function DevicesRoute() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-full overflow-hidden">
|
<div className="flex h-full overflow-hidden">
|
||||||
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
|
<div className="mx-auto h-full w-full space-y-6 px-4 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
|
||||||
<div className="flex items-center justify-between pb-4 mt-8 border-b border-b-slate-800/20 dark:border-b-slate-300/20">
|
<div className="mt-8 flex items-center justify-between border-b border-b-slate-800/20 pb-4 dark:border-b-slate-300/20">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-black dark:text-white">
|
<h1 className="text-xl font-bold text-black dark:text-white">
|
||||||
Cloud KVMs
|
Cloud KVMs
|
||||||
|
|
|
@ -1,19 +1,22 @@
|
||||||
|
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||||
|
|
||||||
import SimpleNavbar from "@components/SimpleNavbar";
|
import SimpleNavbar from "@components/SimpleNavbar";
|
||||||
import GridBackground from "@components/GridBackground";
|
import GridBackground from "@components/GridBackground";
|
||||||
import Container from "@components/Container";
|
import Container from "@components/Container";
|
||||||
import Fieldset from "@components/Fieldset";
|
import Fieldset from "@components/Fieldset";
|
||||||
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
|
||||||
import { InputFieldWithLabel } from "@components/InputField";
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { useState } from "react";
|
|
||||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
|
||||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
import api from "../api";
|
|
||||||
import { DeviceStatus } from "./welcome-local";
|
|
||||||
import ExtLink from "../components/ExtLink";
|
|
||||||
import { DEVICE_API } from "@/ui.config";
|
import { DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
|
import api from "../api";
|
||||||
|
import ExtLink from "../components/ExtLink";
|
||||||
|
|
||||||
|
import { DeviceStatus } from "./welcome-local";
|
||||||
|
|
||||||
const loader = async () => {
|
const loader = async () => {
|
||||||
const res = await api
|
const res = await api
|
||||||
.GET(`${DEVICE_API}/device/status`)
|
.GET(`${DEVICE_API}/device/status`)
|
||||||
|
@ -31,12 +34,9 @@ const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const password = formData.get("password");
|
const password = formData.get("password");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.POST(
|
const response = await api.POST(`${DEVICE_API}/auth/login-local`, {
|
||||||
`${DEVICE_API}/auth/login-local`,
|
password,
|
||||||
{
|
});
|
||||||
password,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return redirect("/");
|
return redirect("/");
|
||||||
|
@ -44,6 +44,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
return { error: "Invalid password" };
|
return { error: "Invalid password" };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
return { error: "An error occurred while logging in" };
|
return { error: "An error occurred while logging in" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -58,22 +59,28 @@ export default function LoginLocalRoute() {
|
||||||
<div className="grid min-h-screen grid-rows-layout">
|
<div className="grid min-h-screen grid-rows-layout">
|
||||||
<SimpleNavbar />
|
<SimpleNavbar />
|
||||||
<Container>
|
<Container>
|
||||||
<div className="flex items-center justify-center w-full h-full isolate">
|
<div className="isolate flex h-full w-full items-center justify-center">
|
||||||
<div className="max-w-2xl -mt-32 space-y-8">
|
<div className="-mt-32 max-w-2xl space-y-8">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" />
|
<img
|
||||||
|
src={LogoWhiteIcon}
|
||||||
|
alt=""
|
||||||
|
className="-ml-4 hidden h-[32px] dark:block"
|
||||||
|
/>
|
||||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<h1 className="text-4xl font-semibold text-black dark:text-white">Welcome back to JetKVM</h1>
|
<h1 className="text-4xl font-semibold text-black dark:text-white">
|
||||||
|
Welcome back to JetKVM
|
||||||
|
</h1>
|
||||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||||
Enter your password to access your JetKVM.
|
Enter your password to access your JetKVM.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Fieldset className="space-y-12">
|
<Fieldset className="space-y-12">
|
||||||
<Form method="POST" className="max-w-sm mx-auto space-y-4">
|
<Form method="POST" className="mx-auto max-w-sm space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<InputFieldWithLabel
|
<InputFieldWithLabel
|
||||||
label="Password"
|
label="Password"
|
||||||
|
@ -88,14 +95,14 @@ export default function LoginLocalRoute() {
|
||||||
onClick={() => setShowPassword(false)}
|
onClick={() => setShowPassword(false)}
|
||||||
className="pointer-events-auto"
|
className="pointer-events-auto"
|
||||||
>
|
>
|
||||||
<LuEye className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
<LuEye className="h-4 w-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
onClick={() => setShowPassword(true)}
|
onClick={() => setShowPassword(true)}
|
||||||
className="pointer-events-auto"
|
className="pointer-events-auto"
|
||||||
>
|
>
|
||||||
<LuEyeOff className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
<LuEyeOff className="h-4 w-4 cursor-pointer text-slate-500 dark:text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -111,7 +118,7 @@ export default function LoginLocalRoute() {
|
||||||
textAlign="center"
|
textAlign="center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-start mt-4 text-xs text-slate-500 dark:text-slate-400">
|
<div className="mt-4 flex justify-start text-xs text-slate-500 dark:text-slate-400">
|
||||||
<ExtLink
|
<ExtLink
|
||||||
href="https://jetkvm.com/docs/networking/local-access#reset-password"
|
href="https://jetkvm.com/docs/networking/local-access#reset-password"
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import AuthLayout from "@components/AuthLayout";
|
|
||||||
import { useLocation, useSearchParams } from "react-router-dom";
|
import { useLocation, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import AuthLayout from "@components/AuthLayout";
|
||||||
|
|
||||||
export default function LoginRoute() {
|
export default function LoginRoute() {
|
||||||
const [sq] = useSearchParams();
|
const [sq] = useSearchParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import AuthLayout from "@components/AuthLayout";
|
|
||||||
import { useLocation, useSearchParams } from "react-router-dom";
|
import { useLocation, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import AuthLayout from "@components/AuthLayout";
|
||||||
|
|
||||||
export default function SignupRoute() {
|
export default function SignupRoute() {
|
||||||
const [sq] = useSearchParams();
|
const [sq] = useSearchParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
|
@ -1,15 +1,19 @@
|
||||||
|
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import GridBackground from "@components/GridBackground";
|
import GridBackground from "@components/GridBackground";
|
||||||
import Container from "@components/Container";
|
import Container from "@components/Container";
|
||||||
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { useState } from "react";
|
|
||||||
import { GridCard } from "../components/Card";
|
|
||||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
|
import { DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
|
import { GridCard } from "../components/Card";
|
||||||
import { cx } from "../cva.config";
|
import { cx } from "../cva.config";
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
|
|
||||||
import { DeviceStatus } from "./welcome-local";
|
import { DeviceStatus } from "./welcome-local";
|
||||||
import { DEVICE_API } from "@/ui.config";
|
|
||||||
|
|
||||||
const loader = async () => {
|
const loader = async () => {
|
||||||
const res = await api
|
const res = await api
|
||||||
|
|
|
@ -1,17 +1,20 @@
|
||||||
|
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||||
|
|
||||||
import GridBackground from "@components/GridBackground";
|
import GridBackground from "@components/GridBackground";
|
||||||
import Container from "@components/Container";
|
import Container from "@components/Container";
|
||||||
import Fieldset from "@components/Fieldset";
|
import Fieldset from "@components/Fieldset";
|
||||||
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
|
|
||||||
import { InputFieldWithLabel } from "@components/InputField";
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { useState, useRef, useEffect } from "react";
|
|
||||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
|
||||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
import api from "../api";
|
|
||||||
import { DeviceStatus } from "./welcome-local";
|
|
||||||
import { DEVICE_API } from "@/ui.config";
|
import { DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
|
import api from "../api";
|
||||||
|
|
||||||
|
import { DeviceStatus } from "./welcome-local";
|
||||||
|
|
||||||
const loader = async () => {
|
const loader = async () => {
|
||||||
const res = await api
|
const res = await api
|
||||||
.GET(`${DEVICE_API}/device/status`)
|
.GET(`${DEVICE_API}/device/status`)
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { cx } from "cva";
|
||||||
|
import { redirect } from "react-router-dom";
|
||||||
|
|
||||||
import GridBackground from "@components/GridBackground";
|
import GridBackground from "@components/GridBackground";
|
||||||
import Container from "@components/Container";
|
import Container from "@components/Container";
|
||||||
import { LinkButton } from "@components/Button";
|
import { LinkButton } from "@components/Button";
|
||||||
|
@ -6,11 +9,12 @@ import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
import DeviceImage from "@/assets/jetkvm-device-still.png";
|
import DeviceImage from "@/assets/jetkvm-device-still.png";
|
||||||
import LogoMark from "@/assets/logo-mark.png";
|
import LogoMark from "@/assets/logo-mark.png";
|
||||||
import { cx } from "cva";
|
|
||||||
import api from "../api";
|
|
||||||
import { redirect } from "react-router-dom";
|
|
||||||
import { DEVICE_API } from "@/ui.config";
|
import { DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
|
import api from "../api";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface DeviceStatus {
|
export interface DeviceStatus {
|
||||||
isSetup: boolean;
|
isSetup: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,8 +51,8 @@ export const formatters = {
|
||||||
];
|
];
|
||||||
|
|
||||||
let duration = (date.valueOf() - new Date().valueOf()) / 1000;
|
let duration = (date.valueOf() - new Date().valueOf()) / 1000;
|
||||||
for (let i = 0; i < DIVISIONS.length; i++) {
|
|
||||||
const division = DIVISIONS[i];
|
for (const division of DIVISIONS) {
|
||||||
if (Math.abs(duration) < division.amount) {
|
if (Math.abs(duration) < division.amount) {
|
||||||
return relativeTimeFormat.format(Math.round(duration), division.name);
|
return relativeTimeFormat.format(Math.round(duration), division.name);
|
||||||
}
|
}
|
||||||
|
@ -61,7 +61,7 @@ export const formatters = {
|
||||||
},
|
},
|
||||||
|
|
||||||
price: (price: number | bigint | string, options?: Intl.NumberFormatOptions) => {
|
price: (price: number | bigint | string, options?: Intl.NumberFormatOptions) => {
|
||||||
let opts: Intl.NumberFormatOptions = {
|
const opts: Intl.NumberFormatOptions = {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
...(options || {}),
|
...(options || {}),
|
||||||
|
|
190
web.go
190
web.go
|
@ -1,6 +1,7 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -10,12 +11,17 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//nolint:typecheck
|
||||||
//go:embed all:static
|
//go:embed all:static
|
||||||
var staticFiles embed.FS
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
@ -93,7 +99,7 @@ func setupRouter() *gin.Engine {
|
||||||
protected := r.Group("/")
|
protected := r.Group("/")
|
||||||
protected.Use(protectedMiddleware())
|
protected.Use(protectedMiddleware())
|
||||||
{
|
{
|
||||||
protected.POST("/webrtc/session", handleWebRTCSession)
|
protected.GET("/webrtc/signaling/client", handleLocalWebRTCSignal)
|
||||||
protected.POST("/cloud/register", handleCloudRegister)
|
protected.POST("/cloud/register", handleCloudRegister)
|
||||||
protected.GET("/cloud/state", handleCloudState)
|
protected.GET("/cloud/state", handleCloudState)
|
||||||
protected.GET("/device", handleDevice)
|
protected.GET("/device", handleDevice)
|
||||||
|
@ -120,35 +126,182 @@ func setupRouter() *gin.Engine {
|
||||||
// TODO: support multiple sessions?
|
// TODO: support multiple sessions?
|
||||||
var currentSession *Session
|
var currentSession *Session
|
||||||
|
|
||||||
func handleWebRTCSession(c *gin.Context) {
|
func handleLocalWebRTCSignal(c *gin.Context) {
|
||||||
var req WebRTCSessionRequest
|
cloudLogger.Infof("new websocket connection established")
|
||||||
|
// Create WebSocket options with InsecureSkipVerify to bypass origin check
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
wsOptions := &websocket.AcceptOptions{
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
InsecureSkipVerify: true, // Allow connections from any origin
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := newSession(SessionConfig{})
|
wsCon, err := websocket.Accept(c.Writer, c.Request, wsOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sd, err := session.ExchangeOffer(req.Sd)
|
// get the source from the request
|
||||||
|
source := c.ClientIP()
|
||||||
|
|
||||||
|
// Now use conn for websocket operations
|
||||||
|
defer wsCon.Close(websocket.StatusNormalClosure, "")
|
||||||
|
|
||||||
|
err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": gin.H{"deviceVersion": builtAppVersion}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if currentSession != nil {
|
|
||||||
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
|
err = handleWebRTCSignalWsMessages(wsCon, false, source)
|
||||||
peerConn := currentSession.peerConnection
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWebRTCSignalWsMessages(wsCon *websocket.Conn, isCloudConnection bool, source string) error {
|
||||||
|
runCtx, cancelRun := context.WithCancel(context.Background())
|
||||||
|
defer cancelRun()
|
||||||
|
|
||||||
|
// Add connection tracking to detect reconnections
|
||||||
|
connectionID := uuid.New().String()
|
||||||
|
cloudLogger.Infof("new websocket connection established with ID: %s", connectionID)
|
||||||
|
|
||||||
|
// connection type
|
||||||
|
var sourceType string
|
||||||
|
if isCloudConnection {
|
||||||
|
sourceType = "cloud"
|
||||||
|
} else {
|
||||||
|
sourceType = "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
// probably we can use a better logging framework here
|
||||||
|
logInfof := func(format string, args ...interface{}) {
|
||||||
|
args = append(args, source, sourceType)
|
||||||
|
websocketLogger.Infof(format+", source: %s, sourceType: %s", args...)
|
||||||
|
}
|
||||||
|
logWarnf := func(format string, args ...interface{}) {
|
||||||
|
args = append(args, source, sourceType)
|
||||||
|
websocketLogger.Warnf(format+", source: %s, sourceType: %s", args...)
|
||||||
|
}
|
||||||
|
logTracef := func(format string, args ...interface{}) {
|
||||||
|
args = append(args, source, sourceType)
|
||||||
|
websocketLogger.Tracef(format+", source: %s, sourceType: %s", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
time.Sleep(WebsocketPingInterval)
|
||||||
|
|
||||||
|
// set the timer for the ping duration
|
||||||
|
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
|
||||||
|
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(v)
|
||||||
|
metricConnectionPingDuration.WithLabelValues(sourceType, source).Observe(v)
|
||||||
|
}))
|
||||||
|
|
||||||
|
logInfof("pinging websocket")
|
||||||
|
err := wsCon.Ping(runCtx)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logWarnf("websocket ping error: %v", err)
|
||||||
|
cancelRun()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// dont use `defer` here because we want to observe the duration of the ping
|
||||||
|
timer.ObserveDuration()
|
||||||
|
|
||||||
|
metricConnectionTotalPingCount.WithLabelValues(sourceType, source).Inc()
|
||||||
|
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if isCloudConnection {
|
||||||
|
// create a channel to receive the disconnect event, once received, we cancelRun
|
||||||
|
cloudDisconnectChan = make(chan error)
|
||||||
|
defer func() {
|
||||||
|
close(cloudDisconnectChan)
|
||||||
|
cloudDisconnectChan = nil
|
||||||
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(1 * time.Second)
|
for err := range cloudDisconnectChan {
|
||||||
_ = peerConn.Close()
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cloudLogger.Infof("disconnecting from cloud due to: %v", err)
|
||||||
|
cancelRun()
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
currentSession = session
|
|
||||||
c.JSON(http.StatusOK, gin.H{"sd": sd})
|
for {
|
||||||
|
typ, msg, err := wsCon.Read(runCtx)
|
||||||
|
if err != nil {
|
||||||
|
logWarnf("websocket read error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if typ != websocket.MessageText {
|
||||||
|
// ignore non-text messages
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var message struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(msg, &message)
|
||||||
|
if err != nil {
|
||||||
|
logWarnf("unable to parse ws message: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.Type == "offer" {
|
||||||
|
logInfof("new session request received")
|
||||||
|
var req WebRTCSessionRequest
|
||||||
|
err = json.Unmarshal(message.Data, &req)
|
||||||
|
if err != nil {
|
||||||
|
logWarnf("unable to parse session request data: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfof("new session request: %v", req.OidcGoogle)
|
||||||
|
logTracef("session request info: %v", req)
|
||||||
|
|
||||||
|
metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc()
|
||||||
|
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
|
||||||
|
err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source)
|
||||||
|
if err != nil {
|
||||||
|
logWarnf("error starting new session: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if message.Type == "new-ice-candidate" {
|
||||||
|
logInfof("The client sent us a new ICE candidate: %v", string(message.Data))
|
||||||
|
var candidate webrtc.ICECandidateInit
|
||||||
|
|
||||||
|
// Attempt to unmarshal as a ICECandidateInit
|
||||||
|
if err := json.Unmarshal(message.Data, &candidate); err != nil {
|
||||||
|
logWarnf("unable to parse incoming ICE candidate data: %v", string(message.Data))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidate.Candidate == "" {
|
||||||
|
logWarnf("empty incoming ICE candidate, skipping")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfof("unmarshalled incoming ICE candidate: %v", candidate)
|
||||||
|
|
||||||
|
if currentSession == nil {
|
||||||
|
logInfof("no current session, skipping incoming ICE candidate")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfof("adding incoming ICE candidate to current session: %v", candidate)
|
||||||
|
if err = currentSession.peerConnection.AddICECandidate(candidate); err != nil {
|
||||||
|
logWarnf("failed to add incoming ICE candidate to our peer connection: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLogin(c *gin.Context) {
|
func handleLogin(c *gin.Context) {
|
||||||
|
@ -419,7 +572,6 @@ func handleSetup(c *gin.Context) {
|
||||||
|
|
||||||
// Set the cookie
|
// Set the cookie
|
||||||
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
|
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// For noPassword mode, ensure the password field is empty
|
// For noPassword mode, ensure the password field is empty
|
||||||
config.HashedPassword = ""
|
config.HashedPassword = ""
|
||||||
|
|
|
@ -8,10 +8,10 @@ import (
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"log"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
@ -38,7 +38,7 @@ func RunWebSecureServer() {
|
||||||
TLSConfig: &tls.Config{
|
TLSConfig: &tls.Config{
|
||||||
// TODO: cache certificate in persistent storage
|
// TODO: cache certificate in persistent storage
|
||||||
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
hostname := WebSecureSelfSignedDefaultDomain
|
var hostname string
|
||||||
if info.ServerName != "" {
|
if info.ServerName != "" {
|
||||||
hostname = info.ServerName
|
hostname = info.ServerName
|
||||||
} else {
|
} else {
|
||||||
|
@ -58,7 +58,6 @@ func RunWebSecureServer() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createSelfSignedCert(hostname string) *tls.Certificate {
|
func createSelfSignedCert(hostname string) *tls.Certificate {
|
||||||
|
@ -72,7 +71,8 @@ func createSelfSignedCert(hostname string) *tls.Certificate {
|
||||||
|
|
||||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to generate private key: %v", err)
|
logger.Errorf("Failed to generate private key: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
keyUsage := x509.KeyUsageDigitalSignature
|
keyUsage := x509.KeyUsageDigitalSignature
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue