Compare commits

...

41 Commits

Author SHA1 Message Date
Aveline 5452d7c721
release 0.3.9 (#349) 2025-04-10 16:47:19 +02:00
Adam Shiervani 3fbcb7e5c4
chore(dev_deploy): update logging for websocket in deployment script (#348) 2025-04-10 16:09:37 +02:00
Aveline 76a1825b02
chore(websocket): logging and metrics improvement (#347)
* chore(websocket): only show warning if websocket is closed abnormally

* chore(websocket): add counter for ping requests received
2025-04-10 15:53:26 +02:00
Aveline 4137b82661
feat(websocket): handle ping messages sent from react and add logging (#346) 2025-04-10 15:10:22 +02:00
Adam Shiervani 929e3a66d7
fix(ui): adjust layout and z-index for improved UI consistency in KvmIdRoute (#345) 2025-04-10 12:39:36 +02:00
Adam Shiervani 101032b816
fix(ui): update WebRTCVideo component to properly animate on peer connection state (#343) 2025-04-10 11:55:28 +02:00
Ben Kochie 6618ee4c6e
fix: Shell linting (#328)
Cleanup various shell linting issues
* Use `/usr/bin/env` consistently for better platform compatibility.
* SC2317 (info): Command appears to be unreachable.
* SC2002 (style): Useless cat.

Signed-off-by: SuperQ <superq@gmail.com>
2025-04-10 00:30:33 +02:00
Adam Shiervani 02bf869b99
fix(ui): increase z-index for Modal component to improve layering (#341) 2025-04-10 00:05:51 +02:00
Adam Shiervani d824a1bc86
fix(dev_device): update JETKVM_PROXY_URL to use WebSocket protocol (#342) 2025-04-10 00:05:41 +02:00
Adam Shiervani ee29cb11bd
Don't block new PC if connection is stable. No need to (#340) 2025-04-09 23:26:02 +02:00
Aveline bf89e038ee
Re-add old signaling for when upgrading (#339) 2025-04-09 22:27:01 +02:00
Adam Shiervani 0b6be9b644 refactor: remove unnecessary whitespace in setupRouter function 2025-04-09 22:25:47 +02:00
Adam Shiervani 1656420b3b re-add old signaling for when upgrading 2025-04-09 22:23:05 +02:00
Aveline 7e83e24e07
fix(ota): certificate signed by unknown authority (#338) 2025-04-09 20:29:03 +02:00
Siyuan Miao 652e845d83 fix(ota): certificate signed by unknown authority 2025-04-09 20:25:26 +02:00
Aveline 58f42e0d16
bump to 0.3.9 2025-04-09 19:59:41 +02:00
Aveline e748346e2b
release 0.3.9 (#337) 2025-04-09 19:57:56 +02:00
Adam Shiervani 1a30977085
Feat/Trickle ice (#336)
* feat(cloud): Use Websocket signaling in cloud mode

* refactor: Enhance WebRTC signaling and connection handling

* refactor: Improve WebRTC connection management and logging in KvmIdRoute

* refactor: Update PeerConnectionDisconnectedOverlay to use Card component for better UI structure

* refactor: Standardize metric naming and improve websocket logging

* refactor: Rename WebRTC signaling functions and update deployment script for debug version

* fix: Handle error when writing new ICE candidate to WebRTC signaling channel

* refactor: Rename signaling handler function for clarity

* refactor: Remove old http local http endpoint

* refactor: Improve metric help text and standardize comparison operator in KvmIdRoute

* chore(websocket): use MetricVec instead of Metric to store metrics

* fix conflicts

* fix: use wss when the page is served over https

* feat: Add app version header and update WebRTC signaling endpoint

* fix: Handle error when writing device metadata to WebRTC signaling channel

---------

Co-authored-by: Siyuan Miao <i@xswan.net>
2025-04-09 00:10:38 +02:00
Aveline fa1b11b228
chore(ota): allow a longer timeout when downloading packages (#332) 2025-04-08 00:43:03 +02:00
Aveline abc6d92331
feat(cloud): disconnect from cloud immediately when cloud URL changes… (#326) 2025-04-07 14:19:43 +02:00
Siyuan Miao 73e715117e feat(cloud): disconnect from cloud immediately when cloud URL changes or user requests to deregister 2025-04-04 13:16:38 +02:00
Adam Shiervani 8268b20f32
refactor: Update WebRTC connection handling and overlays (#320)
* refactor: Update WebRTC connection handling and overlays

* fix: Update comments for WebRTC connection handling in KvmIdRoute

* chore: Clean up import statements in devices.$id.tsx
2025-04-03 19:32:14 +02:00
Aveline 1a26431147
chore(cloud): websocket client improvements (#323) 2025-04-03 19:28:37 +02:00
Siyuan Miao f3b5011d65 feat(cloud): add metrics for cloud connections 2025-04-03 19:06:21 +02:00
Siyuan Miao 1e9adf81d4 chore: skip websocket client if net isn't up or time sync hasn't complete 2025-04-03 18:16:41 +02:00
Aveline 65e4a58ad9
chore: Update README Discord Link (#308) 2025-03-31 06:05:30 +02:00
Cameron Fleming df0d083a28
chore: Update README Discord Link
Corrects Discord link in the help section.
2025-03-29 21:13:59 +00:00
Aveline 1f8f885a1d
chore: Enable more linters (#255) 2025-03-28 10:21:49 +01:00
SuperQ aed453cc8c
chore: Enable more linters
Enable more golangci-lint linters.
* `forbidigo` to stop use of non-logger console printing.
* `goimports` to make sure `import` blocks are formatted nicely.
* `misspell` to catch spelling mistakes.
* `whitespace` to catch whitespace issues.

Signed-off-by: SuperQ <superq@gmail.com>
2025-03-26 18:41:09 +01:00
Aveline edafe996a9
chore: fix linting issues of web_tls.go (#287) 2025-03-26 18:32:55 +01:00
Aveline a9180c972c
chore: move smoketest to private repo (#291) 2025-03-26 18:02:03 +01:00
Siyuan Miao b5e0f894bc chore: move smoketest to private repo 2025-03-25 18:42:26 +01:00
Adam Shiervani a3580b5465
Improve error handling when `RTCPeerConnection` throws (#289)
* fix(WebRTC): improve error handling during peer connection creation and add connection error overlay

* refactor: update peer connection state handling and improve type definitions across components
2025-03-25 14:54:04 +01:00
Adam Shiervani 3b711db781
Apply and Upgrade Eslint (#288)
* Upgrade ESLINT and fix issues

* feat: add frontend linting job to GitHub Actions workflow

* Move UI linting to separate file

* More linting fixes

* Remove pull_request trigger from UI linting workflow

* Update UI linting workflow

* Rename frontend-lint workflow to ui-lint for clarity
2025-03-25 11:56:24 +01:00
Adam Shiervani 9d511d7f58
Autoplay permission handling (#285)
* feat(WebRTC): enhance connection management with connection failures after X attempts or a certain time

* refactor(WebRTC): simplify WebRTCVideo component and enhance connection error handling

* fix(WebRTC): extend connection timeout from 1 second to 60 seconds for improved error handling

* feat(VideoOverlay): add NoAutoplayPermissionsOverlay component and improve HDMIErrorOverlay content

* feat(VideoOverlay): update NoAutoplayPermissionsOverlay styling and improve user instructions

* Remove unused PlayIcon import to clean up code
2025-03-24 23:32:13 +01:00
Adam Shiervani 5d7d4db4aa
Improve connection error handling (#284)
* feat(WebRTC): enhance connection management with connection failures after X attempts or a certain time

* refactor(WebRTC): simplify WebRTCVideo component and enhance connection error handling

* fix(WebRTC): extend connection timeout from 1 second to 60 seconds for improved error handling
2025-03-24 23:31:23 +01:00
Aveline 0a7847c5ab
fix: create empty resource directory to avoid static type check failure (#286) 2025-03-24 23:29:46 +01:00
Siyuan Miao 1b8954e9f3 chore: fix linting issues of web_tls.go 2025-03-24 23:20:08 +01:00
Siyuan Miao ab03aded74 chore: create empty resource directory to avoid static type check fail 2025-03-24 23:16:17 +01:00
Adam Shiervani 204e6c7faf feat(UsbDeviceSetting): integrate remote virtual media state management and improve USB config handlingt 2025-03-24 12:32:12 +01:00
Adam Shiervani caf3922ecd
refactor(WebRTCVideo): improve mouse event handling and video playback logic (#282) 2025-03-24 12:07:31 +01:00
107 changed files with 3080 additions and 1640 deletions

View File

@ -12,6 +12,7 @@ jobs:
build:
runs-on: buildjet-4vcpu-ubuntu-2204
name: Build
if: "github.event.review.state == 'approved' || github.event.event_type != 'pull_request_review'"
steps:
- name: Checkout
uses: actions/checkout@v4
@ -19,12 +20,12 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: v21.1.0
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
cache: "npm"
cache-dependency-path: "**/package-lock.json"
- name: Set up Golang
uses: actions/setup-go@v4
with:
go-version: '1.24.0'
go-version: "1.24.0"
- name: Build frontend
run: |
make frontend
@ -35,108 +36,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: 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
path: bin/jetkvm_app

View File

@ -27,6 +27,9 @@ jobs:
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version: 1.23.x
- name: Create empty resource directory
run: |
mkdir -p static && touch static/.gitkeep
- name: Lint
uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1
with:

122
.github/workflows/smoketest.yml vendored Normal file
View File

@ -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

34
.github/workflows/ui-lint.yml vendored Normal file
View File

@ -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

View File

@ -1,12 +1,22 @@
---
linters:
enable:
# - goimports
# - misspell
- forbidigo
- goimports
- misspell
# - revive
- whitespace
issues:
exclude-rules:
- path: _test.go
linters:
- 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.

View File

@ -2,8 +2,8 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.3.9-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.8
VERSION_DEV := 0.4.0-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.9
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm

View File

@ -23,7 +23,7 @@ We welcome contributions from the community! Whether it's improving the firmware
## 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

271
cloud.go
View File

@ -4,12 +4,16 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sync"
"time"
"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"
@ -32,10 +36,134 @@ const (
// CloudOidcRequestTimeout is the timeout for OIDC token verification requests
// should be lower than the websocket response timeout set in cloud-api
CloudOidcRequestTimeout = 10 * time.Second
// CloudWebSocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
CloudWebSocketPingInterval = 15 * time.Second
// WebsocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
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"},
)
metricConnectionLastPingReceivedTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_received_timestamp",
Help: "The timestamp when the last ping request 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"},
)
metricConnectionTotalPingSentCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_sent",
Help: "The total number of pings sent to the connection",
},
[]string{"type", "source"},
)
metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_received",
Help: "The total number of pings received from the connection",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_session_total_requests",
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)
metricConnectionLastPingReceivedTimestamp.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) {
var req CloudRegisterRequest
@ -90,11 +218,6 @@ func handleCloudRegister(c *gin.Context) {
return
}
if config.CloudToken == "" {
cloudLogger.Info("Starting websocket client due to adoption")
go RunWebsocketClient()
}
config.CloudToken = tokenResp.SecretToken
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
@ -125,74 +248,78 @@ func handleCloudRegister(c *gin.Context) {
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 {
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
return fmt.Errorf("cloud token is not set")
}
wsURL, err := url.Parse(config.CloudURL)
if err != nil {
return fmt.Errorf("failed to parse config.CloudURL: %w", err)
}
if wsURL.Scheme == "http" {
wsURL.Scheme = "ws"
} else {
wsURL.Scheme = "wss"
}
header := http.Header{}
header.Set("X-Device-ID", GetDeviceID())
header.Set("X-App-Version", builtAppVersion)
header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
defer cancelDial()
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header,
OnPingReceived: func(ctx context.Context, payload []byte) bool {
websocketLogger.Infof("ping frame received: %v, source: %s, sourceType: cloud", payload, wsURL.Host)
metricConnectionTotalPingReceivedCount.WithLabelValues("cloud", wsURL.Host).Inc()
metricConnectionLastPingReceivedTimestamp.WithLabelValues("cloud", wsURL.Host).SetToCurrentTime()
return true
},
})
// if the context is canceled, we don't want to return an error
if err != nil {
if errors.Is(err, context.Canceled) {
cloudLogger.Infof("websocket connection canceled")
return nil
}
return err
}
defer c.CloseNow() //nolint:errcheck
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)
cloudLogger.Tracef("session request info: %v", req)
// set the metrics when we successfully connect to the cloud.
wsResetMetrics(true, "cloud", wsURL.Host)
err = handleSessionRequest(runCtx, c, req)
if err != nil {
cloudLogger.Infof("error starting new session: %v", err)
continue
}
}
// we don't have a source for the cloud connection
return handleWebRTCSignalWsMessages(c, true, wsURL.Host)
}
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)
defer cancelOIDC()
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
@ -220,10 +347,35 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
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{
ICEServers: req.ICEServers,
ws: c,
IsCloud: isCloudConnection,
LocalIP: req.IP,
IsCloud: true,
ICEServers: req.ICEServers,
})
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
@ -247,15 +399,37 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
cloudLogger.Info("new session accepted")
cloudLogger.Tracef("new session accepted: %v", 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
}
func RunWebsocketClient() {
for {
// 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()
if err != nil {
cloudLogger.Errorf("websocket client error: %v", err)
metricCloudConnectionStatus.Set(0)
metricCloudConnectionFailureCount.Inc()
time.Sleep(5 * time.Second)
}
}
@ -305,6 +479,9 @@ func rpcDeregisterDevice() error {
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
}

View File

@ -1,3 +1,5 @@
#!/usr/bin/env bash
#
# Exit immediately if a command exits with a non-zero status
set -e
@ -16,7 +18,6 @@ show_help() {
echo "Example:"
echo " $0 -r 192.168.0.17"
echo " $0 -r 192.168.0.17 -u admin"
exit 0
}
# Default values
@ -70,10 +71,10 @@ cd bin
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host
cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < jetkvm_app
# Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e
# Set the library path to include the directory where librockit.so is located
@ -84,13 +85,13 @@ killall jetkvm_app || true
killall jetkvm_app_debug || true
# Navigate to the directory where the binary will be stored
cd "$REMOTE_PATH"
cd "${REMOTE_PATH}"
# Make the new binary executable
chmod +x jetkvm_app_debug
# Run the application in the background
PION_LOG_TRACE=jetkvm,cloud ./jetkvm_app_debug
PION_LOG_TRACE=jetkvm,cloud,websocket ./jetkvm_app_debug
EOF
echo "Deployment complete."

2
go.mod
View File

@ -7,7 +7,7 @@ toolchain go1.21.1
require (
github.com/Masterminds/semver/v3 v3.3.0
github.com/beevik/ntp v1.3.1
github.com/coder/websocket v1.8.12
github.com/coder/websocket v1.8.13
github.com/coreos/go-oidc/v3 v3.11.0
github.com/creack/pty v1.1.23
github.com/gin-gonic/gin v1.9.1

2
go.sum
View File

@ -18,6 +18,8 @@ github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=

View File

@ -771,9 +771,14 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
}
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
currentCloudURL := config.CloudURL
config.CloudURL = apiUrl
config.CloudAppURL = appUrl
if currentCloudURL != apiUrl {
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}

1
log.go
View File

@ -6,3 +6,4 @@ import "github.com/pion/logging"
// ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm")
var cloudLogger = logging.NewDefaultLoggerFactory().NewLogger("cloud")
var websocketLogger = logging.NewDefaultLoggerFactory().NewLogger("websocket")

View File

@ -72,11 +72,9 @@ func Main() {
if config.TLSMode != "" {
go RunWebSecureServer()
}
// If the cloud token isn't set, the client won't be started by default.
// However, if the user adopts the device via the web interface, handleCloudRegister will start the client.
if config.CloudToken != "" {
go RunWebsocketClient()
}
// As websocket client already checks if the cloud token is set, we can start it here.
go RunWebsocketClient()
initSerialPort()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

33
ntp.go
View File

@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"os/exec"
"strconv"
"time"
"github.com/beevik/ntp"
@ -20,13 +21,41 @@ const (
)
var (
builtTimestamp string
timeSyncRetryInterval = 0 * time.Second
timeSyncSuccess = false
defaultNTPServers = []string{
"time.cloudflare.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() {
for {
if !networkState.checked {
@ -40,6 +69,9 @@ func TimeSyncLoop() {
continue
}
// check if time sync is needed, but do nothing for now
isTimeSyncNeeded()
logger.Infof("Syncing system time")
start := time.Now()
err := SyncSystemTime()
@ -56,6 +88,7 @@ func TimeSyncLoop() {
continue
}
timeSyncSuccess = true
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
}

8
ota.go
View File

@ -126,7 +126,13 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return fmt.Errorf("error creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
// TODO: set a separate timeout for the download but keep the TLS handshake short
// use Transport here will cause CA certificate validation failure so we temporarily removed it
client := http.Client{
Timeout: 10 * time.Minute,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error downloading file: %w", err)
}

View File

@ -1,15 +1,11 @@
package kvm
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
"github.com/prometheus/common/version"
)
var promHandler http.Handler
func initPrometheus() {
// A Prometheus metrics endpoint.
version.Version = builtAppVersion

View File

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Check if a commit message was provided
if [ -z "$1" ]; then
@ -26,7 +26,7 @@ git checkout -b release-temp
if git ls-remote --heads public main | grep -q 'refs/heads/main'; then
git reset --soft public/main
else
git reset --soft $(git rev-list --max-parents=0 HEAD)
git reset --soft "$(git rev-list --max-parents=0 HEAD)"
fi
# Merge changes from main

View File

@ -66,7 +66,6 @@ func runATXControl() {
newLedPWRState != ledPWRState ||
newBtnRSTState != btnRSTState ||
newBtnPWRState != btnPWRState {
logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v",
newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState)

View File

@ -8,6 +8,8 @@ module.exports = {
"plugin:react-hooks/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:import/recommended",
"prettier",
],
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
parser: "@typescript-eslint/parser",
@ -20,5 +22,45 @@ module.exports = {
},
rules: {
"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"],
},
},
},
};

View File

@ -5,11 +5,7 @@
"useTabs": false,
"arrowParens": "avoid",
"singleQuote": false,
"plugins": [
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": [
"clsx"
],
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx"],
"printWidth": 90
}
}

View File

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Check if an IP address was provided as an argument
if [ -z "$1" ]; then
@ -16,4 +16,4 @@ echo "└───────────────────────
# Set the environment variable and run Vite
echo "Starting development server with JetKVM device at: $ip_address"
sleep 1
JETKVM_PROXY_URL="http://$ip_address" npx vite dev --mode=device
JETKVM_PROXY_URL="ws://$ip_address" npx vite dev --mode=device

1961
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,12 +13,13 @@
"build:device": "tsc && vite build --mode=device --emptyOutDir",
"build:staging": "tsc && vite build --mode=cloud-staging",
"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"
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.2",
"@headlessui/tailwindcss": "^0.2.1",
"@heroicons/react": "^2.2.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
@ -27,46 +28,50 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.1",
"eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^10.2.3",
"framer-motion": "^11.15.0",
"lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4",
"motion": "^12.4.7",
"react": "^18.2.0",
"react-animate-height": "^3.2.3",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0",
"react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.7.112",
"react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.9",
"recharts": "^2.15.1",
"semver": "^7.7.1",
"recharts": "^2.15.0",
"tailwind-merge": "^2.5.5",
"usehooks-ts": "^3.1.1",
"usehooks-ts": "^3.1.0",
"validator": "^13.12.0",
"xterm": "^5.3.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.3.0",
"@types/semver": "^7.5.8",
"@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.8.0",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.5.3",
"prettier": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.5.13",
"eslint": "^8.20.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript": "^5.7.2",
"vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2"
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@ -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 {
useHidStore,
@ -5,19 +12,13 @@ import {
useSettingsStore,
useUiStore,
} from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import ExtensionPopover from "./popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import MountPopopover from "@/components/popovers/MountPopover";
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function Actionbar({
requestFullscreen,

View File

@ -1,21 +1,22 @@
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import { Button, LinkButton } from "@components/Button";
import { GoogleIcon } from "@components/Icons";
import SimpleNavbar from "@components/SimpleNavbar";
import Container from "@components/Container";
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import Fieldset from "@components/Fieldset";
import GridBackground from "@components/GridBackground";
import StepCounter from "@components/StepCounter";
import { CLOUD_API } from "@/ui.config";
type AuthLayoutProps = {
interface AuthLayoutProps {
title: string;
description: string;
action: string;
cta: string;
ctaHref: string;
showCounter?: boolean;
};
}
export default function AuthLayout({
title,
@ -46,8 +47,8 @@ export default function AuthLayout({
}
/>
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-2xl -mt-16 space-y-8">
<div className="isolate flex h-full w-full items-center justify-center">
<div className="-mt-16 max-w-2xl space-y-8">
{showCounter ? (
<div className="text-center">
<StepCounter currStepIdx={0} nSteps={2} />
@ -61,11 +62,8 @@ export default function AuthLayout({
</div>
<Fieldset className="space-y-12">
<div className="max-w-sm mx-auto space-y-4">
<form
action={`${CLOUD_API}/oidc/google`}
method="POST"
>
<div className="mx-auto max-w-sm space-y-4">
<form action={`${CLOUD_API}/oidc/google`} method="POST">
{/*This could be the KVM ID*/}
{deviceId ? (
<input type="hidden" name="deviceId" value={deviceId} />

View File

@ -1,8 +1,9 @@
import React from "react";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
import ExtLink from "@/components/ExtLink";
import LoadingSpinner from "@/components/LoadingSpinner";
import { cva, cx } from "@/cva.config";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
const sizes = {
XS: "h-[28px] px-2 text-xs",
@ -101,7 +102,7 @@ const iconVariants = cva({
},
});
type ButtonContentPropsType = {
interface ButtonContentPropsType {
text?: string | React.ReactNode;
LeadingIcon?: React.FC<{ className: string | undefined }> | null;
TrailingIcon?: React.FC<{ className: string | undefined }> | null;
@ -111,7 +112,7 @@ type ButtonContentPropsType = {
size: keyof typeof sizes;
theme: keyof typeof themes;
loading?: boolean;
};
}
function ButtonContent(props: ButtonContentPropsType) {
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =

View File

@ -1,10 +1,11 @@
import React, { forwardRef } from "react";
import { cx } from "@/cva.config";
type CardPropsType = {
interface CardPropsType {
children: React.ReactNode;
className?: string;
};
}
export const GridCard = ({
children,

View File

@ -1,10 +1,10 @@
import React from "react";
type Props = {
interface Props {
headline: string;
description?: string | React.ReactNode;
Button?: React.ReactNode;
};
}
export const CardHeader = ({ headline, description, Button }: Props) => {
return (

View File

@ -1,7 +1,8 @@
import type { Ref } from "react";
import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import { cva, cx } from "@/cva.config";
const sizes = {
@ -52,7 +53,7 @@ type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
function CheckboxWithLabel(
{ label, id, description, as, fullWidth, readOnly, ...props },
{ label, id, description, fullWidth, readOnly, ...props },
ref: Ref<HTMLInputElement>,
) {
return (

View File

@ -1,4 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import React, { ReactNode } from "react";
import { cx } from "@/cva.config";
function Container({ children, className }: { children: ReactNode; className?: string }) {

View File

@ -1,8 +1,8 @@
import Card from "@components/Card";
export type CustomTooltipProps = {
export interface CustomTooltipProps {
payload: { payload: { date: number; stat: number }; unit: string }[];
};
}
export default function CustomTooltip({ payload }: CustomTooltipProps) {
if (payload?.length) {

View File

@ -1,14 +1,16 @@
import { GridCard } from "@/components/Card";
import React from "react";
import { GridCard } from "@/components/Card";
import { cx } from "../cva.config";
type Props = {
IconElm?: React.FC<any>;
interface Props {
IconElm?: React.FC<{ className: string | undefined }>;
headline: string;
description?: string | React.ReactNode;
BtnElm?: React.ReactNode;
className?: string;
};
}
export default function EmptyCard({
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="space-y-2">
{IconElm && <IconElm className="w-6 h-6 mx-auto text-blue-600 dark:text-blue-400" />}
<h4 className="text-base font-bold leading-none text-black dark:text-white">{headline}</h4>
{IconElm && (
<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>
<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>
{BtnElm}
</div>

View File

@ -1,4 +1,5 @@
import React from "react";
import { cx } from "@/cva.config";
export default function ExtLink({

View File

@ -1,4 +1,5 @@
import { useEffect } from "react";
import { useFeatureFlag } from "../hooks/useFeatureFlag";
export function FeatureFlag({

View File

@ -1,13 +1,14 @@
import React from "react";
import { cx } from "@/cva.config";
type Props = {
interface Props {
label: string | React.ReactNode;
id?: string;
as?: "label" | "span";
description?: string | React.ReactNode | null;
disabled?: boolean;
};
}
export default function FieldLabel({
label,
id,

View File

@ -9,7 +9,7 @@ export default function Fieldset({
disabled,
}: {
children: React.ReactNode;
fetcher?: FetcherWithComponents<any>;
fetcher?: FetcherWithComponents<unknown>;
className?: string;
disabled?: boolean;
}) {

View File

@ -2,19 +2,22 @@ import { Fragment, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton } from "@headlessui/react";
import { LuMonitorSmartphone } from "react-icons/lu";
import Container from "@/components/Container";
import Card from "@/components/Card";
import { LuMonitorSmartphone } from "react-icons/lu";
import { cx } from "@/cva.config";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "../api";
import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
interface NavbarProps {
isLoggedIn: boolean;
@ -33,7 +36,7 @@ export default function DashboardNavbar({
picture,
kvmName,
}: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
const onLogout = useCallback(async () => {
@ -86,7 +89,7 @@ export default function DashboardNavbar({
<div className="hidden w-[159px] md:block">
<USBStateStatus
state={usbState}
peerConnectionState={peerConnectionState}
peerConnectionState={peerConnectionState}
/>
</div>
</div>

View File

@ -1,3 +1,5 @@
import { useEffect } from "react";
import { cx } from "@/cva.config";
import {
useHidStore,
@ -6,7 +8,6 @@ import {
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import { useEffect } from "react";
import { keys, modifiers } from "@/keyboardMappings";
export default function InfoBar() {

View File

@ -1,7 +1,8 @@
import type { Ref } from "react";
import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import Card from "@/components/Card";
import { cva } from "@/cva.config";
@ -84,7 +85,7 @@ const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProp
{(label || 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>
);
},

View File

@ -1,10 +1,11 @@
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router-dom";
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 {
// Allow dates or times to be passed
const timeMs = typeof date === "number" ? date : date.getTime();

View File

@ -1,5 +1,6 @@
import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config";
const Modal = React.memo(function Modal({
@ -14,12 +15,12 @@ const Modal = React.memo(function Modal({
onClose: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} className="relative z-10">
<Dialog open={open} onClose={onClose} className="relative z-20">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in dark:bg-slate-900/90"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="fixed inset-0 z-20 w-screen overflow-y-auto">
{/* TODO: This doesn't work well with other-sessions */}
<div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4">
<DialogPanel

View File

@ -1,4 +1,5 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import EmptyCard from "@/components/EmptyCard";
export default function NotFoundPage() {

View File

@ -9,21 +9,22 @@ const PeerConnectionStatusMap = {
failed: "Connection failed",
closed: "Closed",
new: "Connecting",
};
} as Record<RTCPeerConnectionState | "error" | "closing", string>;
export type PeerConnections = keyof typeof PeerConnectionStatusMap;
type StatusProps = {
[key in PeerConnections]: {
type StatusProps = Record<
PeerConnections,
{
statusIndicatorClassName: string;
};
};
}
>;
export default function PeerConnectionStatusCard({
state,
title,
}: {
state?: PeerConnections;
state?: RTCPeerConnectionState | null;
title?: string;
}) {
if (!state) return null;

View File

@ -1,9 +1,12 @@
import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "./Card";
import FieldLabel from "@/components/FieldLabel";
import { cva } from "@/cva.config";
import Card from "./Card";
type SelectMenuProps = Pick<
JSX.IntrinsicElements["select"],
"disabled" | "onChange" | "name" | "value"

View File

@ -1,10 +1,11 @@
import Container from "@/components/Container";
import { Link } from "react-router-dom";
import React from "react";
import Container from "@/components/Container";
import LogoBlueIcon from "@/assets/logo-blue.png";
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) {
return (

View File

@ -9,6 +9,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
export default function StatChart({

View File

@ -1,4 +1,5 @@
import React from "react";
import { cx } from "@/cva.config";
interface Props {

View File

@ -1,12 +1,13 @@
import { CheckIcon } from "@heroicons/react/16/solid";
import { cva, cx } from "@/cva.config";
import Card from "@/components/Card";
type Props = {
interface Props {
nSteps: number;
currStepIdx: number;
size?: keyof typeof sizes;
};
}
const sizes = {
SM: "text-xs leading-[12px]",

View File

@ -1,8 +1,5 @@
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 { cx } from "@/cva.config";
import { useEffect } from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
@ -11,6 +8,11 @@ import { WebglAddon } from "@xterm/addon-webgl";
import { Unicode11Addon } from "@xterm/addon-unicode11";
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");
// Terminal theme configuration

View File

@ -1,6 +1,7 @@
import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import { FieldError } from "@/components/InputField";
import Card from "@/components/Card";
import { cx } from "@/cva.config";

View File

@ -1,23 +1,23 @@
import React from "react";
import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import React from "react";
import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards";
import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"];
type StatusProps = {
[key in USBStates]: {
type StatusProps = Record<
USBStates,
{
icon: React.FC<{ className: string | undefined }>;
iconClassName: string;
statusIndicatorClassName: string;
};
};
}
>;
const USBStateMap: {
[key in USBStates]: string;
} = {
const USBStateMap: Record<USBStates, string> = {
configured: "Connected",
attached: "Connecting",
addressed: "Connecting",
@ -30,9 +30,8 @@ export default function USBStateStatus({
peerConnectionState,
}: {
state: USBStates;
peerConnectionState?: RTCPeerConnectionState;
peerConnectionState?: RTCPeerConnectionState | null;
}) {
const StatusCardProps: StatusProps = {
configured: {
icon: ({ className }) => (

View File

@ -1,8 +1,10 @@
import { cx } from "@/cva.config";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
export default function UpdateInProgressStatusCard() {
const { navigateTo } = useDeviceUiNavigation();

View File

@ -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 notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
import Checkbox from "./Checkbox";
import { Button } from "./Button";
import { SelectMenuBasic } from "./SelectMenuBasic";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import Fieldset from "./Fieldset";
export interface USBConfig {
vendor_id: string;
product_id: string;
@ -119,13 +118,12 @@ export function UsbDeviceSetting() {
const onUsbConfigItemChange = useCallback(
(key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => {
setUsbDeviceConfig(val => {
val[key] = e.target.checked;
handleUsbConfigChange(val);
return val;
});
setUsbDeviceConfig(prev => ({
...prev,
[key]: e.target.checked,
}));
},
[handleUsbConfigChange],
[],
);
const handlePresetChange = useCallback(

View File

@ -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 { InputFieldWithLabel } from "./InputField";
import { useEffect, useState } from "react";
import { UsbConfigState } from "../hooks/stores";
import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
import { InputFieldWithLabel } from "./InputField";
import { SelectMenuBasic } from "./SelectMenuBasic";
import Fieldset from "./Fieldset";

View File

@ -1,10 +1,12 @@
import React from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { LinkButton } from "@components/Button";
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion";
import { LuPlay } from "react-icons/lu";
import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner";
import { GridCard } from "@components/Card";
import { motion, AnimatePresence } from "motion/react";
import Card, { GridCard } from "@components/Card";
interface OverlayContentProps {
children: React.ReactNode;
@ -23,18 +25,18 @@ interface LoadingOverlayProps {
show: boolean;
}
export function LoadingOverlay({ show }: LoadingOverlayProps) {
export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="absolute inset-0 aspect-video h-full w-full"
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: show ? 0.3 : 0.1,
ease: "easeInOut"
ease: "easeInOut",
}}
>
<OverlayContent>
@ -53,22 +55,60 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) {
);
}
interface ConnectionErrorOverlayProps {
interface LoadingConnectionOverlayProps {
show: boolean;
text: string;
}
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="absolute inset-0 z-10 aspect-video h-full w-full"
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.3,
ease: "easeInOut"
duration: 0.4,
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>
@ -85,14 +125,75 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
<li>Try restarting both the device and your computer</li>
</ul>
</div>
<div>
<div className="flex items-center gap-x-2">
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
theme="primary"
text="Troubleshooting Guide"
TrailingIcon={ArrowRightIcon}
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>
@ -118,25 +219,27 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<AnimatePresence>
{show && isNoSignal && (
<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 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut"
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<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">No HDMI signal detected.</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<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>
If using an adapter, it&apos;s compatible and functioning
correctly
@ -169,7 +272,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut"
ease: "easeInOut",
}}
>
<OverlayContent>
@ -187,7 +290,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</div>
<div>
<LinkButton
to={"/help/hdmi-error"}
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
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>
);
}

View File

@ -1,11 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import { Button } from "@components/Button";
import Card from "@components/Card";
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 { useHidStore, useUiStore } from "@/hooks/stores";
import { motion, AnimatePresence } from "motion/react";
import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard";

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useDeviceSettingsStore,
useHidStore,
@ -15,15 +16,19 @@ import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { HDMIErrorOverlay } from "./VideoOverlay";
import { ConnectionErrorOverlay } from "./VideoOverlay";
import { LoadingOverlay } from "./VideoOverlay";
import {
HDMIErrorOverlay,
LoadingVideoOverlay,
NoAutoplayPermissionsOverlay,
} from "./VideoOverlay";
export default function WebRTCVideo() {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// Store hooks
const settings = useSettingsStore();
@ -41,15 +46,13 @@ export default function WebRTCVideo() {
// RTC related states
const peerConnection = useRTCStore(state => state.peerConnection);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isLoading = !hdmiError && !isPlaying;
const isConnectionError = ["error", "failed", "disconnected"].includes(
peerConnectionState || "",
);
const isVideoLoading = !isPlaying;
// console.log("peerConnection?.connectionState", peerConnection?.connectionState);
// Keyboard related states
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
@ -82,19 +85,19 @@ export default function WebRTCVideo() {
const onVideoPlaying = useCallback(() => {
setIsPlaying(true);
videoElm.current && updateVideoSizeStore(videoElm.current);
if (videoElm.current) updateVideoSizeStore(videoElm.current);
}, [updateVideoSizeStore]);
// On mount, get the video size
useEffect(
function updateVideoSizeOnMount() {
videoElm.current && updateVideoSizeStore(videoElm.current);
if (videoElm.current) updateVideoSizeStore(videoElm.current);
},
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
);
// 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(
(x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "relative") return;
@ -168,7 +171,14 @@ export default function WebRTCVideo() {
const { buttons } = e;
sendAbsMouseMovement(x, y, buttons);
},
[sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode],
[
sendAbsMouseMovement,
videoClientHeight,
videoClientWidth,
videoWidth,
videoHeight,
settings.mouseMode,
],
);
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
@ -355,7 +365,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(
function setupKeyboardEvents() {
const abortController = new AbortController();
@ -377,47 +448,23 @@ export default function WebRTCVideo() {
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
);
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();
}
}
}, []);
// Setup Video Event Listeners
useEffect(
function setupVideoEventListeners() {
let videoElmRefValue = null;
if (!videoElm.current) return;
videoElmRefValue = videoElm.current;
const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
// To prevent the video from being paused when the user presses a space in fullscreen mode
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
videoElmRefValue.addEventListener(
"contextmenu",
(e: MouseEvent) => e.preventDefault(),
{ signal },
);
// We need to know when the video is playing to update state and video size
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
return () => {
if (videoElmRefValue) abortController.abort();
abortController.abort();
};
},
[
@ -429,6 +476,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(
function setupRelativeMouseEventListeners() {
if (settings.mouseMode !== "relative") return;
@ -436,50 +518,43 @@ export default function WebRTCVideo() {
const abortController = new AbortController();
const signal = abortController.signal;
// bind to body to capture all mouse events
const body = document.querySelector("body");
if (!body) return;
// We bind to the larger container in relative mode because of delta between the acceleration of the local
// mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use.
// When we get Pointer Lock support, we can remove this.
const containerElm = containerRef.current;
if (!containerElm) return;
body.addEventListener("mousemove", relMouseMoveHandler, { signal });
body.addEventListener("pointerdown", relMouseMoveHandler, { signal });
body.addEventListener("pointerup", relMouseMoveHandler, { signal });
containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal });
containerElm.addEventListener("pointerdown", 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 () => {
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);
},
[
setVideoClientSize,
setVideoSize,
mediaStream,
updateVideoSizeStore,
peerConnection?.iceConnectionState,
],
[settings.mouseMode, relMouseMoveHandler, mouseWheelHandler],
);
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 (
<div className="grid h-full w-full grid-rows-layout">
<div className="min-h-[39.5px]">
<fieldset disabled={peerConnectionState !== "connected"}>
<fieldset disabled={peerConnection?.connectionState !== "connected"}>
<Actionbar
requestFullscreen={async () =>
videoElm.current?.requestFullscreen({
@ -490,7 +565,12 @@ export default function WebRTCVideo() {
</fieldset>
</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={cx(
@ -519,23 +599,35 @@ export default function WebRTCVideo() {
className={cx(
"outline-50 max-h-full max-w-full object-contain transition-all duration-1000",
{
"cursor-none": settings.isCursorHidden,
"opacity-0": isLoading || isConnectionError || hdmiError,
"cursor-none":
settings.mouseMode === "absolute" &&
settings.isCursorHidden,
"opacity-0":
isVideoLoading ||
hdmiError ||
peerConnectionState !== "connected",
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
isPlaying,
},
)}
/>
<div
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} />
<ConnectionErrorOverlay show={isConnectionError} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
{peerConnection?.connectionState == "connected" && (
<div
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">
<LoadingVideoOverlay show={isVideoLoading} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
}}
/>
</div>
</div>
</div>
)}
</div>
</div>
<VirtualKeyboard />

View File

@ -1,11 +1,13 @@
import { Button } from "@components/Button";
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import { useEffect, useState } from "react";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useEffect, useState } from "react";
import notifications from "@/notifications";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useJsonRpc } from "../../hooks/useJsonRpc";
import LoadingSpinner from "../LoadingSpinner";
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
@ -102,11 +104,11 @@ export function ATXPowerControl() {
{atxState === null ? (
<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 className="h-[120px] animate-fadeIn opacity-0">
<div className="p-3 space-y-4">
<div className="space-y-4 p-3">
{/* Control Buttons */}
<div className="flex items-center space-x-2">
<Button

View File

@ -1,12 +1,13 @@
import { Button } from "@components/Button";
import { LuPower } from "react-icons/lu";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import FieldLabel from "../FieldLabel";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useCallback, useEffect, useState } from "react";
import notifications from "@/notifications";
import LoadingSpinner from "../LoadingSpinner";
import FieldLabel from "@components/FieldLabel";
import LoadingSpinner from "@components/LoadingSpinner";
interface DCPowerState {
isOn: boolean;
@ -59,11 +60,11 @@ export function DCPowerControl() {
{powerState === null ? (
<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 className="h-[160px] animate-fadeIn opacity-0">
<div className="p-3 space-y-4">
<div className="space-y-4 p-3">
{/* Power Controls */}
<div className="flex items-center space-x-2">
<Button

View File

@ -1,12 +1,13 @@
import { Button } from "@components/Button";
import { LuTerminal } from "react-icons/lu";
import { useEffect, useState } from "react";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SelectMenuBasic } from "../SelectMenuBasic";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useEffect, useState } from "react";
import notifications from "@/notifications";
import { useUiStore } from "@/hooks/stores";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
interface SerialSettings {
baudRate: string;

View File

@ -1,19 +1,20 @@
import { useEffect, useState } from "react";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { Button } from "../Button";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
import { DCPowerControl } from "@components/extensions/DCPowerControl";
import { SerialConsole } from "@components/extensions/SerialConsole";
import notifications from "../../notifications";
import { Button } from "@components/Button";
import notifications from "@/notifications";
interface Extension {
id: string;
name: string;
description: string;
icon: any;
icon: React.ElementType;
}
const AVAILABLE_EXTENSIONS: Extension[] = [
@ -58,7 +59,9 @@ export default function ExtensionPopover() {
const handleSetActiveExtension = (extension: Extension | null) => {
send("setActiveExtension", { extensionId: extension?.id || "" }, 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;
}
setActiveExtension(extension);
@ -80,7 +83,7 @@ export default function ExtensionPopover() {
return (
<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="space-y-4">
{activeExtension ? (
@ -89,7 +92,7 @@ export default function ExtensionPopover() {
{renderActiveExtension()}
<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={{
animationDuration: "0.7s",
animationDelay: "0.2s",
@ -110,7 +113,7 @@ export default function ExtensionPopover() {
title="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">
{AVAILABLE_EXTENSIONS.map(extension => (
<div

View File

@ -1,11 +1,6 @@
import { Button } from "@components/Button";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import Card, { GridCard } from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import {
LuArrowUpFromLine,
LuCheckCheck,
@ -13,11 +8,17 @@ import {
LuPlus,
LuRadioReceiver,
} from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications";
import { useClose } from "@headlessui/react";
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 notifications from "@/notifications";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
@ -194,7 +195,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<GridCard>
<div className="space-y-4 p-4 py-3">
<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">
<SettingsPageHeader
title="Virtual Media"

View File

@ -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 { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
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 notifications from "@/notifications";
const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier };
@ -59,6 +60,7 @@ export default function PasteModal() {
});
}
} catch (error) {
console.error(error);
notifications.error("Failed to paste text");
}
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
@ -71,7 +73,7 @@ export default function PasteModal() {
return (
<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="h-full space-y-4">
<div className="space-y-4">
@ -81,7 +83,7 @@ export default function PasteModal() {
/>
<div
className="space-y-2 opacity-0 animate-fadeIn"
className="animate-fadeIn space-y-2 opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -120,8 +122,8 @@ export default function PasteModal() {
/>
{invalidChars.length > 0 && (
<div className="flex items-center mt-2 gap-x-2">
<ExclamationCircleIcon className="w-4 h-4 text-red-500 dark:text-red-400" />
<div className="mt-2 flex items-center gap-x-2">
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400">
The following characters won&apos;t be pasted:{" "}
{invalidChars.join(", ")}
@ -135,7 +137,7 @@ export default function PasteModal() {
</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={{
animationDuration: "0.7s",
animationDelay: "0.2s",

View File

@ -1,8 +1,8 @@
import { InputFieldWithLabel } from "@components/InputField";
import { useState, useRef } from "react";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
import { LuArrowLeft } from "react-icons/lu";
import { LuPlus, LuArrowLeft } from "react-icons/lu";
import { InputFieldWithLabel } from "@/components/InputField";
import { Button } from "@/components/Button";
interface AddDeviceFormProps {
onAddDevice: (name: string, macAddress: string) => void;
@ -26,7 +26,7 @@ export default function AddDeviceForm({
return (
<div className="space-y-4">
<div
className="space-y-4 opacity-0 animate-fadeIn"
className="animate-fadeIn space-y-4 opacity-0"
style={{
animationDuration: "0.5s",
animationFillMode: "forwards",
@ -73,7 +73,7 @@ export default function AddDeviceForm({
/>
</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={{
animationDuration: "0.7s",
animationDelay: "0.2s",

View File

@ -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 { Button } from "@/components/Button";
import Card from "@/components/Card";
import { FieldError } from "@/components/InputField";
export interface StoredDevice {
name: string;
macAddress: string;
@ -27,12 +28,14 @@ export default function DeviceList({
}: DeviceListProps) {
return (
<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">
{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">
<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">
{device.macAddress?.toLowerCase()}
</p>
@ -60,18 +63,13 @@ export default function DeviceList({
</div>
</Card>
<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={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={onCancelWakeOnLanModal}
/>
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
<Button
size="SM"
theme="primary"

View File

@ -1,7 +1,8 @@
import Card from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/16/solid";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
import Card from "@/components/Card";
import { Button } from "@/components/Button";
export default function EmptyStateCard({
onCancelWakeOnLanModal,
@ -11,15 +12,15 @@ export default function EmptyStateCard({
setShowAddForm: (show: boolean) => void;
}) {
return (
<div className="space-y-4 select-none">
<Card className="opacity-0 animate-fadeIn">
<div className="select-none space-y-4">
<Card className="animate-fadeIn opacity-0">
<div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3">
<div className="space-y-1">
<div className="inline-block">
<Card>
<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>
</Card>
</div>
@ -34,7 +35,7 @@ export default function EmptyStateCard({
</div>
</Card>
<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={{
animationDuration: "0.7s",
animationDelay: "0.2s",

View File

@ -1,10 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import EmptyStateCard from "./EmptyStateCard";
import DeviceList, { StoredDevice } from "./DeviceList";
import AddDeviceForm from "./AddDeviceForm";
@ -99,7 +101,7 @@ export default function WakeOnLanModal() {
return (
<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="space-y-4">
<SettingsPageHeader

View File

@ -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 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>(
stream: Map<number, T>,
metric: K,
@ -120,7 +121,7 @@ export default function ConnectionStatsSidebar() {
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{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>
</div>
) : isMetricSupported(inboundRtpStats, "packetsLost") ? (
@ -130,7 +131,7 @@ export default function ConnectionStatsSidebar() {
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>
</div>
)}
@ -149,7 +150,7 @@ export default function ConnectionStatsSidebar() {
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{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>
</div>
) : isMetricSupported(candidatePairStats, "currentRoundTripTime") ? (
@ -167,7 +168,7 @@ export default function ConnectionStatsSidebar() {
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>
</div>
)}
@ -186,7 +187,7 @@ export default function ConnectionStatsSidebar() {
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{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>
</div>
) : (
@ -216,7 +217,7 @@ export default function ConnectionStatsSidebar() {
<GridCard>
<div className="flex h-[127px] w-full items-center justify-center text-sm text-slate-500">
{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>
</div>
) : (

View File

@ -1,7 +1,8 @@
import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
import { isOnDevice } from "../main";
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
*

View File

@ -1,5 +1,6 @@
import { useContext } from "react";
import { FeatureFlagContext } from "../providers/FeatureFlagProvider";
import { FeatureFlagContext } from "@/providers/FeatureFlagContext";
export const useFeatureFlag = (minAppVersion: string) => {
const context = useContext(FeatureFlagContext);

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect } from "react";
import { useRTCStore } from "@/hooks/stores";
export interface JsonRpcRequest {
@ -56,7 +57,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
// The "API" can also "request" data from the client
// If the payload has a method, it's a request
if ("method" in payload) {
onRequest && onRequest(payload);
if (onRequest) onRequest(payload);
return;
}

View File

@ -1,4 +1,5 @@
import { useCallback } from "react";
import { useHidStore, useRTCStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";

View File

@ -1,19 +1,18 @@
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { useIsMounted } from "./useIsMounted";
/** The size of the observed element. */
type Size = {
interface Size {
/** The width of the observed element. */
width: number | undefined;
/** The height of the observed element. */
height: number | undefined;
};
}
/** The options for the ResizeObserver. */
type UseResizeObserverOptions<T extends HTMLElement = HTMLElement> = {
interface UseResizeObserverOptions<T extends HTMLElement = HTMLElement> {
/** The ref of the element to observe. */
ref: RefObject<T>;
/**
@ -26,7 +25,7 @@ type UseResizeObserverOptions<T extends HTMLElement = HTMLElement> = {
* @default 'content-box'
*/
box?: "border-box" | "content-box" | "device-pixel-content-box";
};
}
const initialSize: Size = {
width: undefined,
@ -102,7 +101,7 @@ export function useResizeObserver<T extends HTMLElement = HTMLElement>(
return () => {
observer.disconnect();
};
}, [box, ref.current, isMounted]);
}, [box, isMounted, ref]);
return { width, height };
}
@ -127,6 +126,6 @@ function extractSize(
return Array.isArray(entry[box])
? 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);
}

View File

@ -1,5 +1,4 @@
import ReactDOM from "react-dom/client";
import Root from "./root";
import "./index.css";
import {
createBrowserRouter,
@ -8,19 +7,22 @@ import {
RouterProvider,
useRouteError,
} 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 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 DevicesAlreadyAdopted from "@routes/devices.already-adopted";
import Root from "./root";
import Notifications from "./notifications";
import LoginLocalRoute from "./routes/login-local";
import WelcomeLocalModeRoute from "./routes/welcome-local.mode";

View File

@ -1,8 +1,9 @@
import toast, { Toast, Toaster, useToasterStore } from "react-hot-toast";
import React, { useEffect } from "react";
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
import Card from "@/components/Card";
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
interface NotificationOptions {
duration?: number;

View File

@ -0,0 +1,10 @@
import { createContext } from "react";
import { FeatureFlagContextType } from "./FeatureFlagProvider";
// Create the context
export const FeatureFlagContext = createContext<FeatureFlagContextType>({
appVersion: null,
isFeatureEnabled: () => false,
});

View File

@ -1,17 +1,12 @@
import { createContext } from "react";
import semver from "semver";
interface FeatureFlagContextType {
import { FeatureFlagContext } from "./FeatureFlagContext";
export interface FeatureFlagContextType {
appVersion: string | null;
isFeatureEnabled: (minVersion: string) => boolean;
}
// Create the context
export const FeatureFlagContext = createContext<FeatureFlagContextType>({
appVersion: null,
isFeatureEnabled: () => false,
});
// Provider component that fetches version and provides context
export const FeatureFlagProvider = ({
children,

View File

@ -1,7 +1,9 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom";
import api from "../api";
import { DEVICE_API } from "@/ui.config";
import api from "../api";
export interface CloudState {
connected: boolean;
url: string;

View File

@ -6,6 +6,8 @@ import {
useActionData,
useLoaderData,
} from "react-router-dom";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { CardHeader } from "@components/CardHeader";
@ -13,7 +15,6 @@ import DashboardNavbar from "@components/Header";
import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { CLOUD_API } from "@/ui.config";
interface LoaderData {
@ -36,6 +37,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
return { message: "There was an error renaming your device. Please try again." };
}
} catch (e) {
console.error(e);
return { message: "There was an error renaming your device. Please try again." };
}

View File

@ -1,15 +1,4 @@
import Card, { GridCard } from "@/components/Card";
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 {
LuGlobe,
LuLink,
@ -18,8 +7,15 @@ import {
LuCheck,
LuUpload,
} 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 { PlusCircleIcon } from "@heroicons/react/20/solid";
import AutoHeight from "@components/AutoHeight";
import { InputFieldWithLabel } from "@/components/InputField";
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 ArchIcon from "@/assets/arch-icon.png";
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 { isOnDevice } from "../main";
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() {
const navigate = useNavigate();

View File

@ -1,11 +1,12 @@
import { useNavigate, useOutletContext } from "react-router-dom";
import { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg";
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. */
@ -15,7 +16,7 @@ export default function OtherSessionRoute() {
// Function to handle closing the modal
const handleClose = () => {
outletContext?.connectWebRTC().then(() => navigate(".."));
outletContext?.setupPeerConnection().then(() => navigate(".."));
};
return (

View File

@ -6,8 +6,9 @@ import {
useActionData,
useLoaderData,
} from "react-router-dom";
import { Button, LinkButton } from "@components/Button";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { CardHeader } from "@components/CardHeader";
import { InputFieldWithLabel } from "@components/InputField";
@ -15,9 +16,10 @@ import DashboardNavbar from "@components/Header";
import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import api from "../api";
import { CLOUD_API } from "@/ui.config";
import api from "../api";
interface LoaderData {
device: { id: string; name: string; user: { googleId: string } };
user: User;
@ -39,6 +41,7 @@ const action = async ({ params, request }: ActionFunctionArgs) => {
return { message: "There was an error renaming your device. Please try again." };
}
} catch (e) {
console.error(e);
return { message: "There was an error renaming your device. Please try again." };
}
@ -80,9 +83,9 @@ export default function DeviceIdRename() {
picture={user?.picture}
/>
<div className="w-full h-full">
<div className="h-full w-full">
<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">
<LinkButton
size="SM"
@ -100,7 +103,7 @@ export default function DeviceIdRename() {
<Fieldset>
<Form method="POST" className="max-w-sm space-y-4">
<div className="relative group">
<div className="group relative">
<InputFieldWithLabel
label="New device name"
type="text"

View File

@ -1,4 +1,5 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom";
import { getDeviceUiPath } from "../hooks/useAppNavigation";
export function loader({ params }: LoaderFunctionArgs) {

View File

@ -1,20 +1,22 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
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 notifications from "../notifications";
import { useCallback, useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { InputFieldWithLabel } from "../components/InputField";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsSectionHeader } from "../components/SettingsSectionHeader";
import { isOnDevice } from "../main";
import api from "@/api";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { GridCard } from "@/components/Card";
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";
export const loader = async () => {

View File

@ -1,9 +1,10 @@
import { useState, useEffect } from "react";
import { useLocation, useRevalidator } from "react-router-dom";
import { Button } from "@components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores";
import { useLocation, useRevalidator } from "react-router-dom";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function SecurityAccessLocalAuthRoute() {
@ -53,6 +54,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
setError(data.error || "An error occurred while setting the password");
}
} catch (error) {
console.error(error);
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");
}
} catch (error) {
console.error(error);
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");
}
} catch (error) {
console.error(error);
setError("An error occurred while disabling the password");
}
};

View File

@ -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 Checkbox from "../components/Checkbox";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { useCallback, useState, useEffect } from "react";
import notifications from "../notifications";
import { TextAreaWithLabel } from "../components/TextArea";
import { isOnDevice } from "../main";
import { Button } from "../components/Button";
import { useSettingsStore } from "../hooks/stores";
import { GridCard } from "@components/Card";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsAdvancedRoute() {
const [send] = useJsonRpc();

View File

@ -1,6 +1,8 @@
import { useCallback, useState } from "react";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsAppearanceRoute() {

View File

@ -1,15 +1,17 @@
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { useState } from "react";
import { useEffect } from "react";
import { useState , useEffect } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { Button } from "../components/Button";
import notifications from "../notifications";
import Checkbox from "../components/Checkbox";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { useDeviceStore } from "../hooks/stores";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsGeneralRoute() {
const [send] = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();

View File

@ -1,11 +1,12 @@
import { useLocation, useNavigate } from "react-router-dom";
import Card from "@/components/Card";
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 { Button } from "@components/Button";
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";

View File

@ -1,13 +1,14 @@
import { useEffect } from "react";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "@routes/devices.$id.settings";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { useEffect } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import notifications from "../notifications";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbInfoSetting } from "../components/UsbInfoSetting";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { FeatureFlag } from "../components/FeatureFlag";
export default function SettingsHardwareRoute() {

View File

@ -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 PointingFinger from "@/assets/pointing-finger.svg";
import { GridCard } from "@/components/Card";
@ -6,11 +9,11 @@ import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { CheckCircleIcon } from "@heroicons/react/16/solid";
import { useCallback, useEffect, useState } from "react";
import { FeatureFlag } from "../components/FeatureFlag";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { useFeatureFlag } from "../hooks/useFeatureFlag";
import { SettingsItem } from "./devices.$id.settings";
type ScrollSensitivity = "low" | "default" | "high";

View File

@ -1,5 +1,4 @@
import { NavLink, Outlet, useLocation } from "react-router-dom";
import Card from "@/components/Card";
import {
LuSettings,
LuKeyboard,
@ -10,8 +9,11 @@ import {
LuArrowLeft,
LuPalette,
} from "react-icons/lu";
import { LinkButton } from "../components/Button";
import React, { useEffect, useRef, useState } from "react";
import Card from "@/components/Card";
import { LinkButton } from "../components/Button";
import { cx } from "../cva.config";
import { useUiStore } from "../hooks/stores";
import useKeyboard from "../hooks/useKeyboard";

View File

@ -1,12 +1,14 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { useState, useEffect } from "react";
import { Button } from "@/components/Button";
import { TextAreaWithLabel } from "@/components/TextArea";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useState, useEffect } from "react";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "../notifications";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings";
const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
const edids = [

View File

@ -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 {
ActionFunctionArgs,
Form,
@ -12,12 +7,19 @@ import {
useParams,
useSearchParams,
} 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 { Button } from "@components/Button";
import { checkAuth } from "@/main";
import api from "../api";
import { CLOUD_API } from "@/ui.config";
import api from "../api";
const loader = async ({ params }: LoaderFunctionArgs) => {
await checkAuth();
const res = await fetch(`${CLOUD_API}/devices/${params.id}`, {

View File

@ -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 {
DeviceSettingsState,
@ -16,36 +33,28 @@ import {
VideoState,
} from "@/hooks/stores";
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 DashboardNavbar from "@components/Header";
import { useInterval } from "usehooks-ts";
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
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 { CLOUD_API, DEVICE_API } from "@/ui.config";
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
import api from "../api";
import Modal from "../components/Modal";
import { motion, AnimatePresence } from "motion/react";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import {
ConnectionFailedOverlay,
LoadingConnectionOverlay,
PeerConnectionDisconnectedOverlay,
} from "../components/VideoOverlay";
import { FeatureFlagProvider } from "../providers/FeatureFlagProvider";
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
import notifications from "../notifications";
import { DeviceStatus } from "./welcome-local";
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
interface LocalLoaderResp {
authMode: "password" | "noPassword" | null;
}
@ -110,7 +119,6 @@ const loader = async ({ params }: LoaderFunctionArgs) => {
export default function KvmIdRoute() {
const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp;
// Depending on the mode, we set the appropriate variables
const user = "user" in loaderResp ? loaderResp.user : null;
const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null;
@ -123,84 +131,347 @@ export default function KvmIdRoute() {
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
const peerConnection = useRTCStore(state => state.peerConnection);
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
const setPeerConnection = useRTCStore(state => state.setPeerConnection);
const setDiskChannel = useRTCStore(state => state.setDiskChannel);
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
const setTransceiver = useRTCStore(state => state.setTransceiver);
const location = useLocation();
const isLegacySignalingEnabled = useRef(false);
const [connectionFailed, setConnectionFailed] = useState(false);
const navigate = useNavigate();
const { otaState, setOtaState, setModalView } = useUpdateStore();
const sdp = useCallback(
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
if (!pc) return;
if (event.candidate !== null) return;
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
const cleanupAndStopReconnecting = useCallback(
function cleanupAndStopReconnecting() {
console.log("Closing peer connection");
try {
const sd = btoa(JSON.stringify(pc.localDescription));
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();
setConnectionFailed(true);
if (peerConnection) {
setPeerConnectionState(peerConnection.connectionState);
}
connectionFailedRef.current = true;
peerConnection?.close();
signalingAttempts.current = 0;
},
[navigate, params.id],
[peerConnection, setPeerConnectionState],
);
const connectWebRTC = useCallback(async () => {
console.log("Attempting to connect WebRTC");
const pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud
...(isInCloud && iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] }
: {}),
});
// We need to track connectionFailed in a ref to avoid stale closure issues
// This is necessary because syncRemoteSessionDescription is a callback that captures
// the connectionFailed value at creation time, but we need the latest value
// when the function is actually called. Without this ref, the function would use
// a stale value of connectionFailed in some conditions.
//
// 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: 15,
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...");
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
pc.onconnectionstatechange = () => {
console.log("[setupPeerConnection] Connection state changed", 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) {
setMediaMediaStream(event.streams[0]);
@ -218,16 +489,12 @@ export default function KvmIdRoute() {
setDiskChannel(diskDataChannel);
};
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
setPeerConnection(pc);
} catch (e) {
console.error(`Error creating offer: ${e}`);
}
setPeerConnection(pc);
}, [
cleanupAndStopReconnecting,
iceConfig?.iceServers,
sdp,
legacyHTTPSignaling,
sendWebRTCSignal,
setDiskChannel,
setMediaMediaStream,
setPeerConnection,
@ -236,23 +503,12 @@ export default function KvmIdRoute() {
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(() => {
if (peerConnection?.connectionState === undefined) {
connectWebRTC();
if (peerConnectionState === "failed") {
console.log("Connection failed, closing peer connection");
cleanupAndStopReconnecting();
}
}, [connectWebRTC, peerConnection?.connectionState]);
}, [peerConnectionState, cleanupAndStopReconnecting]);
// Cleanup effect
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
@ -277,7 +533,7 @@ export default function KvmIdRoute() {
// TURN server usage detection
useEffect(() => {
if (peerConnection?.connectionState !== "connected") return;
if (peerConnectionState !== "connected") return;
const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState();
const lastLocalStat = Array.from(localCandidateStats).pop();
@ -289,7 +545,7 @@ export default function KvmIdRoute() {
const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
}, [peerConnection?.connectionState, setIsTurnServerInUse]);
}, [peerConnectionState, setIsTurnServerInUse]);
// TURN server usage reporting
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
@ -380,10 +636,6 @@ export default function KvmIdRoute() {
});
}, [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
useEffect(() => {
if (queryParams.get("updateSuccess")) {
@ -420,18 +672,17 @@ export default function KvmIdRoute() {
useEffect(() => {
if (!peerConnection) return;
if (!kvmTerminal) {
console.log('Creating data channel "terminal"');
// console.log('Creating data channel "terminal"');
setKvmTerminal(peerConnection.createDataChannel("terminal"));
}
if (!serialConsole) {
console.log('Creating data channel "serial"');
// console.log('Creating data channel "serial"');
setSerialConsole(peerConnection.createDataChannel("serial"));
}
}, [kvmTerminal, peerConnection, serialConsole]);
const outlet = useOutlet();
const location = useLocation();
const onModalClose = useCallback(() => {
if (location.pathname !== "/other-session") navigateTo("/");
}, [navigateTo, location.pathname]);
@ -469,6 +720,43 @@ export default function KvmIdRoute() {
[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 (
<FeatureFlagProvider appVersion={appVersion}>
{!outlet && otaState.updating && (
@ -497,6 +785,7 @@ export default function KvmIdRoute() {
<button className="absolute top-0" tabIndex={-1} id="videoFocusTrap" />
</div>
</FocusTrap>
<div className="grid h-full select-none grid-rows-headerBody">
<DashboardNavbar
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
@ -507,15 +796,23 @@ export default function KvmIdRoute() {
kvmName={deviceName || "JetKVM Device"}
/>
<div className="flex h-full overflow-hidden">
<div className="relative flex h-full w-full overflow-hidden">
<WebRTCVideo />
<div
style={{ animationDuration: "500ms" }}
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center p-4 opacity-0"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{!!ConnectionStatusElement && ConnectionStatusElement}
</div>
</div>
<SidebarContainer sidebarView={sidebarView} />
</div>
</div>
</div>
<div
className="isolate"
className="z-50"
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
@ -523,7 +820,8 @@ export default function KvmIdRoute() {
}}
>
<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>
</div>

View File

@ -1,4 +1,6 @@
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 { LinkButton } from "@components/Button";
@ -7,8 +9,6 @@ import useInterval from "@/hooks/useInterval";
import { checkAuth } from "@/main";
import { User } from "@/hooks/stores";
import EmptyCard from "@components/EmptyCard";
import { LuMonitorSmartphone } from "react-icons/lu";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { CLOUD_API } from "@/ui.config";
interface LoaderData {
@ -49,8 +49,8 @@ export default function DevicesRoute() {
/>
<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="flex items-center justify-between pb-4 mt-8 border-b border-b-slate-800/20 dark:border-b-slate-300/20">
<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="mt-8 flex items-center justify-between border-b border-b-slate-800/20 pb-4 dark:border-b-slate-300/20">
<div>
<h1 className="text-xl font-bold text-black dark:text-white">
Cloud KVMs

View File

@ -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 GridBackground from "@components/GridBackground";
import Container from "@components/Container";
import Fieldset from "@components/Fieldset";
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button";
import { useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import LogoBlueIcon from "@/assets/logo-blue.png";
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 api from "../api";
import ExtLink from "../components/ExtLink";
import { DeviceStatus } from "./welcome-local";
const loader = async () => {
const res = await api
.GET(`${DEVICE_API}/device/status`)
@ -31,12 +34,9 @@ const action = async ({ request }: ActionFunctionArgs) => {
const password = formData.get("password");
try {
const response = await api.POST(
`${DEVICE_API}/auth/login-local`,
{
password,
},
);
const response = await api.POST(`${DEVICE_API}/auth/login-local`, {
password,
});
if (response.ok) {
return redirect("/");
@ -44,6 +44,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
return { error: "Invalid password" };
}
} catch (error) {
console.error(error);
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">
<SimpleNavbar />
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-2xl -mt-32 space-y-8">
<div className="isolate flex h-full w-full items-center justify-center">
<div className="-mt-32 max-w-2xl space-y-8">
<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" />
</div>
<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">
Enter your password to access your JetKVM.
</p>
</div>
<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">
<InputFieldWithLabel
label="Password"
@ -88,14 +95,14 @@ export default function LoginLocalRoute() {
onClick={() => setShowPassword(false)}
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
onClick={() => setShowPassword(true)}
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>
)
}
@ -111,7 +118,7 @@ export default function LoginLocalRoute() {
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
href="https://jetkvm.com/docs/networking/local-access#reset-password"
className="hover:underline"

View File

@ -1,6 +1,7 @@
import AuthLayout from "@components/AuthLayout";
import { useLocation, useSearchParams } from "react-router-dom";
import AuthLayout from "@components/AuthLayout";
export default function LoginRoute() {
const [sq] = useSearchParams();
const location = useLocation();

View File

@ -1,6 +1,7 @@
import AuthLayout from "@components/AuthLayout";
import { useLocation, useSearchParams } from "react-router-dom";
import AuthLayout from "@components/AuthLayout";
export default function SignupRoute() {
const [sq] = useSearchParams();
const location = useLocation();

Some files were not shown because too many files have changed in this diff Show More