release 0.3.9 (#337)

This commit is contained in:
Aveline 2025-04-09 19:57:56 +02:00 committed by GitHub
commit e748346e2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
101 changed files with 2965 additions and 1644 deletions

View File

@ -12,6 +12,7 @@ jobs:
build: build:
runs-on: buildjet-4vcpu-ubuntu-2204 runs-on: buildjet-4vcpu-ubuntu-2204
name: Build name: Build
if: "github.event.review.state == 'approved' || github.event.event_type != 'pull_request_review'"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -19,12 +20,12 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: v21.1.0 node-version: v21.1.0
cache: 'npm' cache: "npm"
cache-dependency-path: '**/package-lock.json' cache-dependency-path: "**/package-lock.json"
- name: Set up Golang - name: Set up Golang
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: '1.24.0' go-version: "1.24.0"
- name: Build frontend - name: Build frontend
run: | run: |
make frontend make frontend
@ -36,107 +37,3 @@ jobs:
with: with:
name: jetkvm-app name: jetkvm-app
path: bin/jetkvm_app path: bin/jetkvm_app
deploy_and_test:
runs-on: buildjet-4vcpu-ubuntu-2204
name: Smoke test
needs: build
concurrency:
group: smoketest-jk
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: jetkvm-app
- name: Configure WireGuard and check connectivity
run: |
WG_KEY_FILE=$(mktemp)
echo -n "$CI_WG_PRIVATE" > $WG_KEY_FILE && \
sudo apt-get update && sudo apt-get install -y wireguard-tools && \
sudo ip link add dev wg-ci type wireguard && \
sudo ip addr add $CI_WG_IPS dev wg-ci && \
sudo wg set wg-ci listen-port 51820 \
private-key $WG_KEY_FILE \
peer $CI_WG_PUBLIC \
allowed-ips $CI_WG_ALLOWED_IPS \
endpoint $CI_WG_ENDPOINT \
persistent-keepalive 15 && \
sudo ip link set up dev wg-ci && \
sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci
ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1)
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }}
CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }}
CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }}
CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }}
CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }}
CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }}
- name: Configure SSH
run: |
# Write SSH private key to a file
SSH_PRIVATE_KEY=$(mktemp)
echo "$CI_SSH_PRIVATE" > $SSH_PRIVATE_KEY
chmod 0600 $SSH_PRIVATE_KEY
# Configure SSH
mkdir -p ~/.ssh
cat <<EOF >> ~/.ssh/config
Host jkci
HostName $CI_HOST
User $CI_USER
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile $SSH_PRIVATE_KEY
EOF
env:
CI_USER: ${{ vars.JETKVM_CI_USER }}
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
- name: Deploy application
run: |
set -e
# Copy the binary to the remote host
echo "+ Copying the application to the remote host"
cat jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
# Deploy and run the application on the remote host
echo "+ Deploying the application on the remote host"
ssh jkci ash <<EOF
# Extract the binary
gzip -d /userdata/jetkvm/jetkvm_app.update.gz
# Flush filesystem buffers to ensure all data is written to disk
sync
# Clear the filesystem caches to force a read from disk
echo 1 > /proc/sys/vm/drop_caches
# Reboot the application
reboot -d 5 -f &
EOF
sleep 10
echo "Deployment complete, waiting for JetKVM to come back online "
function check_online() {
for i in {1..60}; do
if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then
echo "JetKVM is back online"
return 0
fi
echo -n "."
sleep 1
done
echo "JetKVM did not come back online within 60 seconds"
return 1
}
check_online
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Run smoke tests
run: |
echo "+ Checking the status of the device"
curl -v http://$CI_HOST/device/status && echo
echo "+ Collecting logs"
ssh jkci "cat /userdata/jetkvm/last.log" > last.log
cat last.log
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Upload logs
uses: actions/upload-artifact@v4
with:
name: device-logs
path: last.log

View File

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

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: linters:
enable: enable:
# - goimports - forbidigo
# - misspell - goimports
- misspell
# - revive # - revive
- whitespace
issues: issues:
exclude-rules: exclude-rules:
- path: _test.go - path: _test.go
linters: linters:
- errcheck - errcheck
linters-settings:
forbidigo:
forbid:
- p: ^fmt\.Print.*$
msg: Do not commit print statements. Use logger package.
- p: ^log\.(Fatal|Panic|Print)(f|ln)?.*$
msg: Do not commit log statements. Use logger package.

View File

@ -23,7 +23,7 @@ We welcome contributions from the community! Whether it's improving the firmware
## I need help ## I need help
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW). The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://jetkvm.com/discord).
## I want to report an issue ## I want to report an issue

248
cloud.go
View File

@ -7,9 +7,12 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"sync"
"time" "time"
"github.com/coder/websocket/wsjson" "github.com/coder/websocket/wsjson"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
@ -32,10 +35,118 @@ const (
// CloudOidcRequestTimeout is the timeout for OIDC token verification requests // CloudOidcRequestTimeout is the timeout for OIDC token verification requests
// should be lower than the websocket response timeout set in cloud-api // should be lower than the websocket response timeout set in cloud-api
CloudOidcRequestTimeout = 10 * time.Second CloudOidcRequestTimeout = 10 * time.Second
// CloudWebSocketPingInterval is the interval at which the websocket client sends ping messages to the cloud // WebsocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
CloudWebSocketPingInterval = 15 * time.Second WebsocketPingInterval = 15 * time.Second
) )
var (
metricCloudConnectionStatus = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_status",
Help: "The status of the cloud connection",
},
)
metricCloudConnectionEstablishedTimestamp = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_established_timestamp",
Help: "The timestamp when the cloud connection was established",
},
)
metricConnectionLastPingTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_timestamp",
Help: "The timestamp when the last ping response was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_duration",
Help: "The duration of the last ping response",
},
[]string{"type", "source"},
)
metricConnectionPingDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_ping_duration",
Help: "The duration of the ping response",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
[]string{"type", "source"},
)
metricConnectionTotalPingCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_count",
Help: "The total number of pings sent to the connection",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_session_total_request_count",
Help: "The total number of session requests received",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_session_request_duration",
Help: "The duration of session requests",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
[]string{"type", "source"},
)
metricConnectionLastSessionRequestTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_timestamp",
Help: "The timestamp of the last session request",
},
[]string{"type", "source"},
)
metricConnectionLastSessionRequestDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_duration",
Help: "The duration of the last session request",
},
[]string{"type", "source"},
)
metricCloudConnectionFailureCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_cloud_connection_failure_count",
Help: "The number of times the cloud connection has failed",
},
)
)
var (
cloudDisconnectChan chan error
cloudDisconnectLock = &sync.Mutex{}
)
func wsResetMetrics(established bool, sourceType string, source string) {
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(-1)
if sourceType != "cloud" {
return
}
if established {
metricCloudConnectionEstablishedTimestamp.SetToCurrentTime()
metricCloudConnectionStatus.Set(1)
} else {
metricCloudConnectionEstablishedTimestamp.Set(-1)
metricCloudConnectionStatus.Set(-1)
}
}
func handleCloudRegister(c *gin.Context) { func handleCloudRegister(c *gin.Context) {
var req CloudRegisterRequest var req CloudRegisterRequest
@ -90,11 +201,6 @@ func handleCloudRegister(c *gin.Context) {
return return
} }
if config.CloudToken == "" {
cloudLogger.Info("Starting websocket client due to adoption")
go RunWebsocketClient()
}
config.CloudToken = tokenResp.SecretToken config.CloudToken = tokenResp.SecretToken
provider, err := oidc.NewProvider(c, "https://accounts.google.com") provider, err := oidc.NewProvider(c, "https://accounts.google.com")
@ -125,24 +231,47 @@ func handleCloudRegister(c *gin.Context) {
c.JSON(200, gin.H{"message": "Cloud registration successful"}) c.JSON(200, gin.H{"message": "Cloud registration successful"})
} }
func disconnectCloud(reason error) {
cloudDisconnectLock.Lock()
defer cloudDisconnectLock.Unlock()
if cloudDisconnectChan == nil {
cloudLogger.Tracef("cloud disconnect channel is not set, no need to disconnect")
return
}
// just in case the channel is closed, we don't want to panic
defer func() {
if r := recover(); r != nil {
cloudLogger.Infof("cloud disconnect channel is closed, no need to disconnect: %v", r)
}
}()
cloudDisconnectChan <- reason
}
func runWebsocketClient() error { func runWebsocketClient() error {
if config.CloudToken == "" { if config.CloudToken == "" {
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
return fmt.Errorf("cloud token is not set") return fmt.Errorf("cloud token is not set")
} }
wsURL, err := url.Parse(config.CloudURL) wsURL, err := url.Parse(config.CloudURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse config.CloudURL: %w", err) return fmt.Errorf("failed to parse config.CloudURL: %w", err)
} }
if wsURL.Scheme == "http" { if wsURL.Scheme == "http" {
wsURL.Scheme = "ws" wsURL.Scheme = "ws"
} else { } else {
wsURL.Scheme = "wss" wsURL.Scheme = "wss"
} }
header := http.Header{} header := http.Header{}
header.Set("X-Device-ID", GetDeviceID()) header.Set("X-Device-ID", GetDeviceID())
header.Set("X-App-Version", builtAppVersion)
header.Set("Authorization", "Bearer "+config.CloudToken) header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout) dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
defer cancelDial() defer cancelDial()
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{ c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header, HTTPHeader: header,
@ -152,47 +281,15 @@ func runWebsocketClient() error {
} }
defer c.CloseNow() //nolint:errcheck defer c.CloseNow() //nolint:errcheck
cloudLogger.Infof("websocket connected to %s", wsURL) cloudLogger.Infof("websocket connected to %s", wsURL)
runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun() // set the metrics when we successfully connect to the cloud.
go func() { wsResetMetrics(true, "cloud", "")
for {
time.Sleep(CloudWebSocketPingInterval) // we don't have a source for the cloud connection
err := c.Ping(runCtx) return handleWebRTCSignalWsMessages(c, true, "")
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) func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
cloudLogger.Tracef("session request info: %v", req)
err = handleSessionRequest(runCtx, c, req)
if err != nil {
cloudLogger.Infof("error starting new session: %v", err)
continue
}
}
}
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout) oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
defer cancelOIDC() defer cancelOIDC()
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com") provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
@ -220,10 +317,35 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
return fmt.Errorf("google identity mismatch") return fmt.Errorf("google identity mismatch")
} }
return nil
}
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest, isCloudConnection bool, source string) error {
var sourceType string
if isCloudConnection {
sourceType = "cloud"
} else {
sourceType = "local"
}
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v)
metricConnectionSessionRequestDuration.WithLabelValues(sourceType, source).Observe(v)
}))
defer timer.ObserveDuration()
// If the message is from the cloud, we need to authenticate the session.
if isCloudConnection {
if err := authenticateSession(ctx, c, req); err != nil {
return err
}
}
session, err := newSession(SessionConfig{ session, err := newSession(SessionConfig{
ICEServers: req.ICEServers, ws: c,
IsCloud: isCloudConnection,
LocalIP: req.IP, LocalIP: req.IP,
IsCloud: true, ICEServers: req.ICEServers,
}) })
if err != nil { if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err}) _ = wsjson.Write(context.Background(), c, gin.H{"error": err})
@ -247,15 +369,40 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
cloudLogger.Info("new session accepted") cloudLogger.Info("new session accepted")
cloudLogger.Tracef("new session accepted: %v", session) cloudLogger.Tracef("new session accepted: %v", session)
currentSession = session currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"sd": sd}) _ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
return nil return nil
} }
func RunWebsocketClient() { func RunWebsocketClient() {
for { for {
// reset the metrics when we start the websocket client.
wsResetMetrics(false, "cloud", "")
// If the cloud token is not set, we don't need to run the websocket client.
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
continue
}
// If the network is not up, well, we can't connect to the cloud.
if !networkState.Up {
cloudLogger.Warn("waiting for network to be up, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
// If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail.
if isTimeSyncNeeded() && !timeSyncSuccess {
cloudLogger.Warn("system time is not synced, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
err := runWebsocketClient() err := runWebsocketClient()
if err != nil { if err != nil {
cloudLogger.Errorf("websocket client error: %v", err) cloudLogger.Errorf("websocket client error: %v", err)
metricCloudConnectionStatus.Set(0)
metricCloudConnectionFailureCount.Inc()
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
} }
} }
@ -305,6 +452,9 @@ func rpcDeregisterDevice() error {
return fmt.Errorf("failed to save configuration after deregistering: %w", err) return fmt.Errorf("failed to save configuration after deregistering: %w", err)
} }
cloudLogger.Infof("device deregistered, disconnecting from cloud")
disconnectCloud(fmt.Errorf("device deregistered"))
return nil return nil
} }

View File

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

1
log.go
View File

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

View File

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

33
ntp.go
View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os/exec" "os/exec"
"strconv"
"time" "time"
"github.com/beevik/ntp" "github.com/beevik/ntp"
@ -20,13 +21,41 @@ const (
) )
var ( var (
builtTimestamp string
timeSyncRetryInterval = 0 * time.Second timeSyncRetryInterval = 0 * time.Second
timeSyncSuccess = false
defaultNTPServers = []string{ defaultNTPServers = []string{
"time.cloudflare.com", "time.cloudflare.com",
"time.apple.com", "time.apple.com",
} }
) )
func isTimeSyncNeeded() bool {
if builtTimestamp == "" {
logger.Warnf("Built timestamp is not set, time sync is needed")
return true
}
ts, err := strconv.Atoi(builtTimestamp)
if err != nil {
logger.Warnf("Failed to parse built timestamp: %v", err)
return true
}
// builtTimestamp is UNIX timestamp in seconds
builtTime := time.Unix(int64(ts), 0)
now := time.Now()
logger.Tracef("Built time: %v, now: %v", builtTime, now)
if now.Sub(builtTime) < 0 {
logger.Warnf("System time is behind the built time, time sync is needed")
return true
}
return false
}
func TimeSyncLoop() { func TimeSyncLoop() {
for { for {
if !networkState.checked { if !networkState.checked {
@ -40,6 +69,9 @@ func TimeSyncLoop() {
continue continue
} }
// check if time sync is needed, but do nothing for now
isTimeSyncNeeded()
logger.Infof("Syncing system time") logger.Infof("Syncing system time")
start := time.Now() start := time.Now()
err := SyncSystemTime() err := SyncSystemTime()
@ -56,6 +88,7 @@ func TimeSyncLoop() {
continue continue
} }
timeSyncSuccess = true
logger.Infof("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start)) logger.Infof("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))
time.Sleep(timeSyncInterval) // after the first sync is done time.Sleep(timeSyncInterval) // after the first sync is done
} }

10
ota.go
View File

@ -126,7 +126,15 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return fmt.Errorf("error creating request: %w", err) return fmt.Errorf("error creating request: %w", err)
} }
resp, err := http.DefaultClient.Do(req) client := http.Client{
// allow a longer timeout for the download but keep the TLS handshake short
Timeout: 10 * time.Minute,
Transport: &http.Transport{
TLSHandshakeTimeout: 1 * time.Minute,
},
}
resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("error downloading file: %w", err) return fmt.Errorf("error downloading file: %w", err)
} }

View File

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

View File

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

View File

@ -8,6 +8,8 @@ module.exports = {
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"plugin:react/recommended", "plugin:react/recommended",
"plugin:react/jsx-runtime", "plugin:react/jsx-runtime",
"plugin:import/recommended",
"prettier",
], ],
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"], ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
@ -20,5 +22,45 @@ module.exports = {
}, },
rules: { rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"import/order": [
"error",
{
/**
* @description
*
* This keeps imports separate from one another, ensuring that imports are separated
* by their relative groups. As you move through the groups, imports become closer
* to the current file.
*
* @example
* ```
* import fs from 'fs';
*
* import package from 'npm-package';
*
* import xyz from '~/project-file';
*
* import index from '../';
*
* import sibling from './foo';
* ```
*/
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
},
],
},
settings: {
"import/resolver": {
alias: {
map: [
["@components", "./src/components"],
["@routes", "./src/routes"],
["@assets", "./src/assets"],
["@", "./src"],
],
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
},
},
}, },
}; };

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,16 @@
import { GridCard } from "@/components/Card";
import React from "react"; import React from "react";
import { GridCard } from "@/components/Card";
import { cx } from "../cva.config"; import { cx } from "../cva.config";
type Props = { interface Props {
IconElm?: React.FC<any>; IconElm?: React.FC<{ className: string | undefined }>;
headline: string; headline: string;
description?: string | React.ReactNode; description?: string | React.ReactNode;
BtnElm?: React.ReactNode; BtnElm?: React.ReactNode;
className?: string; className?: string;
}; }
export default function EmptyCard({ export default function EmptyCard({
IconElm, IconElm,
@ -27,10 +29,16 @@ export default function EmptyCard({
> >
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]"> <div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
<div className="space-y-2"> <div className="space-y-2">
{IconElm && <IconElm className="w-6 h-6 mx-auto text-blue-600 dark:text-blue-400" />} {IconElm && (
<h4 className="text-base font-bold leading-none text-black dark:text-white">{headline}</h4> <IconElm className="mx-auto h-6 w-6 text-blue-600 dark:text-blue-400" />
)}
<h4 className="text-base font-bold leading-none text-black dark:text-white">
{headline}
</h4>
</div> </div>
<p className="mx-auto text-sm text-slate-600 dark:text-slate-400">{description}</p> <p className="mx-auto text-sm text-slate-600 dark:text-slate-400">
{description}
</p>
</div> </div>
{BtnElm} {BtnElm}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import type { Ref } from "react"; import type { Ref } from "react";
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx"; import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { cva } from "@/cva.config"; import { cva } from "@/cva.config";
@ -84,7 +85,7 @@ const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProp
{(label || description) && ( {(label || description) && (
<FieldLabel label={label} id={id} description={description} /> <FieldLabel label={label} id={id} description={description} />
)} )}
<InputField ref={ref as any} id={id} {...props} /> <InputField ref={ref as never} id={id} {...props} />
</div> </div>
); );
}, },

View File

@ -1,10 +1,11 @@
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { MdConnectWithoutContact } from "react-icons/md"; import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { LuEllipsisVertical } from "react-icons/lu"; import { LuEllipsisVertical } from "react-icons/lu";
import Card from "@components/Card";
import { Button, LinkButton } from "@components/Button";
function getRelativeTimeString(date: Date | number, lang = navigator.language): string { function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
// Allow dates or times to be passed // Allow dates or times to be passed
const timeMs = typeof date === "number" ? date : date.getTime(); const timeMs = typeof date === "number" ? date : date.getTime();

View File

@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react"; import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
const Modal = React.memo(function Modal({ const Modal = React.memo(function Modal({

View File

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

View File

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

View File

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

View File

@ -1,10 +1,11 @@
import Container from "@/components/Container";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import React from "react"; import React from "react";
import Container from "@/components/Container";
import LogoBlueIcon from "@/assets/logo-blue.png"; import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
type Props = { logoHref?: string; actionElement?: React.ReactNode }; interface Props { logoHref?: string; actionElement?: React.ReactNode }
export default function SimpleNavbar({ logoHref, actionElement }: Props) { export default function SimpleNavbar({ logoHref, actionElement }: Props) {
return ( return (

View File

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

View File

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

View File

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

View File

@ -1,8 +1,5 @@
import "react-simple-keyboard/build/css/index.css"; import "react-simple-keyboard/build/css/index.css";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
import { Button } from "./Button";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { cx } from "@/cva.config";
import { useEffect } from "react"; import { useEffect } from "react";
import { useXTerm } from "react-xtermjs"; import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
@ -11,6 +8,11 @@ import { WebglAddon } from "@xterm/addon-webgl";
import { Unicode11Addon } from "@xterm/addon-unicode11"; import { Unicode11Addon } from "@xterm/addon-unicode11";
import { ClipboardAddon } from "@xterm/addon-clipboard"; import { ClipboardAddon } from "@xterm/addon-clipboard";
import { cx } from "@/cva.config";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
import { Button } from "./Button";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2"); const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Terminal theme configuration // Terminal theme configuration

View File

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

View File

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

View File

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

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

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

View File

@ -1,10 +1,12 @@
import React from "react"; import React from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { LinkButton } from "@components/Button"; import { motion, AnimatePresence } from "framer-motion";
import { LuPlay } from "react-icons/lu";
import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { motion, AnimatePresence } from "motion/react";
interface OverlayContentProps { interface OverlayContentProps {
children: React.ReactNode; children: React.ReactNode;
@ -23,18 +25,18 @@ interface LoadingOverlayProps {
show: boolean; show: boolean;
} }
export function LoadingOverlay({ show }: LoadingOverlayProps) { export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {show && (
<motion.div <motion.div
className="absolute inset-0 aspect-video h-full w-full" className="aspect-video h-full w-full"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
duration: show ? 0.3 : 0.1, duration: show ? 0.3 : 0.1,
ease: "easeInOut" ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
@ -53,22 +55,60 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) {
); );
} }
interface ConnectionErrorOverlayProps { interface LoadingConnectionOverlayProps {
show: boolean; show: boolean;
text: string;
} }
export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverlayProps) {
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {show && (
<motion.div <motion.div
className="absolute inset-0 z-10 aspect-video h-full w-full" className="aspect-video h-full w-full"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{ transition={{
duration: 0.3, duration: 0.4,
ease: "easeInOut" ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-center justify-center gap-y-1">
<div className="animate flex h-12 w-12 items-center justify-center">
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
{text}
</p>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}
interface ConnectionErrorOverlayProps {
show: boolean;
setupPeerConnection: () => Promise<void>;
}
export function ConnectionFailedOverlay({
show,
setupPeerConnection,
}: ConnectionErrorOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.4,
ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
@ -85,14 +125,75 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
<li>Try restarting both the device and your computer</li> <li>Try restarting both the device and your computer</li>
</ul> </ul>
</div> </div>
<div> <div className="flex items-center gap-x-2">
<LinkButton <LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"} to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light" theme="primary"
text="Troubleshooting Guide" text="Troubleshooting Guide"
TrailingIcon={ArrowRightIcon} TrailingIcon={ArrowRightIcon}
size="SM" size="SM"
/> />
<Button
onClick={() => setupPeerConnection()}
LeadingIcon={ArrowPathIcon}
text="Try again"
size="SM"
theme="light"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}
interface PeerConnectionDisconnectedOverlay {
show: boolean;
}
export function PeerConnectionDisconnectedOverlay({
show,
}: PeerConnectionDisconnectedOverlay) {
return (
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.4,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
</ul>
</div>
<div className="flex items-center gap-x-2">
<Card>
<div className="flex items-center gap-x-2 p-4">
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
<p className="text-sm text-slate-700 dark:text-slate-300">
Retrying connection...
</p>
</div>
</Card>
</div> </div>
</div> </div>
</div> </div>
@ -118,25 +219,27 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<AnimatePresence> <AnimatePresence>
{show && isNoSignal && ( {show && isNoSignal && (
<motion.div <motion.div
className="absolute inset-0 w-full h-full aspect-video" className="absolute inset-0 aspect-video h-full w-full"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
duration: 0.3, duration: 0.3,
ease: "easeInOut" ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
<div className="flex flex-col items-start gap-y-1"> <div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> <ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300"> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2> <h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<ul className="list-disc space-y-2 pl-4 text-left"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>Ensure the HDMI cable securely connected at both ends</li> <li>Ensure the HDMI cable securely connected at both ends</li>
<li>Ensure source device is powered on and outputting a signal</li> <li>
Ensure source device is powered on and outputting a signal
</li>
<li> <li>
If using an adapter, it&apos;s compatible and functioning If using an adapter, it&apos;s compatible and functioning
correctly correctly
@ -169,7 +272,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
duration: 0.3, duration: 0.3,
ease: "easeInOut" ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
@ -187,7 +290,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</div> </div>
<div> <div>
<LinkButton <LinkButton
to={"/help/hdmi-error"} to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light" theme="light"
text="Learn more" text="Learn more"
TrailingIcon={ArrowRightIcon} TrailingIcon={ArrowRightIcon}
@ -204,3 +307,54 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</> </>
); );
} }
interface NoAutoplayPermissionsOverlayProps {
show: boolean;
onPlayClick: () => void;
}
export function NoAutoplayPermissionsOverlay({
show,
onPlayClick,
}: NoAutoplayPermissionsOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="absolute inset-0 z-10 aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="space-y-4">
<h2 className="text-2xl font-extrabold text-black dark:text-white">
Autoplay permissions required
</h2>
<div className="space-y-2 text-center">
<div>
<Button
size="MD"
theme="primary"
LeadingIcon={LuPlay}
text="Manually start stream"
onClick={onPlayClick}
/>
</div>
<div className="text-xs text-slate-600 dark:text-slate-400">
Please adjust browser settings to enable autoplay
</div>
</div>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -1,11 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard"; import Keyboard from "react-simple-keyboard";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion";
import Card from "@components/Card";
// eslint-disable-next-line import/order
import { Button } from "@components/Button";
import "react-simple-keyboard/build/css/index.css"; import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useUiStore } from "@/hooks/stores";
import { motion, AnimatePresence } from "motion/react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
useDeviceSettingsStore, useDeviceSettingsStore,
useHidStore, useHidStore,
@ -15,9 +16,12 @@ import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar"; import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { HDMIErrorOverlay } from "./VideoOverlay";
import { ConnectionErrorOverlay } from "./VideoOverlay"; import {
import { LoadingOverlay } from "./VideoOverlay"; HDMIErrorOverlay,
LoadingVideoOverlay,
NoAutoplayPermissionsOverlay,
} from "./VideoOverlay";
export default function WebRTCVideo() { export default function WebRTCVideo() {
// Video and stream related refs and states // Video and stream related refs and states
@ -41,15 +45,13 @@ export default function WebRTCVideo() {
// RTC related states // RTC related states
const peerConnection = useRTCStore(state => state.peerConnection); const peerConnection = useRTCStore(state => state.peerConnection);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// HDMI and UI states // HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState); const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isLoading = !hdmiError && !isPlaying; const isVideoLoading = !isPlaying;
const isConnectionError = ["error", "failed", "disconnected"].includes(
peerConnectionState || "", // console.log("peerConnection?.connectionState", peerConnection?.connectionState);
);
// Keyboard related states // Keyboard related states
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } = const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
@ -82,19 +84,19 @@ export default function WebRTCVideo() {
const onVideoPlaying = useCallback(() => { const onVideoPlaying = useCallback(() => {
setIsPlaying(true); setIsPlaying(true);
videoElm.current && updateVideoSizeStore(videoElm.current); if (videoElm.current) updateVideoSizeStore(videoElm.current);
}, [updateVideoSizeStore]); }, [updateVideoSizeStore]);
// On mount, get the video size // On mount, get the video size
useEffect( useEffect(
function updateVideoSizeOnMount() { function updateVideoSizeOnMount() {
videoElm.current && updateVideoSizeStore(videoElm.current); if (videoElm.current) updateVideoSizeStore(videoElm.current);
}, },
[setVideoClientSize, updateVideoSizeStore, setVideoSize], [setVideoClientSize, updateVideoSizeStore, setVideoSize],
); );
// Mouse-related // Mouse-related
const calcDelta = (pos: number) => Math.abs(pos) < 10 ? pos * 2 : pos; const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
const sendRelMouseMovement = useCallback( const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => { (x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "relative") return; if (settings.mouseMode !== "relative") return;
@ -168,7 +170,14 @@ export default function WebRTCVideo() {
const { buttons } = e; const { buttons } = e;
sendAbsMouseMovement(x, y, buttons); sendAbsMouseMovement(x, y, buttons);
}, },
[sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode], [
sendAbsMouseMovement,
videoClientHeight,
videoClientWidth,
videoWidth,
videoHeight,
settings.mouseMode,
],
); );
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity); const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
@ -355,7 +364,68 @@ export default function WebRTCVideo() {
], ],
); );
// Effect hooks const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video
// there is no way to prevent this, so we need to simply force play the video when it's paused.
// Fix only works in chrome based browsers.
if (e.code === "Space") {
if (videoElm.current?.paused == true) {
console.log("Force playing video");
videoElm.current?.play();
}
}
}, []);
const addStreamToVideoElm = useCallback(
(mediaStream: MediaStream) => {
if (!videoElm.current) return;
const videoElmRefValue = videoElm.current;
// console.log("Adding stream to video element", videoElmRefValue);
videoElmRefValue.srcObject = mediaStream;
updateVideoSizeStore(videoElmRefValue);
},
[updateVideoSizeStore],
);
useEffect(
function updateVideoStreamOnNewTrack() {
if (!peerConnection) return;
const abortController = new AbortController();
const signal = abortController.signal;
peerConnection.addEventListener(
"track",
(e: RTCTrackEvent) => {
// console.log("Adding stream to video element");
addStreamToVideoElm(e.streams[0]);
},
{ signal },
);
return () => {
abortController.abort();
};
},
[addStreamToVideoElm, peerConnection],
);
useEffect(
function updateVideoStream() {
if (!mediaStream) return;
console.log("Updating video stream from mediaStream");
// We set the as early as possible
addStreamToVideoElm(mediaStream);
},
[
setVideoClientSize,
mediaStream,
updateVideoSizeStore,
peerConnection,
addStreamToVideoElm,
],
);
// Setup Keyboard Events
useEffect( useEffect(
function setupKeyboardEvents() { function setupKeyboardEvents() {
const abortController = new AbortController(); const abortController = new AbortController();
@ -377,47 +447,23 @@ export default function WebRTCVideo() {
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent], [keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
); );
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { // Setup Video Event Listeners
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video
// there is no way to prevent this, so we need to simply force play the video when it's paused.
// Fix only works in chrome based browsers.
if (e.code === "Space") {
if (videoElm.current?.paused == true) {
console.log("Force playing video");
videoElm.current?.play();
}
}
}, []);
useEffect( useEffect(
function setupVideoEventListeners() { function setupVideoEventListeners() {
let videoElmRefValue = null; const videoElmRefValue = videoElm.current;
if (!videoElm.current) return; if (!videoElmRefValue) return;
videoElmRefValue = videoElm.current;
const abortController = new AbortController(); const abortController = new AbortController();
const signal = abortController.signal; const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal }); // To prevent the video from being paused when the user presses a space in fullscreen mode
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal, // We need to know when the video is playing to update state and video size
passive: true,
});
videoElmRefValue.addEventListener(
"contextmenu",
(e: MouseEvent) => e.preventDefault(),
{ signal },
);
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal }); videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
return () => { return () => {
if (videoElmRefValue) abortController.abort(); abortController.abort();
}; };
}, },
[ [
@ -429,6 +475,41 @@ export default function WebRTCVideo() {
], ],
); );
// Setup Absolute Mouse Events
useEffect(
function setAbsoluteMouseModeEventListeners() {
const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return;
if (settings.mouseMode !== "absolute") return;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
// Reset the mouse position when the window is blurred or the document is hidden
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal });
return () => {
abortController.abort();
};
},
[absMouseMoveHandler, mouseWheelHandler, resetMousePosition, settings.mouseMode],
);
// Setup Relative Mouse Events
const containerRef = useRef<HTMLDivElement>(null);
useEffect( useEffect(
function setupRelativeMouseEventListeners() { function setupRelativeMouseEventListeners() {
if (settings.mouseMode !== "relative") return; if (settings.mouseMode !== "relative") return;
@ -436,50 +517,43 @@ export default function WebRTCVideo() {
const abortController = new AbortController(); const abortController = new AbortController();
const signal = abortController.signal; const signal = abortController.signal;
// bind to body to capture all mouse events // We bind to the larger container in relative mode because of delta between the acceleration of the local
const body = document.querySelector("body"); // mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use.
if (!body) return; // When we get Pointer Lock support, we can remove this.
const containerElm = containerRef.current;
if (!containerElm) return;
body.addEventListener("mousemove", relMouseMoveHandler, { signal }); containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal });
body.addEventListener("pointerdown", relMouseMoveHandler, { signal }); containerElm.addEventListener("pointerdown", relMouseMoveHandler, { signal });
body.addEventListener("pointerup", relMouseMoveHandler, { signal }); containerElm.addEventListener("pointerup", relMouseMoveHandler, { signal });
containerElm.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
containerElm.addEventListener("contextmenu", preventContextMenu, { signal });
return () => { return () => {
abortController.abort(); abortController.abort();
body.removeEventListener("mousemove", relMouseMoveHandler);
body.removeEventListener("pointerdown", relMouseMoveHandler);
body.removeEventListener("pointerup", relMouseMoveHandler);
}; };
}, [settings.mouseMode, relMouseMoveHandler],
)
useEffect(
function updateVideoStream() {
if (!mediaStream) return;
if (!videoElm.current) return;
if (peerConnection?.iceConnectionState !== "connected") return;
setTimeout(() => {
if (videoElm?.current) {
videoElm.current.srcObject = mediaStream;
}
}, 0);
updateVideoSizeStore(videoElm.current);
}, },
[ [settings.mouseMode, relMouseMoveHandler, mouseWheelHandler],
setVideoClientSize,
setVideoSize,
mediaStream,
updateVideoSizeStore,
peerConnection?.iceConnectionState,
],
); );
const hasNoAutoPlayPermissions = useMemo(() => {
if (peerConnection?.connectionState !== "connected") return false;
if (isPlaying) return false;
if (hdmiError) return false;
if (videoHeight === 0 || videoWidth === 0) return false;
return true;
}, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]);
return ( return (
<div className="grid h-full w-full grid-rows-layout"> <div className="grid h-full w-full grid-rows-layout">
<div className="min-h-[39.5px]"> <div className="min-h-[39.5px]">
<fieldset disabled={peerConnectionState !== "connected"}> <fieldset disabled={peerConnection?.connectionState !== "connected"}>
<Actionbar <Actionbar
requestFullscreen={async () => requestFullscreen={async () =>
videoElm.current?.requestFullscreen({ videoElm.current?.requestFullscreen({
@ -490,7 +564,12 @@ export default function WebRTCVideo() {
</fieldset> </fieldset>
</div> </div>
<div className="h-full overflow-hidden"> <div
ref={containerRef}
className={cx("h-full overflow-hidden", {
"cursor-none": settings.mouseMode === "relative" && settings.isCursorHidden,
})}
>
<div className="relative h-full"> <div className="relative h-full">
<div <div
className={cx( className={cx(
@ -519,23 +598,32 @@ export default function WebRTCVideo() {
className={cx( className={cx(
"outline-50 max-h-full max-w-full object-contain transition-all duration-1000", "outline-50 max-h-full max-w-full object-contain transition-all duration-1000",
{ {
"cursor-none": settings.isCursorHidden, "cursor-none":
"opacity-0": isLoading || isConnectionError || hdmiError, settings.mouseMode === "absolute" &&
settings.isCursorHidden,
"opacity-0": isVideoLoading || hdmiError,
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20": "animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
isPlaying, isPlaying,
}, },
)} )}
/> />
{peerConnection?.connectionState == "connected" && (
<div <div
style={{ animationDuration: "500ms" }} style={{ animationDuration: "500ms" }}
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0" 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"> <div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<LoadingOverlay show={isLoading} /> <LoadingVideoOverlay show={isVideoLoading} />
<ConnectionErrorOverlay show={isConnectionError} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} /> <HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
}}
/>
</div> </div>
</div> </div>
)}
</div> </div>
</div> </div>
<VirtualKeyboard /> <VirtualKeyboard />

View File

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

View File

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

View File

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

View File

@ -1,19 +1,20 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { Button } from "../Button";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl"; import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
import { DCPowerControl } from "@components/extensions/DCPowerControl"; import { DCPowerControl } from "@components/extensions/DCPowerControl";
import { SerialConsole } from "@components/extensions/SerialConsole"; import { SerialConsole } from "@components/extensions/SerialConsole";
import notifications from "../../notifications"; import { Button } from "@components/Button";
import notifications from "@/notifications";
interface Extension { interface Extension {
id: string; id: string;
name: string; name: string;
description: string; description: string;
icon: any; icon: React.ElementType;
} }
const AVAILABLE_EXTENSIONS: Extension[] = [ const AVAILABLE_EXTENSIONS: Extension[] = [
@ -58,7 +59,9 @@ export default function ExtensionPopover() {
const handleSetActiveExtension = (extension: Extension | null) => { const handleSetActiveExtension = (extension: Extension | null) => {
send("setActiveExtension", { extensionId: extension?.id || "" }, resp => { send("setActiveExtension", { extensionId: extension?.id || "" }, resp => {
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Failed to set active extension: ${resp.error.data || "Unknown error"}`); notifications.error(
`Failed to set active extension: ${resp.error.data || "Unknown error"}`,
);
return; return;
} }
setActiveExtension(extension); setActiveExtension(extension);
@ -80,7 +83,7 @@ export default function ExtensionPopover() {
return ( return (
<GridCard> <GridCard>
<div className="p-4 py-3 space-y-4"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody">
<div className="space-y-4"> <div className="space-y-4">
{activeExtension ? ( {activeExtension ? (
@ -89,7 +92,7 @@ export default function ExtensionPopover() {
{renderActiveExtension()} {renderActiveExtension()}
<div <div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn" className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -110,7 +113,7 @@ export default function ExtensionPopover() {
title="Extensions" title="Extensions"
description="Load and manage your extensions" description="Load and manage your extensions"
/> />
<Card className="opacity-0 animate-fadeIn"> <Card className="animate-fadeIn opacity-0">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30"> <div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{AVAILABLE_EXTENSIONS.map(extension => ( {AVAILABLE_EXTENSIONS.map(extension => (
<div <div

View File

@ -1,11 +1,6 @@
import { Button } from "@components/Button";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import Card, { GridCard } from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/20/solid"; import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react"; import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { import {
LuArrowUpFromLine, LuArrowUpFromLine,
LuCheckCheck, LuCheckCheck,
@ -13,11 +8,17 @@ import {
LuPlus, LuPlus,
LuRadioReceiver, LuRadioReceiver,
} from "react-icons/lu"; } from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { Button } from "@components/Button";
import Card, { GridCard } from "@components/Card";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => { const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats); const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);

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 { Button } from "@components/Button";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea"; import { TextAreaWithLabel } from "@components/TextArea";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "../../notifications";
import { useCallback, useEffect, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react";
import { chars, keys, modifiers } from "@/keyboardMappings"; import { chars, keys, modifiers } from "@/keyboardMappings";
import notifications from "@/notifications";
const hidKeyboardPayload = (keys: number[], modifier: number) => { const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier }; return { keys, modifier };
@ -59,6 +60,7 @@ export default function PasteModal() {
}); });
} }
} catch (error) { } catch (error) {
console.error(error);
notifications.error("Failed to paste text"); notifications.error("Failed to paste text");
} }
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]); }, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
@ -71,7 +73,7 @@ export default function PasteModal() {
return ( return (
<GridCard> <GridCard>
<div className="p-4 py-3 space-y-4"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
@ -81,7 +83,7 @@ export default function PasteModal() {
/> />
<div <div
className="space-y-2 opacity-0 animate-fadeIn" className="animate-fadeIn space-y-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -120,8 +122,8 @@ export default function PasteModal() {
/> />
{invalidChars.length > 0 && ( {invalidChars.length > 0 && (
<div className="flex items-center mt-2 gap-x-2"> <div className="mt-2 flex items-center gap-x-2">
<ExclamationCircleIcon className="w-4 h-4 text-red-500 dark:text-red-400" /> <ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400"> <span className="text-xs text-red-500 dark:text-red-400">
The following characters won&apos;t be pasted:{" "} The following characters won&apos;t be pasted:{" "}
{invalidChars.join(", ")} {invalidChars.join(", ")}
@ -135,7 +137,7 @@ export default function PasteModal() {
</div> </div>
</div> </div>
<div <div
className="flex items-center justify-end opacity-0 animate-fadeIn gap-x-2" className="flex animate-fadeIn items-center justify-end gap-x-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

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

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 { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
import { Button } from "@/components/Button";
import Card from "@/components/Card";
import { FieldError } from "@/components/InputField";
export interface StoredDevice { export interface StoredDevice {
name: string; name: string;
macAddress: string; macAddress: string;
@ -27,12 +28,14 @@ export default function DeviceList({
}: DeviceListProps) { }: DeviceListProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card className="opacity-0 animate-fadeIn"> <Card className="animate-fadeIn opacity-0">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30"> <div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{storedDevices.map((device, index) => ( {storedDevices.map((device, index) => (
<div key={index} className="flex items-center justify-between p-3 gap-x-2"> <div key={index} className="flex items-center justify-between gap-x-2 p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">{device?.name}</p> <p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">
{device?.name}
</p>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
{device.macAddress?.toLowerCase()} {device.macAddress?.toLowerCase()}
</p> </p>
@ -60,18 +63,13 @@ export default function DeviceList({
</div> </div>
</Card> </Card>
<div <div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn" className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
}} }}
> >
<Button <Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
size="SM"
theme="blank"
text="Close"
onClick={onCancelWakeOnLanModal}
/>
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"

View File

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

View File

@ -1,10 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores"; import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import EmptyStateCard from "./EmptyStateCard"; import EmptyStateCard from "./EmptyStateCard";
import DeviceList, { StoredDevice } from "./DeviceList"; import DeviceList, { StoredDevice } from "./DeviceList";
import AddDeviceForm from "./AddDeviceForm"; import AddDeviceForm from "./AddDeviceForm";
@ -99,7 +101,7 @@ export default function WakeOnLanModal() {
return ( return (
<GridCard> <GridCard>
<div className="p-4 py-3 space-y-4"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader

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 { useInterval } from "usehooks-ts";
import SidebarHeader from "@/components/SidebarHeader";
import { GridCard } from "@/components/Card";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@/components/StatChart";
function createChartArray<T, K extends keyof T>( function createChartArray<T, K extends keyof T>(
stream: Map<number, T>, stream: Map<number, T>,
metric: K, metric: K,

View File

@ -1,7 +1,8 @@
import { useNavigate, useParams, NavigateOptions } from "react-router-dom"; import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
import { isOnDevice } from "../main";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { isOnDevice } from "../main";
/** /**
* Generates the correct path based on whether the app is running on device or in cloud mode * Generates the correct path based on whether the app is running on device or in cloud mode
* *

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import Root from "./root";
import "./index.css"; import "./index.css";
import { import {
createBrowserRouter, createBrowserRouter,
@ -8,19 +7,22 @@ import {
RouterProvider, RouterProvider,
useRouteError, useRouteError,
} from "react-router-dom"; } from "react-router-dom";
import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
import SetupRoute from "@routes/devices.$id.setup";
import LoginRoute from "@routes/login";
import SignupRoute from "@routes/signup";
import AdoptRoute from "@routes/adopt";
import DeviceIdRename from "@routes/devices.$id.rename";
import DevicesIdDeregister from "@routes/devices.$id.deregister";
import NotFoundPage from "@components/NotFoundPage";
import EmptyCard from "@components/EmptyCard";
import { ExclamationTriangleIcon } from "@heroicons/react/16/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/16/solid";
import EmptyCard from "@components/EmptyCard";
import NotFoundPage from "@components/NotFoundPage";
import DevicesIdDeregister from "@routes/devices.$id.deregister";
import DeviceIdRename from "@routes/devices.$id.rename";
import AdoptRoute from "@routes/adopt";
import SignupRoute from "@routes/signup";
import LoginRoute from "@routes/login";
import SetupRoute from "@routes/devices.$id.setup";
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
import Card from "@components/Card"; import Card from "@components/Card";
import DevicesAlreadyAdopted from "@routes/devices.already-adopted"; import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
import Root from "./root";
import Notifications from "./notifications"; import Notifications from "./notifications";
import LoginLocalRoute from "./routes/login-local"; import LoginLocalRoute from "./routes/login-local";
import WelcomeLocalModeRoute from "./routes/welcome-local.mode"; import WelcomeLocalModeRoute from "./routes/welcome-local.mode";

View File

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

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

View File

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

View File

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

View File

@ -1,15 +1,4 @@
import Card, { GridCard } from "@/components/Card";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import {
MountMediaState,
RemoteVirtualMediaState,
useMountMediaStore,
useRTCStore,
} from "../hooks/stores";
import { cx } from "../cva.config";
import { import {
LuGlobe, LuGlobe,
LuLink, LuLink,
@ -18,8 +7,15 @@ import {
LuCheck, LuCheck,
LuUpload, LuUpload,
} from "react-icons/lu"; } from "react-icons/lu";
import { PlusCircleIcon , ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { TrashIcon } from "@heroicons/react/16/solid";
import { useNavigate } from "react-router-dom";
import Card, { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import AutoHeight from "@components/AutoHeight"; import AutoHeight from "@components/AutoHeight";
import { InputFieldWithLabel } from "@/components/InputField"; import { InputFieldWithLabel } from "@/components/InputField";
import DebianIcon from "@/assets/debian-icon.png"; import DebianIcon from "@/assets/debian-icon.png";
@ -28,14 +24,20 @@ import FedoraIcon from "@/assets/fedora-icon.png";
import OpenSUSEIcon from "@/assets/opensuse-icon.png"; import OpenSUSEIcon from "@/assets/opensuse-icon.png";
import ArchIcon from "@/assets/arch-icon.png"; import ArchIcon from "@/assets/arch-icon.png";
import NetBootIcon from "@/assets/netboot-icon.svg"; import NetBootIcon from "@/assets/netboot-icon.svg";
import { TrashIcon } from "@heroicons/react/16/solid";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import notifications from "../notifications";
import Fieldset from "@/components/Fieldset"; import Fieldset from "@/components/Fieldset";
import { isOnDevice } from "../main";
import { DEVICE_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
import { useNavigate } from "react-router-dom";
import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { isOnDevice } from "../main";
import { cx } from "../cva.config";
import {
MountMediaState,
RemoteVirtualMediaState,
useMountMediaStore,
useRTCStore,
} from "../hooks/stores";
export default function MountRoute() { export default function MountRoute() {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -1,11 +1,12 @@
import { useNavigate, useOutletContext } from "react-router-dom"; import { useNavigate, useOutletContext } from "react-router-dom";
import { GridCard } from "@/components/Card"; import { GridCard } from "@/components/Card";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg"; import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg"; import LogoWhite from "@/assets/logo-white.svg";
interface ContextType { interface ContextType {
connectWebRTC: () => Promise<void>; setupPeerConnection: () => Promise<void>;
} }
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
@ -15,7 +16,7 @@ export default function OtherSessionRoute() {
// Function to handle closing the modal // Function to handle closing the modal
const handleClose = () => { const handleClose = () => {
outletContext?.connectWebRTC().then(() => navigate("..")); outletContext?.setupPeerConnection().then(() => navigate(".."));
}; };
return ( return (

View File

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

View File

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

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 { useLoaderData, useNavigate } from "react-router-dom";
import { Button, LinkButton } from "../components/Button";
import { DEVICE_API } from "../ui.config";
import api from "../api";
import { LocalDevice } from "./devices.$id";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { GridCard } from "../components/Card";
import { ShieldCheckIcon } from "@heroicons/react/24/outline"; import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import notifications from "../notifications";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { InputFieldWithLabel } from "../components/InputField"; import api from "@/api";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsSectionHeader } from "../components/SettingsSectionHeader"; import { GridCard } from "@/components/Card";
import { isOnDevice } from "../main"; import { Button, LinkButton } from "@/components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import { SettingsSectionHeader } from "@/components/SettingsSectionHeader";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications";
import { DEVICE_API } from "@/ui.config";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { isOnDevice } from "@/main";
import { LocalDevice } from "./devices.$id";
import { SettingsItem } from "./devices.$id.settings";
import { CloudState } from "./adopt"; import { CloudState } from "./adopt";
export const loader = async () => { export const loader = async () => {

View File

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

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

View File

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

View File

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

View File

@ -1,11 +1,12 @@
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import Card from "@/components/Card";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import Card from "@/components/Card";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores"; import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";

View File

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

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

View File

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

View File

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

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

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 { cx } from "@/cva.config";
import { import {
DeviceSettingsState, DeviceSettingsState,
@ -16,36 +33,28 @@ import {
VideoState, VideoState,
} from "@/hooks/stores"; } from "@/hooks/stores";
import WebRTCVideo from "@components/WebRTCVideo"; import WebRTCVideo from "@components/WebRTCVideo";
import {
LoaderFunctionArgs,
Outlet,
Params,
redirect,
useLoaderData,
useLocation,
useNavigate,
useOutlet,
useParams,
useSearchParams,
} from "react-router-dom";
import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { checkAuth, isInCloud, isOnDevice } from "@/main";
import DashboardNavbar from "@components/Header"; import DashboardNavbar from "@components/Header";
import { useInterval } from "usehooks-ts";
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats"; import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
import FocusTrap from "focus-trap-react";
import Terminal from "@components/Terminal"; import Terminal from "@components/Terminal";
import { CLOUD_API, DEVICE_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
import api from "../api";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import { motion, AnimatePresence } from "motion/react";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import {
ConnectionFailedOverlay,
LoadingConnectionOverlay,
PeerConnectionDisconnectedOverlay,
} from "../components/VideoOverlay";
import { FeatureFlagProvider } from "../providers/FeatureFlagProvider"; import { FeatureFlagProvider } from "../providers/FeatureFlagProvider";
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
import notifications from "../notifications"; import notifications from "../notifications";
import { DeviceStatus } from "./welcome-local";
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
interface LocalLoaderResp { interface LocalLoaderResp {
authMode: "password" | "noPassword" | null; authMode: "password" | "noPassword" | null;
} }
@ -110,7 +119,6 @@ const loader = async ({ params }: LoaderFunctionArgs) => {
export default function KvmIdRoute() { export default function KvmIdRoute() {
const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp; const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp;
// Depending on the mode, we set the appropriate variables // Depending on the mode, we set the appropriate variables
const user = "user" in loaderResp ? loaderResp.user : null; const user = "user" in loaderResp ? loaderResp.user : null;
const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null; const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null;
@ -123,28 +131,244 @@ export default function KvmIdRoute() {
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse); const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
const peerConnection = useRTCStore(state => state.peerConnection); const peerConnection = useRTCStore(state => state.peerConnection);
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState); const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const setMediaMediaStream = useRTCStore(state => state.setMediaStream); const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
const setPeerConnection = useRTCStore(state => state.setPeerConnection); const setPeerConnection = useRTCStore(state => state.setPeerConnection);
const setDiskChannel = useRTCStore(state => state.setDiskChannel); const setDiskChannel = useRTCStore(state => state.setDiskChannel);
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel); const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
const setTransceiver = useRTCStore(state => state.setTransceiver); const setTransceiver = useRTCStore(state => state.setTransceiver);
const location = useLocation();
const isLegacySignalingEnabled = useRef(false);
const [connectionFailed, setConnectionFailed] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { otaState, setOtaState, setModalView } = useUpdateStore(); const { otaState, setOtaState, setModalView } = useUpdateStore();
const sdp = useCallback( const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => { const cleanupAndStopReconnecting = useCallback(
if (!pc) return; function cleanupAndStopReconnecting() {
if (event.candidate !== null) return; console.log("Closing peer connection");
setConnectionFailed(true);
if (peerConnection) {
setPeerConnectionState(peerConnection.connectionState);
}
connectionFailedRef.current = true;
peerConnection?.close();
signalingAttempts.current = 0;
},
[peerConnection, setPeerConnectionState],
);
// 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 { try {
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
console.log("[setRemoteSessionDescription] Remote description set successfully");
setLoadingMessage("Establishing secure connection...");
} catch (error) {
console.error(
"[setRemoteSessionDescription] Failed to set remote description:",
error,
);
cleanupAndStopReconnecting();
return;
}
// Replace the interval-based check with a more reliable approach
let attempts = 0;
const checkInterval = setInterval(() => {
attempts++;
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
if (pc.sctp?.state === "connected") {
console.log("[setRemoteSessionDescription] Remote description set");
clearInterval(checkInterval);
setLoadingMessage("Connection established");
} else if (attempts >= 10) {
console.log(
"[setRemoteSessionDescription] Failed to establish connection after 10 attempts",
{
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
},
);
cleanupAndStopReconnecting();
clearInterval(checkInterval);
} else {
console.log("[setRemoteSessionDescription] Waiting for connection, state:", {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
});
}
}, 1000);
},
[cleanupAndStopReconnecting],
);
const ignoreOffer = useRef(false);
const isSettingRemoteAnswerPending = useRef(false);
const makingOffer = useRef(false);
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const { sendMessage, getWebSocket } = useWebSocket(
isOnDevice
? `${wsProtocol}//${window.location.host}/webrtc/signaling/client`
: `${CLOUD_API.replace("http", "ws")}/webrtc/signaling/client?id=${params.id}`,
{
heartbeat: true,
retryOnError: true,
reconnectAttempts: 5,
reconnectInterval: 1000,
onReconnectStop: () => {
console.log("Reconnect stopped");
cleanupAndStopReconnecting();
},
shouldReconnect(event) {
console.log("[Websocket] shouldReconnect", event);
// TODO: Why true?
return true;
},
onClose(event) {
console.log("[Websocket] onClose", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onError(event) {
console.log("[Websocket] onError", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onOpen() {
console.log("[Websocket] onOpen");
},
onMessage: message => {
if (message.data === "pong") return;
/*
Currently the signaling process is as follows:
After open, the other side will send a `device-metadata` message with the device version
If the device version is not set, we can assume the device is using the legacy signaling
Otherwise, we can assume the device is using the new signaling
If the device is using the legacy signaling, we close the websocket connection
and use the legacy HTTPSignaling function to get the remote session description
If the device is using the new signaling, we don't need to do anything special, but continue to use the websocket connection
to chat with the other peer about the connection
*/
const parsedMessage = JSON.parse(message.data);
if (parsedMessage.type === "device-metadata") {
const { deviceVersion } = parsedMessage.data;
console.log("[Websocket] Received device-metadata message");
console.log("[Websocket] Device version", deviceVersion);
// If the device version is not set, we can assume the device is using the legacy signaling
if (!deviceVersion) {
console.log("[Websocket] Device is using legacy signaling");
// Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling
// which does everything over HTTP(at least from the perspective of the client)
isLegacySignalingEnabled.current = true;
getWebSocket()?.close();
} else {
console.log("[Websocket] Device is using new signaling");
isLegacySignalingEnabled.current = false;
}
setupPeerConnection();
}
if (!peerConnection) return;
if (parsedMessage.type === "answer") {
console.log("[Websocket] Received answer");
const readyForOffer =
// If we're making an offer, we don't want to accept an answer
!makingOffer &&
// If the peer connection is stable or we're setting the remote answer pending, we're ready for an offer
(peerConnection?.signalingState === "stable" ||
isSettingRemoteAnswerPending.current);
// If we're not ready for an offer, we don't want to accept an offer
ignoreOffer.current = parsedMessage.type === "offer" && !readyForOffer;
if (ignoreOffer.current) return;
// Set so we don't accept an answer while we're setting the remote description
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
console.log(
"[Websocket] Setting remote answer pending",
isSettingRemoteAnswerPending.current,
);
const sd = atob(parsedMessage.data);
const remoteSessionDescription = JSON.parse(sd);
setRemoteSessionDescription(
peerConnection,
new RTCSessionDescription(remoteSessionDescription),
);
// Reset the remote answer pending flag
isSettingRemoteAnswerPending.current = false;
} else if (parsedMessage.type === "new-ice-candidate") {
console.log("[Websocket] Received new-ice-candidate");
const candidate = parsedMessage.data;
peerConnection.addIceCandidate(candidate);
}
},
},
// Don't even retry once we declare failure
!connectionFailed && isLegacySignalingEnabled.current === false,
);
const sendWebRTCSignal = useCallback(
(type: string, data: unknown) => {
// Second argument tells the library not to queue the message, and send it once the connection is established again.
// We have event handlers that handle the connection set up, so we don't need to queue the message.
sendMessage(JSON.stringify({ type, data }), false);
},
[sendMessage],
);
const legacyHTTPSignaling = useCallback(
async (pc: RTCPeerConnection) => {
const sd = btoa(JSON.stringify(pc.localDescription)); const sd = btoa(JSON.stringify(pc.localDescription));
const sessionUrl = isOnDevice // Legacy mode == UI in cloud with updated code connecting to older device version.
? `${DEVICE_API}/webrtc/session` // In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled
: `${CLOUD_API}/webrtc/session`; 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, { const res = await api.POST(sessionUrl, {
sd, sd,
// When on device, we don't need to specify the device id, as it's already known // When on device, we don't need to specify the device id, as it's already known
@ -152,55 +376,107 @@ export default function KvmIdRoute() {
}); });
const json = await res.json(); const json = await res.json();
if (res.status === 401) return navigate(isOnDevice ? "/login-local" : "/login");
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) { if (!res.ok) {
pc?.close(); console.error("Error getting SDP", { status: res.status, json });
console.error(`Error setting SDP - Status: ${res.status}}`, json); cleanupAndStopReconnecting();
return; return;
} }
}
pc.setRemoteDescription( console.log("Successfully got Remote Session Description. Setting.");
new RTCSessionDescription(JSON.parse(atob(json.sd))), setLoadingMessage("Setting remote session description...");
).catch(e => console.log(`Error setting remote description: ${e}`));
} catch (error) { const decodedSd = atob(json.sd);
console.error(`Error setting SDP: ${error}`); const parsedSd = JSON.parse(decodedSd);
pc?.close(); setRemoteSessionDescription(pc, new RTCSessionDescription(parsedSd));
}
}, },
[navigate, params.id], [cleanupAndStopReconnecting, navigate, params.id, setRemoteSessionDescription],
); );
const connectWebRTC = useCallback(async () => { const setupPeerConnection = useCallback(async () => {
console.log("Attempting to connect WebRTC"); console.log("[setupPeerConnection] Setting up peer connection");
const pc = new RTCPeerConnection({ setConnectionFailed(false);
setLoadingMessage("Connecting to device...");
if (peerConnection?.signalingState === "stable") {
console.log("[setupPeerConnection] Peer connection already established");
return;
}
let pc: RTCPeerConnection;
try {
console.log("[setupPeerConnection] Creating peer connection");
setLoadingMessage("Creating peer connection...");
pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud // We only use STUN or TURN servers if we're in the cloud
...(isInCloud && iceConfig?.iceServers ...(isInCloud && iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] } ? { iceServers: [iceConfig?.iceServers] }
: {}), : {}),
}); });
setPeerConnectionState(pc.connectionState);
console.log("[setupPeerConnection] Peer connection created", pc);
setLoadingMessage("Setting up connection to device...");
} catch (e) {
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
setTimeout(() => {
cleanupAndStopReconnecting();
}, 1000);
return;
}
// Set up event listeners and data channels // Set up event listeners and data channels
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
console.log("[setupPeerConnection] Connection state changed", pc.connectionState);
setPeerConnectionState(pc.connectionState); setPeerConnectionState(pc.connectionState);
}; };
pc.onicecandidate = event => sdp(event, pc); pc.onnegotiationneeded = async () => {
try {
console.log("[setupPeerConnection] Creating offer");
makingOffer.current = true;
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sd = btoa(JSON.stringify(pc.localDescription));
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
if (isNewSignalingEnabled) {
sendWebRTCSignal("offer", { sd: sd });
} else {
console.log("Legacy signanling. Waiting for ICE Gathering to complete...");
}
} catch (e) {
console.error(
`[setupPeerConnection] Error creating offer: ${e}`,
new Date().toISOString(),
);
cleanupAndStopReconnecting();
} finally {
makingOffer.current = false;
}
};
pc.onicecandidate = async ({ candidate }) => {
if (!candidate) return;
if (candidate.candidate === "") return;
sendWebRTCSignal("new-ice-candidate", candidate);
};
pc.onicegatheringstatechange = event => {
const pc = event.currentTarget as RTCPeerConnection;
if (pc.iceGatheringState === "complete") {
console.log("ICE Gathering completed");
setLoadingMessage("ICE Gathering completed");
if (isLegacySignalingEnabled.current) {
// We can now start the https/ws connection to get the remote session description from the KVM device
legacyHTTPSignaling(pc);
}
} else if (pc.iceGatheringState === "gathering") {
console.log("ICE Gathering Started");
setLoadingMessage("Gathering ICE candidates...");
}
};
pc.ontrack = function (event) { pc.ontrack = function (event) {
setMediaMediaStream(event.streams[0]); setMediaMediaStream(event.streams[0]);
@ -218,16 +494,13 @@ export default function KvmIdRoute() {
setDiskChannel(diskDataChannel); setDiskChannel(diskDataChannel);
}; };
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
setPeerConnection(pc); setPeerConnection(pc);
} catch (e) {
console.error(`Error creating offer: ${e}`);
}
}, [ }, [
cleanupAndStopReconnecting,
iceConfig?.iceServers, iceConfig?.iceServers,
sdp, legacyHTTPSignaling,
peerConnection?.signalingState,
sendWebRTCSignal,
setDiskChannel, setDiskChannel,
setMediaMediaStream, setMediaMediaStream,
setPeerConnection, setPeerConnection,
@ -236,23 +509,12 @@ export default function KvmIdRoute() {
setTransceiver, setTransceiver,
]); ]);
// WebRTC connection management
useInterval(() => {
if (
["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "")
) {
return;
}
if (location.pathname.includes("other-session")) return;
connectWebRTC();
}, 3000);
// On boot, if the connection state is undefined, we connect to the WebRTC
useEffect(() => { useEffect(() => {
if (peerConnection?.connectionState === undefined) { if (peerConnectionState === "failed") {
connectWebRTC(); console.log("Connection failed, closing peer connection");
cleanupAndStopReconnecting();
} }
}, [connectWebRTC, peerConnection?.connectionState]); }, [peerConnectionState, cleanupAndStopReconnecting]);
// Cleanup effect // Cleanup effect
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats); const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
@ -277,7 +539,7 @@ export default function KvmIdRoute() {
// TURN server usage detection // TURN server usage detection
useEffect(() => { useEffect(() => {
if (peerConnection?.connectionState !== "connected") return; if (peerConnectionState !== "connected") return;
const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState(); const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState();
const lastLocalStat = Array.from(localCandidateStats).pop(); const lastLocalStat = Array.from(localCandidateStats).pop();
@ -289,7 +551,7 @@ export default function KvmIdRoute() {
const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn); setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
}, [peerConnection?.connectionState, setIsTurnServerInUse]); }, [peerConnectionState, setIsTurnServerInUse]);
// TURN server usage reporting // TURN server usage reporting
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
@ -380,10 +642,6 @@ export default function KvmIdRoute() {
}); });
}, [rpcDataChannel?.readyState, send, setHdmiState]); }, [rpcDataChannel?.readyState, send, setHdmiState]);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.send = send;
// When the update is successful, we need to refresh the client javascript and show a success modal // When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => { useEffect(() => {
if (queryParams.get("updateSuccess")) { if (queryParams.get("updateSuccess")) {
@ -420,18 +678,17 @@ export default function KvmIdRoute() {
useEffect(() => { useEffect(() => {
if (!peerConnection) return; if (!peerConnection) return;
if (!kvmTerminal) { if (!kvmTerminal) {
console.log('Creating data channel "terminal"'); // console.log('Creating data channel "terminal"');
setKvmTerminal(peerConnection.createDataChannel("terminal")); setKvmTerminal(peerConnection.createDataChannel("terminal"));
} }
if (!serialConsole) { if (!serialConsole) {
console.log('Creating data channel "serial"'); // console.log('Creating data channel "serial"');
setSerialConsole(peerConnection.createDataChannel("serial")); setSerialConsole(peerConnection.createDataChannel("serial"));
} }
}, [kvmTerminal, peerConnection, serialConsole]); }, [kvmTerminal, peerConnection, serialConsole]);
const outlet = useOutlet(); const outlet = useOutlet();
const location = useLocation();
const onModalClose = useCallback(() => { const onModalClose = useCallback(() => {
if (location.pathname !== "/other-session") navigateTo("/"); if (location.pathname !== "/other-session") navigateTo("/");
}, [navigateTo, location.pathname]); }, [navigateTo, location.pathname]);
@ -469,6 +726,43 @@ export default function KvmIdRoute() {
[send, setScrollSensitivity], [send, setScrollSensitivity],
); );
const ConnectionStatusElement = useMemo(() => {
const hasConnectionFailed =
connectionFailed || ["failed", "closed"].includes(peerConnectionState || "");
const isPeerConnectionLoading =
["connecting", "new"].includes(peerConnectionState || "") ||
peerConnection === null;
const isDisconnected = peerConnectionState === "disconnected";
const isOtherSession = location.pathname.includes("other-session");
if (isOtherSession) return null;
if (peerConnectionState === "connected") return null;
if (isDisconnected) {
return <PeerConnectionDisconnectedOverlay show={true} />;
}
if (hasConnectionFailed)
return (
<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />
);
if (isPeerConnectionLoading) {
return <LoadingConnectionOverlay show={true} text={loadingMessage} />;
}
return null;
}, [
connectionFailed,
loadingMessage,
location.pathname,
peerConnection,
peerConnectionState,
setupPeerConnection,
]);
return ( return (
<FeatureFlagProvider appVersion={appVersion}> <FeatureFlagProvider appVersion={appVersion}>
{!outlet && otaState.updating && ( {!outlet && otaState.updating && (
@ -507,8 +801,14 @@ export default function KvmIdRoute() {
kvmName={deviceName || "JetKVM Device"} kvmName={deviceName || "JetKVM Device"}
/> />
<div className="flex h-full overflow-hidden"> <div className="flex h-full w-full overflow-hidden">
<WebRTCVideo /> <div className="pointer-events-none fixed inset-0 isolate z-20 flex h-full w-full items-center justify-center">
<div className="my-2 h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{!!ConnectionStatusElement && ConnectionStatusElement}
</div>
</div>
{peerConnectionState === "connected" && <WebRTCVideo />}
<SidebarContainer sidebarView={sidebarView} /> <SidebarContainer sidebarView={sidebarView} />
</div> </div>
</div> </div>
@ -523,7 +823,8 @@ export default function KvmIdRoute() {
}} }}
> >
<Modal open={outlet !== null} onClose={onModalClose}> <Modal open={outlet !== null} onClose={onModalClose}>
<Outlet context={{ connectWebRTC }} /> {/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
<Outlet context={{ setupPeerConnection }} />
</Modal> </Modal>
</div> </div>

View File

@ -1,4 +1,6 @@
import { useLoaderData, useRevalidator } from "react-router-dom"; import { useLoaderData, useRevalidator } from "react-router-dom";
import { LuMonitorSmartphone } from "react-icons/lu";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import DashboardNavbar from "@components/Header"; import DashboardNavbar from "@components/Header";
import { LinkButton } from "@components/Button"; import { LinkButton } from "@components/Button";
@ -7,8 +9,6 @@ import useInterval from "@/hooks/useInterval";
import { checkAuth } from "@/main"; import { checkAuth } from "@/main";
import { User } from "@/hooks/stores"; import { User } from "@/hooks/stores";
import EmptyCard from "@components/EmptyCard"; import EmptyCard from "@components/EmptyCard";
import { LuMonitorSmartphone } from "react-icons/lu";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { CLOUD_API } from "@/ui.config"; import { CLOUD_API } from "@/ui.config";
interface LoaderData { interface LoaderData {
@ -49,8 +49,8 @@ export default function DevicesRoute() {
/> />
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
<div className="w-full h-full px-4 mx-auto space-y-6 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl"> <div className="mx-auto h-full w-full space-y-6 px-4 sm:max-w-6xl sm:px-8 md:max-w-7xl md:px-12 lg:max-w-8xl">
<div className="flex items-center justify-between pb-4 mt-8 border-b border-b-slate-800/20 dark:border-b-slate-300/20"> <div className="mt-8 flex items-center justify-between border-b border-b-slate-800/20 pb-4 dark:border-b-slate-300/20">
<div> <div>
<h1 className="text-xl font-bold text-black dark:text-white"> <h1 className="text-xl font-bold text-black dark:text-white">
Cloud KVMs Cloud KVMs

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 SimpleNavbar from "@components/SimpleNavbar";
import GridBackground from "@components/GridBackground"; import GridBackground from "@components/GridBackground";
import Container from "@components/Container"; import Container from "@components/Container";
import Fieldset from "@components/Fieldset"; import Fieldset from "@components/Fieldset";
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { InputFieldWithLabel } from "@components/InputField"; import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import LogoBlueIcon from "@/assets/logo-blue.png"; import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
import ExtLink from "../components/ExtLink";
import { DEVICE_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
import api from "../api";
import ExtLink from "../components/ExtLink";
import { DeviceStatus } from "./welcome-local";
const loader = async () => { const loader = async () => {
const res = await api const res = await api
.GET(`${DEVICE_API}/device/status`) .GET(`${DEVICE_API}/device/status`)
@ -31,12 +34,9 @@ const action = async ({ request }: ActionFunctionArgs) => {
const password = formData.get("password"); const password = formData.get("password");
try { try {
const response = await api.POST( const response = await api.POST(`${DEVICE_API}/auth/login-local`, {
`${DEVICE_API}/auth/login-local`,
{
password, password,
}, });
);
if (response.ok) { if (response.ok) {
return redirect("/"); return redirect("/");
@ -44,6 +44,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
return { error: "Invalid password" }; return { error: "Invalid password" };
} }
} catch (error) { } catch (error) {
console.error(error);
return { error: "An error occurred while logging in" }; return { error: "An error occurred while logging in" };
} }
}; };
@ -58,22 +59,28 @@ export default function LoginLocalRoute() {
<div className="grid min-h-screen grid-rows-layout"> <div className="grid min-h-screen grid-rows-layout">
<SimpleNavbar /> <SimpleNavbar />
<Container> <Container>
<div className="flex items-center justify-center w-full h-full isolate"> <div className="isolate flex h-full w-full items-center justify-center">
<div className="max-w-2xl -mt-32 space-y-8"> <div className="-mt-32 max-w-2xl space-y-8">
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" /> <img
src={LogoWhiteIcon}
alt=""
className="-ml-4 hidden h-[32px] dark:block"
/>
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" /> <img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
</div> </div>
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">Welcome back to JetKVM</h1> <h1 className="text-4xl font-semibold text-black dark:text-white">
Welcome back to JetKVM
</h1>
<p className="font-medium text-slate-600 dark:text-slate-400"> <p className="font-medium text-slate-600 dark:text-slate-400">
Enter your password to access your JetKVM. Enter your password to access your JetKVM.
</p> </p>
</div> </div>
<Fieldset className="space-y-12"> <Fieldset className="space-y-12">
<Form method="POST" className="max-w-sm mx-auto space-y-4"> <Form method="POST" className="mx-auto max-w-sm space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<InputFieldWithLabel <InputFieldWithLabel
label="Password" label="Password"
@ -88,14 +95,14 @@ export default function LoginLocalRoute() {
onClick={() => setShowPassword(false)} onClick={() => setShowPassword(false)}
className="pointer-events-auto" className="pointer-events-auto"
> >
<LuEye className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" /> <LuEye className="h-4 w-4 cursor-pointer text-slate-500 dark:text-slate-400" />
</div> </div>
) : ( ) : (
<div <div
onClick={() => setShowPassword(true)} onClick={() => setShowPassword(true)}
className="pointer-events-auto" className="pointer-events-auto"
> >
<LuEyeOff className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" /> <LuEyeOff className="h-4 w-4 cursor-pointer text-slate-500 dark:text-slate-400" />
</div> </div>
) )
} }
@ -111,7 +118,7 @@ export default function LoginLocalRoute() {
textAlign="center" textAlign="center"
/> />
<div className="flex justify-start mt-4 text-xs text-slate-500 dark:text-slate-400"> <div className="mt-4 flex justify-start text-xs text-slate-500 dark:text-slate-400">
<ExtLink <ExtLink
href="https://jetkvm.com/docs/networking/local-access#reset-password" href="https://jetkvm.com/docs/networking/local-access#reset-password"
className="hover:underline" className="hover:underline"

View File

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

View File

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

View File

@ -1,15 +1,19 @@
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { useState } from "react";
import GridBackground from "@components/GridBackground"; import GridBackground from "@components/GridBackground";
import Container from "@components/Container"; import Container from "@components/Container";
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { useState } from "react";
import { GridCard } from "../components/Card";
import LogoBlueIcon from "@/assets/logo-blue.png"; import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
import { DEVICE_API } from "@/ui.config";
import { GridCard } from "../components/Card";
import { cx } from "../cva.config"; import { cx } from "../cva.config";
import api from "../api"; import api from "../api";
import { DeviceStatus } from "./welcome-local"; import { DeviceStatus } from "./welcome-local";
import { DEVICE_API } from "@/ui.config";
const loader = async () => { const loader = async () => {
const res = await api const res = await api

View File

@ -1,17 +1,20 @@
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { useState, useRef, useEffect } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import GridBackground from "@components/GridBackground"; import GridBackground from "@components/GridBackground";
import Container from "@components/Container"; import Container from "@components/Container";
import Fieldset from "@components/Fieldset"; import Fieldset from "@components/Fieldset";
import { ActionFunctionArgs, Form, redirect, useActionData } from "react-router-dom";
import { InputFieldWithLabel } from "@components/InputField"; import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { useState, useRef, useEffect } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import LogoBlueIcon from "@/assets/logo-blue.png"; import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
import { DEVICE_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
const loader = async () => { const loader = async () => {
const res = await api const res = await api
.GET(`${DEVICE_API}/device/status`) .GET(`${DEVICE_API}/device/status`)

View File

@ -1,4 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { cx } from "cva";
import { redirect } from "react-router-dom";
import GridBackground from "@components/GridBackground"; import GridBackground from "@components/GridBackground";
import Container from "@components/Container"; import Container from "@components/Container";
import { LinkButton } from "@components/Button"; import { LinkButton } from "@components/Button";
@ -6,11 +9,12 @@ import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
import DeviceImage from "@/assets/jetkvm-device-still.png"; import DeviceImage from "@/assets/jetkvm-device-still.png";
import LogoMark from "@/assets/logo-mark.png"; import LogoMark from "@/assets/logo-mark.png";
import { cx } from "cva";
import api from "../api";
import { redirect } from "react-router-dom";
import { DEVICE_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
import api from "../api";
export interface DeviceStatus { export interface DeviceStatus {
isSetup: boolean; isSetup: boolean;
} }

View File

@ -51,8 +51,8 @@ export const formatters = {
]; ];
let duration = (date.valueOf() - new Date().valueOf()) / 1000; let duration = (date.valueOf() - new Date().valueOf()) / 1000;
for (let i = 0; i < DIVISIONS.length; i++) {
const division = DIVISIONS[i]; for (const division of DIVISIONS) {
if (Math.abs(duration) < division.amount) { if (Math.abs(duration) < division.amount) {
return relativeTimeFormat.format(Math.round(duration), division.name); return relativeTimeFormat.format(Math.round(duration), division.name);
} }
@ -61,7 +61,7 @@ export const formatters = {
}, },
price: (price: number | bigint | string, options?: Intl.NumberFormatOptions) => { price: (price: number | bigint | string, options?: Intl.NumberFormatOptions) => {
let opts: Intl.NumberFormatOptions = { const opts: Intl.NumberFormatOptions = {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
...(options || {}), ...(options || {}),

190
web.go
View File

@ -1,6 +1,7 @@
package kvm package kvm
import ( import (
"context"
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -10,12 +11,17 @@ import (
"strings" "strings"
"time" "time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pion/webrtc/v4"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
//nolint:typecheck
//go:embed all:static //go:embed all:static
var staticFiles embed.FS var staticFiles embed.FS
@ -93,7 +99,7 @@ func setupRouter() *gin.Engine {
protected := r.Group("/") protected := r.Group("/")
protected.Use(protectedMiddleware()) protected.Use(protectedMiddleware())
{ {
protected.POST("/webrtc/session", handleWebRTCSession) protected.GET("/webrtc/signaling/client", handleLocalWebRTCSignal)
protected.POST("/cloud/register", handleCloudRegister) protected.POST("/cloud/register", handleCloudRegister)
protected.GET("/cloud/state", handleCloudState) protected.GET("/cloud/state", handleCloudState)
protected.GET("/device", handleDevice) protected.GET("/device", handleDevice)
@ -120,35 +126,182 @@ func setupRouter() *gin.Engine {
// TODO: support multiple sessions? // TODO: support multiple sessions?
var currentSession *Session var currentSession *Session
func handleWebRTCSession(c *gin.Context) { func handleLocalWebRTCSignal(c *gin.Context) {
var req WebRTCSessionRequest cloudLogger.Infof("new websocket connection established")
// Create WebSocket options with InsecureSkipVerify to bypass origin check
if err := c.ShouldBindJSON(&req); err != nil { wsOptions := &websocket.AcceptOptions{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) InsecureSkipVerify: true, // Allow connections from any origin
return
} }
session, err := newSession(SessionConfig{}) wsCon, err := websocket.Accept(c.Writer, c.Request, wsOptions)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
sd, err := session.ExchangeOffer(req.Sd) // get the source from the request
source := c.ClientIP()
// Now use conn for websocket operations
defer wsCon.Close(websocket.StatusNormalClosure, "")
err = wsjson.Write(context.Background(), wsCon, gin.H{"type": "device-metadata", "data": gin.H{"deviceVersion": builtAppVersion}})
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if currentSession != nil {
writeJSONRPCEvent("otherSessionConnected", nil, currentSession) err = handleWebRTCSignalWsMessages(wsCon, false, source)
peerConn := currentSession.peerConnection if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
func handleWebRTCSignalWsMessages(wsCon *websocket.Conn, isCloudConnection bool, source string) error {
runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun()
// Add connection tracking to detect reconnections
connectionID := uuid.New().String()
cloudLogger.Infof("new websocket connection established with ID: %s", connectionID)
// connection type
var sourceType string
if isCloudConnection {
sourceType = "cloud"
} else {
sourceType = "local"
}
// probably we can use a better logging framework here
logInfof := func(format string, args ...interface{}) {
args = append(args, source, sourceType)
websocketLogger.Infof(format+", source: %s, sourceType: %s", args...)
}
logWarnf := func(format string, args ...interface{}) {
args = append(args, source, sourceType)
websocketLogger.Warnf(format+", source: %s, sourceType: %s", args...)
}
logTracef := func(format string, args ...interface{}) {
args = append(args, source, sourceType)
websocketLogger.Tracef(format+", source: %s, sourceType: %s", args...)
}
go func() { go func() {
time.Sleep(1 * time.Second) for {
_ = peerConn.Close() time.Sleep(WebsocketPingInterval)
// set the timer for the ping duration
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(v)
metricConnectionPingDuration.WithLabelValues(sourceType, source).Observe(v)
}))
logInfof("pinging websocket")
err := wsCon.Ping(runCtx)
if err != nil {
logWarnf("websocket ping error: %v", err)
cancelRun()
return
}
// dont use `defer` here because we want to observe the duration of the ping
timer.ObserveDuration()
metricConnectionTotalPingCount.WithLabelValues(sourceType, source).Inc()
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
}
}()
if isCloudConnection {
// create a channel to receive the disconnect event, once received, we cancelRun
cloudDisconnectChan = make(chan error)
defer func() {
close(cloudDisconnectChan)
cloudDisconnectChan = nil
}()
go func() {
for err := range cloudDisconnectChan {
if err == nil {
continue
}
cloudLogger.Infof("disconnecting from cloud due to: %v", err)
cancelRun()
}
}() }()
} }
currentSession = session
c.JSON(http.StatusOK, gin.H{"sd": sd}) for {
typ, msg, err := wsCon.Read(runCtx)
if err != nil {
logWarnf("websocket read error: %v", err)
return err
}
if typ != websocket.MessageText {
// ignore non-text messages
continue
}
var message struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
err = json.Unmarshal(msg, &message)
if err != nil {
logWarnf("unable to parse ws message: %v", err)
continue
}
if message.Type == "offer" {
logInfof("new session request received")
var req WebRTCSessionRequest
err = json.Unmarshal(message.Data, &req)
if err != nil {
logWarnf("unable to parse session request data: %v", err)
continue
}
logInfof("new session request: %v", req.OidcGoogle)
logTracef("session request info: %v", req)
metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc()
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
err = handleSessionRequest(runCtx, wsCon, req, isCloudConnection, source)
if err != nil {
logWarnf("error starting new session: %v", err)
continue
}
} else if message.Type == "new-ice-candidate" {
logInfof("The client sent us a new ICE candidate: %v", string(message.Data))
var candidate webrtc.ICECandidateInit
// Attempt to unmarshal as a ICECandidateInit
if err := json.Unmarshal(message.Data, &candidate); err != nil {
logWarnf("unable to parse incoming ICE candidate data: %v", string(message.Data))
continue
}
if candidate.Candidate == "" {
logWarnf("empty incoming ICE candidate, skipping")
continue
}
logInfof("unmarshalled incoming ICE candidate: %v", candidate)
if currentSession == nil {
logInfof("no current session, skipping incoming ICE candidate")
continue
}
logInfof("adding incoming ICE candidate to current session: %v", candidate)
if err = currentSession.peerConnection.AddICECandidate(candidate); err != nil {
logWarnf("failed to add incoming ICE candidate to our peer connection: %v", err)
}
}
}
} }
func handleLogin(c *gin.Context) { func handleLogin(c *gin.Context) {
@ -419,7 +572,6 @@ func handleSetup(c *gin.Context) {
// Set the cookie // Set the cookie
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true) c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
} else { } else {
// For noPassword mode, ensure the password field is empty // For noPassword mode, ensure the password field is empty
config.HashedPassword = "" config.HashedPassword = ""

View File

@ -8,10 +8,10 @@ import (
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/pem" "encoding/pem"
"log"
"math/big" "math/big"
"net" "net"
"net/http" "net/http"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -38,7 +38,7 @@ func RunWebSecureServer() {
TLSConfig: &tls.Config{ TLSConfig: &tls.Config{
// TODO: cache certificate in persistent storage // TODO: cache certificate in persistent storage
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
hostname := WebSecureSelfSignedDefaultDomain var hostname string
if info.ServerName != "" { if info.ServerName != "" {
hostname = info.ServerName hostname = info.ServerName
} else { } else {
@ -58,7 +58,6 @@ func RunWebSecureServer() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
return
} }
func createSelfSignedCert(hostname string) *tls.Certificate { func createSelfSignedCert(hostname string) *tls.Certificate {
@ -72,7 +71,8 @@ func createSelfSignedCert(hostname string) *tls.Certificate {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil { if err != nil {
log.Fatalf("Failed to generate private key: %v", err) logger.Errorf("Failed to generate private key: %v", err)
os.Exit(1)
} }
keyUsage := x509.KeyUsageDigitalSignature keyUsage := x509.KeyUsageDigitalSignature

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