Compare commits

...

61 Commits

Author SHA1 Message Date
SuperQ 97ce785056
Fix more linter issues.
Signed-off-by: SuperQ <superq@gmail.com>
2025-03-11 18:35:12 +01:00
SuperQ 75296b4b7e
Chore: Enable golangci-lint
Add a GitHub actions workflow to run golangci-lint.

Signed-off-by: SuperQ <superq@gmail.com>
2025-03-11 18:35:12 +01:00
SuperQ d3641bb4b9
Chore: Fix up various linting issues
In prep to add golangci-lint, fix various linting issues.
* Make the `kvm` package a fully-qualified public package.

Signed-off-by: SuperQ <superq@gmail.com>
2025-03-11 18:22:45 +01:00
Aveline 4884240f5f
Cleanup logging (#216) 2025-03-11 18:18:15 +01:00
SuperQ 34e33e45bf
Cleanup logging
Make sure all logging output is called via the main logger instead of
stdlib `"log"` or `fmt.Print(f|ln)`.

Signed-off-by: SuperQ <superq@gmail.com>
2025-03-11 16:53:54 +01:00
Aveline c5cec99797
feat: usb dynamic config (#248) 2025-03-10 17:08:29 +01:00
Siyuan Miao d1948adca8 refactor(usb): move usbconfig to a seperated package 2025-03-10 14:02:52 +01:00
Siyuan Miao c088534d34 feat(usb): dynamic usb devices config 2025-03-10 13:54:42 +01:00
Simon Smith 5c7accae0d
add confirm prompt to delete file (#243) 2025-03-10 10:56:57 +01:00
Adam Shiervani 536e823243
feat: Add scroll sensitivity configuration and improved wheel event handling (#242)
- Implement scroll sensitivity settings with low, default, and high modes
- Add RPC methods for getting and setting scroll sensitivity
- Enhance wheel event handling with device-specific sensitivity and clamping
- Create a new device settings store for managing scroll and trackpad parameters
- Update mouse settings route to include scroll sensitivity selection
2025-03-10 10:51:11 +01:00
Simon Smith 3b83f4c7a1
fix rpcGetUsbEmulationState filepath (#241) 2025-03-09 15:12:27 +01:00
Carl Downing 1ec87f043f
copyedits (#232)
* fix case

* fix typos

* fix css class names

---------

Co-authored-by: Carl Downing <carl@undivided.io>
2025-03-09 15:04:11 +01:00
Aveline 554121a20b
chore: ensure config is loaded before init functions (#226) 2025-03-04 11:32:54 +01:00
Aveline d4efd72731
feat: implement build and test workflow (#224) 2025-03-03 22:33:43 +01:00
Siyuan Miao 08a315d908 feat: implement build and test workflow 2025-03-03 22:18:36 +01:00
Adam Shiervani 16f83e6136
fix(build): Add frontend build step to dev release process (#219) 2025-03-03 14:15:43 +01:00
Aveline da97a17977
fix(usb_config): check if usb_config is defined in kvm_config.json (#220) 2025-03-03 13:33:06 +01:00
Siyuan Miao a60d373849 fix(usb_config): check if usb_config is defined in kvm_config.json 2025-03-03 13:32:51 +01:00
Adam Shiervani 7e6a24800e
fix(build): Fix Buildate date (#218) 2025-03-03 11:44:36 +01:00
Adam Shiervani 7f43ba869f
fix(ui): Improve terminal data channel handling for cross-browser compatibility (#210)
- Add support for different binary data types (ArrayBuffer and Blob)
- Implement FileReader for converting Blob data in Firefox
- Send initial terminal size on data channel open
2025-02-28 14:29:08 +01:00
Adam Shiervani b499482c5d
chore(deps): Update UI dependencies to latest versions (#209) 2025-02-28 13:57:17 +01:00
Adam Shiervani e4bb4f288c
feat(cloud): Add support for custom cloud app URL configuration (#207)
* feat(cloud): Add support for custom cloud app URL configuration

- Extend CloudState and Config to include CloudAppURL
- Update RPC methods to handle both API and app URLs
- Modify cloud adoption and settings routes to support custom app URLs
- Remove hardcoded cloud app URL environment file
- Simplify cloud URL configuration in UI

* fix(cloud): Improve cloud URL configuration and adoption flow

- Update error handling in cloud URL configuration RPC method
- Modify cloud adoption route to support dynamic cloud URLs
- Remove hardcoded default cloud URLs in device access settings
- Refactor cloud adoption click handler to be more flexible

* refactor(cloud): Simplify cloud URL configuration RPC method

- Update rpcSetCloudUrl to return only an error
- Remove unnecessary boolean return value
- Improve error handling consistency

* refactor(ui): Simplify cloud provider configuration and URL handling
2025-02-28 13:48:52 +01:00
Adam Shiervani 482c64ad02
feat(ui): Add feature flag system (#208) 2025-02-28 12:49:55 +01:00
Adam Shiervani 543ef2114e
fix(ui): Navigate to root on cloud device access settings disconnection (#205) 2025-02-27 17:24:15 +01:00
Adam Shiervani a4863f6999
refactor(ui): Improve device access settings with conditional rendering and loader data handling (#204) 2025-02-27 17:18:56 +01:00
Adam Shiervani d49a567a38
refactor(ui): Extract device UI path generation to standalone function (#203) 2025-02-27 17:08:13 +01:00
Adam Shiervani a3355bb81c fix(ui): Update device route navigation to root on modal close 2025-02-27 16:59:43 +01:00
Adam Shiervani 4052b3d225
Move settings to modals & better modal handling (#194)
* feat(ui): Add other session handling route and modal

* feat(ui): Add dedicated update route and refactor update dialog state management

* feat(ui): Add local authentication route

* refactor(ui): Remove LocalAuthPasswordDialog component and clean up related code

* refactor(ui): Remove OtherSessionConnectedModal component

* feat(ui): Add dedicated mount route and refactor mount media dialog

* refactor(ui): Simplify Escape key navigation in device route

* refactor(ui): Add TODO comments for future URL-based state migration

* refactor(ui): Migrate settings and update routes to dedicated routes

This commit introduces a comprehensive refactoring of the UI routing and state management:
- Removed sidebar-based settings view
- Replaced global modal state with URL-based routing
- Added dedicated routes for settings, including general, security, and update sections
- Simplified modal and sidebar interactions
- Improved animation and transition handling using motion library
- Removed deprecated components and simplified route structure

* fix(ui): Add TODO comment for modal session interaction

* refactor(ui): Move USB configuration to new settings setup

This commit introduces several improvements to the USB configuration workflow:
- Refactored USB configuration dialog component
- Simplified USB config state management
- Moved USB configuration to hardware settings route
- Updated JSON-RPC type definitions
- Cleaned up unused imports and components
- Improved error handling and notifications

* refactor(ui): Replace react-router-dom navigation with custom navigation hook

This commit introduces a new custom navigation hook `useDeviceUiNavigation` to replace direct usage of `useNavigate` across multiple components:
- Removed direct `useNavigate` imports in various components
- Added `navigateTo` method from new navigation hook
- Updated navigation calls in ActionBar, MountPopover, UpdateInProgressStatusCard, and other routes
- Simplified navigation logic and prepared for potential future navigation enhancements
- Removed console logs and unnecessary comments

* refactor(ui): Remove unused react-router-dom import

Clean up unnecessary import of `useNavigate` from react-router-dom in device settings route

* feat(ui): Improve mobile navigation and scrolling in device settings

* refactor(ui): Reorganize device access and security settings

This commit introduces several changes to the device access and security settings:
- Renamed "Security" section to "Access" in settings navigation
- Moved local authentication routes from security to access
- Removed deprecated security settings route
- Added new route for device access settings with cloud and local authentication management
- Updated cloud URL and adoption logic to be part of the access settings
- Simplified routing and component structure for better user experience

* fix(ui): Update logout button hover state color

* fix(ui): Adjust device de-registration button size to small

* fix(ui): Update appearance settings section header and description

* refactor(ui): Replace SectionHeader with new SettingsPageHeader and SettingsSectionHeader components

This commit introduces two new header components for settings pages:
- Created SettingsPageHeader for main page headers
- Created SettingsSectionHeader for subsection headers
- Replaced all existing SectionHeader imports with new components
- Updated styling and type definitions to support more flexible header rendering

* feat(ui): Add dev channel toggle to advanced settings

Move dev channel update option from general settings to advanced settings
- Introduced new state and handler for dev channel toggle
- Removed dev channel option from general settings route
- Added dev channel toggle in advanced settings with error handling
2025-02-27 16:48:50 +01:00
jackislanding 77263e73f7
Feature/usb config - Rebasing USB Config Changes on Dev Branch (#185)
* rebasing on dev branch

* fixed formatting

* fixed formatting

* removed query params

* moved usb settings to hardware setting

* swapped from error to log

* added fix for any change to product name now resulting in show the spinner as custom on page reload

* formatting

---------

Co-authored-by: JackTheRooster <adrian@rydeas.com>
Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
2025-02-27 09:53:47 +01:00
Adam Shiervani 92aec30c8f
fix(ui): Fix & Update Ubuntu 24.04 LTS ISO download URL to latest version (#197) 2025-02-26 11:57:03 +01:00
Aveline ba0c937e2a
feat: prometheus metrics endpoint 2025-02-25 16:19:32 +01:00
Adam Shiervani f4a86a2d11
feat(dev): Add option to skip frontend build in dev_deploy.sh (#183) 2025-02-25 16:12:04 +01:00
Adam Shiervani 7304e6b672
feat(cloud): Add custom cloud API URL configuration support (#181)
* feat(cloud): Add custom cloud API URL configuration support

- Implement RPC methods to set, get, and reset cloud URL
- Update cloud registration to remove hardcoded cloud API URL
- Modify UI to allow configuring custom cloud API URL in developer settings
- Remove environment-specific cloud configuration files
- Simplify cloud URL configuration in UI config

* fix(ui): Update cloud app URL to production environment in device mode

* refactor(ui): Remove SIGNAL_API env & Rename to DEVICE_API to make clear distinction between  CLOUD_API and DEVICE_API.

* feat(ui): Only show Cloud API URL Change on device mode

* fix(cloud): Don't override the CloudURL on deregistration from the cloud.
2025-02-25 16:10:46 +01:00
SuperQ e1ea783fc7
Feature: Add a metrics endpoint
Add a basic Prometheus Monitoring metrics endpoint.
* Include a `jetkvm_build_info` metric.
* `go mod tidy`

Signed-off-by: SuperQ <superq@gmail.com>
2025-02-23 15:18:46 +01:00
Techno Tim de5403eada
fix(devcontainer): Map local ssh keys to container (#180) 2025-02-20 21:39:25 +01:00
Siyuan Miao ff3727b1fe chore: bump version to 0.3.7 2025-02-19 14:03:18 +01:00
Adam Shiervani d415afcea9
feat(ui): improve video playback and keyboard handling in WebRTCVideo (#176)
* feat(ui): improve video playback and keyboard handling in WebRTCVideo

* fix(ui): correct event handler naming in WebRTCVideo component
2025-02-19 14:00:15 +01:00
Adam Shiervani 368c1eea90
refactor(ui): simplify backlight settings handling (#175) 2025-02-19 11:38:11 +01:00
Aveline b38f899c84
fix(config): should return defaultConfig when config file doesnt exist (#174) 2025-02-19 10:27:24 +01:00
Siyuan Miao a3d21557c3 fix(config): should return defaultConfig when config file doesnt exist 2025-02-19 10:25:45 +01:00
Adam Shiervani 5e91cfc7fa
Add full dark mode support to FieldLabel component (#173) 2025-02-18 19:30:04 +01:00
Adam Shiervani 9ffdf0c4a6
feat(dev): use npx and update dev_device.sh to accept IP address as a command-line argument (#169) 2025-02-18 17:22:35 +01:00
Aveline 591d512b11
add extra logging and tune timeout settings for cloud (#167)
* chore(config): merge userConfig with defaultConfig and add a lock

* chore(cloud): add extra logging and tune timeout settings
2025-02-18 17:22:03 +01:00
Aveline 69461140e3
chore(config): merge userConfig with defaultConfig and add a lock (#164)
* chore(config): merge userConfig with defaultConfig and add a lock

* chore(config): remove lock for LoadConfig
2025-02-17 20:12:34 +01:00
Aveline cd333c4ebc
feat(extension): ATX/DC/Serial extension support 2025-02-17 18:37:47 +01:00
Adam Shiervani 1973a65635
feat(dev): change default npm dev script to device mode and add wrapper script to set environment variable and proxy signal api (#157) 2025-02-17 16:29:42 +01:00
Aveline f3b4dbce49
feat: use the api url from device config (#161) 2025-02-17 11:34:38 +01:00
Aveline 806792203f
feat(ntp): add delay between sync attempts and support NTP servers from DHCP (#162)
* feat(ntp): use ntp server from dhcp info

* feat(ntp): use ntp server from dhcp info

* feat(ntp): add delay between time sync attempts

* chore(ntp): more logging
2025-02-17 11:02:28 +01:00
Cameron Fleming 5217377175
feat(display.go): Add display brightness settings (#17)
* feat(display.go): impl setDisplayBrightness()

Implements setDisplayBrightness(brightness int) which allows setting the
backlight brightness on JetKVM's hardware.

Needs to be implemented into the RPC, config and frontend.

* feat(config): add backlight control settings

* feat(display): add automatic dimming & switch off to display

WIP, dims the display to 50% of the BacklightMaxBrightness after
BacklightDimAfterMS expires. Turns the display off after
BacklightOffAfterMS

* feat(rpc): add methods to get and set BacklightSettings

* WIP: feat(settings): add Max backlight setting

* chore: use constant for backlight control file

* fix: only attempt to wake the display if it's off

* feat(display): wake on touch

* fix: re-use buffer between reads

* fix: wakeDisplay() on start to fix warm start issue

If the application had turned off the display before exiting, it
wouldn't be brought on when the application restarted without a device
reboot.

* chore: various comment & string updates

* fix: newline on set brightness log

Noticed by @eric
https://github.com/jetkvm/kvm/pull/17#discussion_r1903338705

* fix: set default value for display

Set the DisplayMaxBrightness to the default brightness used
out-of-the-box by JetKVM. Also sets the dim/timeout to 2 minutes and 30
mintes respectively.

* feat(display.go): use tickers to countdown to dim/off

As suggested by tutman in https://github.com/jetkvm/kvm/pull/17, use
tickers set to the duration of dim/off to avoid a loop running every
second. The tickers are reset to the dim/off times whenever
wakeDisplay() is called.

* chore: update config

Changed Dim & Off values to seconds instead of milliseconds, there's no
need for it to be that precise.

* feat(display.go): wakeDisplay() force

Adds the force boolean to wakedisplay() which allows skipping the
backlightState == 0 check, this means you can force a ticker reset, even
if the display is currently in the "full bright" state

* feat(display.go): move tickers into their own method

This allows them to only be started if necessary. If the user has chosen
to keep the display on and not-dimmed all the time, the tickers can't
start as their tick value must be a positive integer.

* feat(display.go): stop tickers when auto-dim/auto-off is disabled

* feat(rpc): implement display backlight control methods

* feat(ui): implement display backlight control

* chore: update variable names

As part of @joshuasing's review on #17, updated variables & constants to
match the Go best practices.

Signed-off-by: Cameron Fleming <cameron@nevexo.space>

* fix(display): move backlightTicker setup into screen setup goroutine

Signed-off-by: Cameron Fleming <cameron@nevexo.space>

* chore: fix some start-up timing issues

* fix(display): Don't attempt to start the tickers if the display is disabled

If max_brightness is zero, then there's no point in trying to dim it (or
turn it off...)

* fix: wakeDisplay() doesn't need to stop the tickers

The tickers only need to be reset, if they're disabled, they won't have
been started.

* fix: Don't wake up the display if it's turned off

---------

Signed-off-by: Cameron Fleming <cameron@nevexo.space>
2025-02-17 11:00:28 +01:00
Andrew 951173ba19
Restart mDNS every time the connection information changes (#155) 2025-02-13 18:10:47 +01:00
Cameron Fleming 2a99c2db9d
fix(net): stop dhcp client and release all v4 addr on linkdown (#16)
This commit fixes jetkvm/kvm#12 by disabling the udhcpc client when the
link goes down, it then removes all the active IPv4 addresses from the
deivce.

Once the link comes back up, it re-activates the udhcpc client so it can
fetch a new IPv4 address for the device.

This doesn't make any changes to the IPv6 side of things yet.
2025-02-13 15:41:10 +01:00
Cameron Fleming 0b5033f798
feat: restore EDID on reboot (#34)
This commit adds the config entry "EdidString" and saves the EDID string
when it's modified via the RPC.

The EDID is restored when the jetkvm_native control socket connects
(usually at boot)

Signed-off-by: Cameron Fleming <cameron@nevexo.space>
2025-02-13 14:42:07 +01:00
Scai d07bedb323
Invert colors on Icons (#123)
* feat(ui): invert colors on icons

* feat(ui): fix tailwindcss class for invert
2025-02-13 14:33:03 +01:00
Dominik Heidler aa0f38bc0b
Add openSUSE ISOs (#151) 2025-02-13 14:05:07 +01:00
Andrew Nicholson 63b3ef0151
Enable "Boot Interface Subclass" for keyboard and mouse. (#113)
This is often required for the keyboard/mouse to be recognized in
BIOS/UEFI firmware.
2025-02-12 15:08:03 +01:00
Brandon Tuttle 69168ff062
Fix fullscreen video relative mouse movements (#85) 2025-02-11 20:00:50 +01:00
Aveline 0d7efe5c0e
feat: add ICE servers and local IP address returned by the API to fix connectivity issues behind NAT (#146)
Add ICE servers and local IP address returned by the API to fix connectivity issues behind NAT
2025-02-11 15:45:14 +01:00
Brandon Tuttle 15768ee0ab
Remove rounded corners from video stream (#86) 2025-02-11 15:13:41 +01:00
Antony Messerli 2e8ea8cccc
Update to latest ISO versions (#78)
* Fedora 38 is EOL, bump to 41 and use main Fedora mirror
* Bumps Arch Linux and Debian to latest builds
2025-02-11 15:13:29 +01:00
Brandon Tuttle 727561738e
Clean up native subprocess is main process dies (#19) 2025-02-11 14:55:02 +01:00
Cameron Fleming a9767b650c
fix(cloud): only start WS Client if config.CloudToken is set (#27) 2025-02-11 14:51:18 +01:00
115 changed files with 7317 additions and 3137 deletions

View File

@ -6,5 +6,9 @@
// Should match what is defined in ui/package.json
"version": "21.1.0"
}
}
},
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
]
}

142
.github/workflows/build.yml vendored Normal file
View File

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

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

@ -0,0 +1,34 @@
---
name: golangci-lint
on:
push:
paths:
- "go.sum"
- "go.mod"
- "**.go"
- ".github/workflows/golangci-lint.yml"
- ".golangci.yml"
pull_request:
permissions: # added using https://github.com/step-security/secure-repo
contents: read
jobs:
golangci:
permissions:
contents: read # for actions/checkout to fetch code
pull-requests: read # for golangci/golangci-lint-action to fetch pull requests
name: lint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Go
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version: 1.23.x
- name: Lint
uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1
with:
args: --verbose
version: v1.62.0

12
.golangci.yml Normal file
View File

@ -0,0 +1,12 @@
---
linters:
enable:
# - goimports
# - misspell
# - revive
issues:
exclude-rules:
- path: _test.go
linters:
- errcheck

View File

@ -1,17 +1,26 @@
VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.4
BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.3.8-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.7
GO_LDFLAGS := \
-s -w \
-X github.com/prometheus/common/version.Branch=$(BRANCH) \
-X github.com/prometheus/common/version.BuildDate=$(BUILDDATE) \
-X github.com/prometheus/common/version.Revision=$(REVISION)
hash_resource:
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
build_dev: hash_resource
@echo "Building..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X kvm.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
frontend:
cd ui && npm ci && npm run build:device
dev_release: build_dev
dev_release: frontend build_dev
@echo "Uploading release..."
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
@ -19,7 +28,7 @@ dev_release: build_dev
build_release: frontend hash_resource
@echo "Building release..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
release:
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \

View File

@ -3,7 +3,6 @@ package kvm
import (
"context"
"errors"
"log"
"net"
"os"
"time"
@ -94,7 +93,8 @@ func (d *NBDDevice) Start() error {
// Remove the socket file if it already exists
if _, err := os.Stat(nbdSocketPath); err == nil {
if err := os.Remove(nbdSocketPath); err != nil {
log.Fatalf("Failed to remove existing socket file %s: %v", nbdSocketPath, err)
logger.Errorf("Failed to remove existing socket file %s: %v", nbdSocketPath, err)
os.Exit(1)
}
}
@ -134,7 +134,7 @@ func (d *NBDDevice) runServerConn() {
MaximumBlockSize: uint32(16 * 1024),
SupportsMultiConn: false,
})
log.Println("nbd server exited:", err)
logger.Infof("nbd server exited: %v", err)
}
func (d *NBDDevice) runClientConn() {
@ -142,14 +142,14 @@ func (d *NBDDevice) runClientConn() {
ExportName: "jetkvm",
BlockSize: uint32(4 * 1024),
})
log.Println("nbd client exited:", err)
logger.Infof("nbd client exited: %w", err)
}
func (d *NBDDevice) Close() {
if d.dev != nil {
err := client.Disconnect(d.dev)
if err != nil {
log.Println("error disconnecting nbd client:", err)
logger.Warnf("error disconnecting nbd client: %v", err)
}
_ = d.dev.Close()
}

View File

@ -7,13 +7,14 @@ import (
"fmt"
"net/http"
"net/url"
"github.com/coder/websocket/wsjson"
"time"
"github.com/coder/websocket/wsjson"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
)
type CloudRegisterRequest struct {
@ -23,6 +24,18 @@ type CloudRegisterRequest struct {
ClientId string `json:"clientId"`
}
const (
// CloudWebSocketConnectTimeout is the timeout for the websocket connection to the cloud
CloudWebSocketConnectTimeout = 1 * time.Minute
// CloudAPIRequestTimeout is the timeout for cloud API requests
CloudAPIRequestTimeout = 10 * time.Second
// CloudOidcRequestTimeout is the timeout for OIDC token verification requests
// should be lower than the websocket response timeout set in cloud-api
CloudOidcRequestTimeout = 10 * time.Second
// CloudWebSocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
CloudWebSocketPingInterval = 15 * time.Second
)
func handleCloudRegister(c *gin.Context) {
var req CloudRegisterRequest
@ -43,22 +56,31 @@ func handleCloudRegister(c *gin.Context) {
return
}
resp, err := http.Post(req.CloudAPI+"/devices/token", "application/json", bytes.NewBuffer(jsonPayload))
client := &http.Client{Timeout: CloudAPIRequestTimeout}
apiReq, err := http.NewRequest(http.MethodPost, config.CloudURL+"/devices/token", bytes.NewBuffer(jsonPayload))
if err != nil {
c.JSON(500, gin.H{"error": "Failed to create register request: " + err.Error()})
return
}
apiReq.Header.Set("Content-Type", "application/json")
apiResp, err := client.Do(apiReq)
if err != nil {
c.JSON(500, gin.H{"error": "Failed to exchange token: " + err.Error()})
return
}
defer resp.Body.Close()
defer apiResp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(resp.StatusCode, gin.H{"error": "Failed to exchange token: " + resp.Status})
if apiResp.StatusCode != http.StatusOK {
c.JSON(apiResp.StatusCode, gin.H{"error": "Failed to exchange token: " + apiResp.Status})
return
}
var tokenResp struct {
SecretToken string `json:"secretToken"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
if err := json.NewDecoder(apiResp.Body).Decode(&tokenResp); err != nil {
c.JSON(500, gin.H{"error": "Failed to parse token response: " + err.Error()})
return
}
@ -68,8 +90,12 @@ func handleCloudRegister(c *gin.Context) {
return
}
if config.CloudToken == "" {
cloudLogger.Info("Starting websocket client due to adoption")
go RunWebsocketClient()
}
config.CloudToken = tokenResp.SecretToken
config.CloudURL = req.CloudAPI
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
if err != nil {
@ -116,7 +142,7 @@ func runWebsocketClient() error {
header := http.Header{}
header.Set("X-Device-ID", GetDeviceID())
header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Minute)
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
defer cancelDial()
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header,
@ -124,16 +150,16 @@ func runWebsocketClient() error {
if err != nil {
return err
}
defer c.CloseNow()
logger.Infof("WS connected to %v", wsURL.String())
defer c.CloseNow() //nolint:errcheck
cloudLogger.Infof("websocket connected to %s", wsURL)
runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun()
go func() {
for {
time.Sleep(15 * time.Second)
time.Sleep(CloudWebSocketPingInterval)
err := c.Ping(runCtx)
if err != nil {
logger.Warnf("websocket ping error: %v", err)
cloudLogger.Warnf("websocket ping error: %v", err)
cancelRun()
return
}
@ -151,24 +177,30 @@ func runWebsocketClient() error {
var req WebRTCSessionRequest
err = json.Unmarshal(msg, &req)
if err != nil {
logger.Warnf("unable to parse ws message: %v", string(msg))
cloudLogger.Warnf("unable to parse ws message: %v", string(msg))
continue
}
cloudLogger.Infof("new session request: %v", req.OidcGoogle)
cloudLogger.Tracef("session request info: %v", req)
err = handleSessionRequest(runCtx, c, req)
if err != nil {
logger.Infof("error starting new session: %v", err)
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, time.Minute)
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
defer cancelOIDC()
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
if err != nil {
fmt.Println("Failed to initialize OIDC provider:", err)
_ = wsjson.Write(context.Background(), c, gin.H{
"error": fmt.Sprintf("failed to initialize OIDC provider: %v", err),
})
cloudLogger.Errorf("failed to initialize OIDC provider: %v", err)
return err
}
@ -184,10 +216,15 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
googleIdentity := idToken.Audience[0] + ":" + idToken.Subject
if config.GoogleIdentity != googleIdentity {
_ = wsjson.Write(context.Background(), c, gin.H{"error": "google identity mismatch"})
return fmt.Errorf("google identity mismatch")
}
session, err := newSession()
session, err := newSession(SessionConfig{
ICEServers: req.ICEServers,
LocalIP: req.IP,
IsCloud: true,
})
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
return err
@ -206,6 +243,9 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
_ = peerConn.Close()
}()
}
cloudLogger.Info("new session accepted")
cloudLogger.Tracef("new session accepted: %v", session)
currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"sd": sd})
return nil
@ -215,7 +255,7 @@ func RunWebsocketClient() {
for {
err := runWebsocketClient()
if err != nil {
fmt.Println("Websocket client error:", err)
cloudLogger.Errorf("websocket client error: %v", err)
time.Sleep(5 * time.Second)
}
}
@ -224,12 +264,14 @@ func RunWebsocketClient() {
type CloudState struct {
Connected bool `json:"connected"`
URL string `json:"url,omitempty"`
AppURL string `json:"appUrl,omitempty"`
}
func rpcGetCloudState() CloudState {
return CloudState{
Connected: config.CloudToken != "" && config.CloudURL != "",
URL: config.CloudURL,
AppURL: config.CloudAppURL,
}
}
@ -244,7 +286,7 @@ func rpcDeregisterDevice() error {
}
req.Header.Set("Authorization", "Bearer "+config.CloudToken)
client := &http.Client{Timeout: 10 * time.Second}
client := &http.Client{Timeout: CloudAPIRequestTimeout}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send deregister request: %w", err)
@ -257,8 +299,8 @@ func rpcDeregisterDevice() error {
// (e.g., wrong cloud token, already deregistered). Regardless of the reason, we can safely remove it.
if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
config.CloudToken = ""
config.CloudURL = ""
config.GoogleIdentity = ""
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save configuration after deregistering: %w", err)
}

View File

@ -1,7 +1,7 @@
package main
import (
"kvm"
"github.com/jetkvm/kvm"
)
func main() {

View File

@ -4,6 +4,9 @@ import (
"encoding/json"
"fmt"
"os"
"sync"
"github.com/jetkvm/kvm/internal/usbgadget"
)
type WakeOnLanDevice struct {
@ -12,51 +15,100 @@ type WakeOnLanDevice struct {
}
type Config struct {
CloudURL string `json:"cloud_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
IncludePreRelease bool `json:"include_pre_release"`
HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
CloudURL string `json:"cloud_url"`
CloudAppURL string `json:"cloud_app_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
IncludePreRelease bool `json:"include_pre_release"`
HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
EdidString string `json:"hdmi_edid_string"`
ActiveExtension string `json:"active_extension"`
DisplayMaxBrightness int `json:"display_max_brightness"`
DisplayDimAfterSec int `json:"display_dim_after_sec"`
DisplayOffAfterSec int `json:"display_off_after_sec"`
TLSMode string `json:"tls_mode"`
UsbConfig *usbgadget.Config `json:"usb_config"`
UsbDevices *usbgadget.Devices `json:"usb_devices"`
}
const configPath = "/userdata/kvm_config.json"
var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
TLSMode: "",
UsbConfig: &usbgadget.Config{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
SerialNumber: "",
Manufacturer: "JetKVM",
Product: "USB Emulation Device",
},
UsbDevices: &usbgadget.Devices{
AbsoluteMouse: true,
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
},
}
var config *Config
var (
config *Config
configLock = &sync.Mutex{}
)
func LoadConfig() {
configLock.Lock()
defer configLock.Unlock()
if config != nil {
logger.Info("config already loaded, skipping")
return
}
// load the default config
config = defaultConfig
file, err := os.Open(configPath)
if err != nil {
logger.Debug("default config file doesn't exist, using default")
config = defaultConfig
return
}
defer file.Close()
var loadedConfig Config
// load and merge the default config with the user config
loadedConfig := *defaultConfig
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Errorf("config file JSON parsing failed, %v", err)
config = defaultConfig
return
}
// merge the user config with the default config
if loadedConfig.UsbConfig == nil {
loadedConfig.UsbConfig = defaultConfig.UsbConfig
}
if loadedConfig.UsbDevices == nil {
loadedConfig.UsbDevices = defaultConfig.UsbDevices
}
config = &loadedConfig
}
func SaveConfig() error {
configLock.Lock()
defer configLock.Unlock()
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
@ -71,3 +123,9 @@ func SaveConfig() error {
return nil
}
func ensureConfigLoaded() {
if config == nil {
LoadConfig()
}
}

View File

@ -10,6 +10,7 @@ show_help() {
echo
echo "Optional:"
echo " -u, --user <remote_user> Remote username (default: root)"
echo " --skip-ui-build Skip frontend/UI build"
echo " --help Display this help message"
echo
echo "Example:"
@ -21,6 +22,7 @@ show_help() {
# Default values
REMOTE_USER="root"
REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
@ -33,6 +35,10 @@ while [[ $# -gt 0 ]]; do
REMOTE_USER="$2"
shift 2
;;
--skip-ui-build)
SKIP_UI_BUILD=true
shift
;;
--help)
show_help
exit 0
@ -52,12 +58,17 @@ if [ -z "$REMOTE_HOST" ]; then
fi
# Build the development version on the host
make frontend
if [ "$SKIP_UI_BUILD" = false ]; then
make frontend
fi
make build_dev
# Change directory to the binary output directory
cd bin
# Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host
cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug"
@ -79,8 +90,7 @@ cd "$REMOTE_PATH"
chmod +x jetkvm_app_debug
# Run the application in the background
./jetkvm_app_debug
PION_LOG_TRACE=jetkvm,cloud ./jetkvm_app_debug
EOF
echo "Deployment complete."

View File

@ -1,17 +1,30 @@
package kvm
import (
"errors"
"fmt"
"log"
"os"
"strconv"
"time"
)
var currentScreen = "ui_Boot_Screen"
var backlightState = 0 // 0 - NORMAL, 1 - DIMMED, 2 - OFF
var (
dimTicker *time.Ticker
offTicker *time.Ticker
)
const (
touchscreenDevice string = "/dev/input/event1"
backlightControlClass string = "/sys/class/backlight/backlight/brightness"
)
func switchToScreen(screen string) {
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
if err != nil {
log.Printf("failed to switch to screen %s: %v", screen, err)
logger.Warnf("failed to switch to screen %s: %v", screen, err)
return
}
currentScreen = screen
@ -27,7 +40,7 @@ func updateLabelIfChanged(objName string, newText string) {
}
func switchToScreenIfDifferent(screenName string) {
fmt.Println("switching screen from", currentScreen, screenName)
logger.Infof("switching screen from %s to %s", currentScreen, screenName)
if currentScreen != screenName {
switchToScreen(screenName)
}
@ -61,11 +74,12 @@ var displayInited = false
func requestDisplayUpdate() {
if !displayInited {
fmt.Println("display not inited, skipping updates")
logger.Info("display not inited, skipping updates")
return
}
go func() {
fmt.Println("display updating........................")
wakeDisplay(false)
logger.Info("display updating")
//TODO: only run once regardless how many pending updates
updateDisplay()
}()
@ -83,14 +97,169 @@ func updateStaticContents() {
updateLabelIfChanged("ui_Status_Content_Device_Id_Content_Label", GetDeviceID())
}
// setDisplayBrightness sets /sys/class/backlight/backlight/brightness to alter
// the backlight brightness of the JetKVM hardware's display.
func setDisplayBrightness(brightness int) error {
// NOTE: The actual maximum value for this is 255, but out-of-the-box, the value is set to 64.
// The maximum set here is set to 100 to reduce the risk of drawing too much power (and besides, 255 is very bright!).
if brightness > 100 || brightness < 0 {
return errors.New("brightness value out of bounds, must be between 0 and 100")
}
// Check the display backlight class is available
if _, err := os.Stat(backlightControlClass); errors.Is(err, os.ErrNotExist) {
return errors.New("brightness value cannot be set, possibly not running on JetKVM hardware")
}
// Set the value
bs := []byte(strconv.Itoa(brightness))
err := os.WriteFile(backlightControlClass, bs, 0644)
if err != nil {
return err
}
logger.Infof("display: set brightness to %v", brightness)
return nil
}
// tick_displayDim() is called when when dim ticker expires, it simply reduces the brightness
// of the display by half of the max brightness.
func tick_displayDim() {
err := setDisplayBrightness(config.DisplayMaxBrightness / 2)
if err != nil {
logger.Warnf("display: failed to dim display: %s", err)
}
dimTicker.Stop()
backlightState = 1
}
// tick_displayOff() is called when the off ticker expires, it turns off the display
// by setting the brightness to zero.
func tick_displayOff() {
err := setDisplayBrightness(0)
if err != nil {
logger.Warnf("display: failed to turn off display: %s", err)
}
offTicker.Stop()
backlightState = 2
}
// wakeDisplay sets the display brightness back to config.DisplayMaxBrightness and stores the time the display
// last woke, ready for displayTimeoutTick to put the display back in the dim/off states.
// Set force to true to skip the backlight state check, this should be done if altering the tickers.
func wakeDisplay(force bool) {
if backlightState == 0 && !force {
return
}
// Don't try to wake up if the display is turned off.
if config.DisplayMaxBrightness == 0 {
return
}
err := setDisplayBrightness(config.DisplayMaxBrightness)
if err != nil {
logger.Warnf("display wake failed, %s", err)
}
if config.DisplayDimAfterSec != 0 {
dimTicker.Reset(time.Duration(config.DisplayDimAfterSec) * time.Second)
}
if config.DisplayOffAfterSec != 0 {
offTicker.Reset(time.Duration(config.DisplayOffAfterSec) * time.Second)
}
backlightState = 0
}
// watchTsEvents monitors the touchscreen for events and simply calls wakeDisplay() to ensure the
// touchscreen interface still works even with LCD dimming/off.
// TODO: This is quite a hack, really we should be getting an event from jetkvm_native, or the whole display backlight
// control should be hoisted up to jetkvm_native.
func watchTsEvents() {
ts, err := os.OpenFile(touchscreenDevice, os.O_RDONLY, 0666)
if err != nil {
logger.Warnf("display: failed to open touchscreen device: %s", err)
return
}
defer ts.Close()
// This buffer is set to 24 bytes as that's the normal size of events on /dev/input
// Reference: https://www.kernel.org/doc/Documentation/input/input.txt
// This could potentially be set higher, to require multiple events to wake the display.
buf := make([]byte, 24)
for {
_, err := ts.Read(buf)
if err != nil {
logger.Warnf("display: failed to read from touchscreen device: %s", err)
return
}
wakeDisplay(false)
}
}
// startBacklightTickers starts the two tickers for dimming and switching off the display
// if they're not already set. This is done separately to the init routine as the "never dim"
// option has the value set to zero, but time.NewTicker only accept positive values.
func startBacklightTickers() {
// Don't start the tickers if the display is switched off.
// Set the display to off if that's the case.
if config.DisplayMaxBrightness == 0 {
_ = setDisplayBrightness(0)
return
}
if dimTicker == nil && config.DisplayDimAfterSec != 0 {
logger.Info("display: dim_ticker has started")
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
defer dimTicker.Stop()
go func() {
for { //nolint:gosimple
select {
case <-dimTicker.C:
tick_displayDim()
}
}
}()
}
if offTicker == nil && config.DisplayOffAfterSec != 0 {
logger.Info("display: off_ticker has started")
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
defer offTicker.Stop()
go func() {
for { //nolint:gosimple
select {
case <-offTicker.C:
tick_displayOff()
}
}
}()
}
}
func init() {
ensureConfigLoaded()
go func() {
waitCtrlClientConnected()
fmt.Println("setting initial display contents")
logger.Info("setting initial display contents")
time.Sleep(500 * time.Millisecond)
updateStaticContents()
displayInited = true
fmt.Println("display inited")
logger.Info("display inited")
startBacklightTickers()
wakeDisplay(true)
requestDisplayUpdate()
}()
go watchTsEvents()
}

View File

@ -2,7 +2,6 @@ package kvm
import (
"context"
"fmt"
"os"
"sync"
"syscall"
@ -104,7 +103,7 @@ func RunFuseServer() {
var err error
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
if err != nil {
fmt.Println("failed to mount fuse: %w", err)
logger.Warnf("failed to mount fuse: %v", err)
}
fuseServer.Wait()
}

29
go.mod
View File

@ -1,4 +1,4 @@
module kvm
module github.com/jetkvm/kvm
go 1.21.0
@ -14,24 +14,30 @@ require (
github.com/google/uuid v1.6.0
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
github.com/hanwen/go-fuse/v2 v2.5.1
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965
github.com/hashicorp/go-envparse v0.1.0
github.com/pion/logging v0.2.2
github.com/pion/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.0.0
github.com/pojntfx/go-nbd v0.3.2
github.com/prometheus/client_golang v1.21.0
github.com/prometheus/common v0.62.0
github.com/psanford/httpreadat v0.1.0
github.com/vishvananda/netlink v1.3.0
golang.org/x/crypto v0.28.0
golang.org/x/net v0.30.0
go.bug.st/serial v1.6.2
golang.org/x/crypto v0.31.0
golang.org/x/net v0.33.0
)
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/creack/goselect v0.1.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
@ -40,12 +46,13 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pilebones/go-udev v0.9.0 // indirect
github.com/pion/datachannel v1.5.9 // indirect
@ -61,16 +68,16 @@ require (
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
google.golang.org/protobuf v1.34.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

72
go.sum
View File

@ -2,10 +2,14 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM=
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
@ -16,7 +20,8 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -40,8 +45,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -49,22 +54,26 @@ github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ=
github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY=
github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@ -76,8 +85,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 h1:bZGtUfkOl0dqvem8ltx9KCYied0gSlRuDhaZDxgppN4=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965/go.mod h1:6cAIK2c4O3/yETSrRjmNwsBL3yE4Vcu9M9p/Qwx5+gM=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q=
@ -114,14 +123,20 @@ github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA=
github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -132,8 +147,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@ -144,32 +160,32 @@ github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1Y
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

4
hw.go
View File

@ -14,7 +14,7 @@ func extractSerialNumber() (string, error) {
return "", err
}
r, err := regexp.Compile("Serial\\s*:\\s*(\\S+)")
r, err := regexp.Compile(`Serial\s*:\s*(\S+)`)
if err != nil {
return "", fmt.Errorf("failed to compile regex: %w", err)
}
@ -27,7 +27,7 @@ func extractSerialNumber() (string, error) {
return matches[1], nil
}
func readOtpEntropy() ([]byte, error) {
func readOtpEntropy() ([]byte, error) { //nolint:unused
content, err := os.ReadFile("/sys/bus/nvmem/devices/rockchip-otp0/nvmem")
if err != nil {
return nil, err

View File

@ -0,0 +1,327 @@
package usbgadget
import (
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"sort"
)
type gadgetConfigItem struct {
order uint
device string
path []string
attrs gadgetAttributes
configAttrs gadgetAttributes
configPath []string
reportDesc []byte
}
type gadgetAttributes map[string]string
type gadgetConfigItemWithKey struct {
key string
item gadgetConfigItem
}
type orderedGadgetConfigItems []gadgetConfigItemWithKey
var defaultGadgetConfig = map[string]gadgetConfigItem{
"base": {
order: 0,
attrs: gadgetAttributes{
"bcdUSB": "0x0200", // USB 2.0
"idVendor": "0x1d6b", // The Linux Foundation
"idProduct": "0104", // Multifunction Composite Gadget
"bcdDevice": "0100",
},
configAttrs: gadgetAttributes{
"MaxPower": "250", // in unit of 2mA
},
},
"base_info": {
order: 1,
path: []string{"strings", "0x409"},
configPath: []string{"strings", "0x409"},
attrs: gadgetAttributes{
"serialnumber": "",
"manufacturer": "JetKVM",
"product": "JetKVM USB Emulation Device",
},
configAttrs: gadgetAttributes{
"configuration": "Config 1: HID",
},
},
// keyboard HID
"keyboard": keyboardConfig,
// mouse HID
"absolute_mouse": absoluteMouseConfig,
// relative mouse HID
"relative_mouse": relativeMouseConfig,
// mass storage
"mass_storage_base": massStorageBaseConfig,
"mass_storage_lun0": massStorageLun0Config,
}
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
switch itemKey {
case "absolute_mouse":
return u.enabledDevices.AbsoluteMouse
case "relative_mouse":
return u.enabledDevices.RelativeMouse
case "keyboard":
return u.enabledDevices.Keyboard
case "mass_storage_base":
return u.enabledDevices.MassStorage
case "mass_storage_lun0":
return u.enabledDevices.MassStorage
default:
return true
}
}
func (u *UsbGadget) loadGadgetConfig() {
if u.customConfig.isEmpty {
u.log.Trace("using default gadget config")
return
}
u.configMap["base"].attrs["idVendor"] = u.customConfig.VendorId
u.configMap["base"].attrs["idProduct"] = u.customConfig.ProductId
u.configMap["base_info"].attrs["serialnumber"] = u.customConfig.SerialNumber
u.configMap["base_info"].attrs["manufacturer"] = u.customConfig.Manufacturer
u.configMap["base_info"].attrs["product"] = u.customConfig.Product
}
func (u *UsbGadget) SetGadgetConfig(config *Config) {
u.configLock.Lock()
defer u.configLock.Unlock()
if config == nil {
return // nothing to do
}
u.customConfig = *config
u.loadGadgetConfig()
}
func (u *UsbGadget) SetGadgetDevices(devices *Devices) {
u.configLock.Lock()
defer u.configLock.Unlock()
if devices == nil {
return // nothing to do
}
u.enabledDevices = *devices
}
// GetConfigPath returns the path to the config item.
func (u *UsbGadget) GetConfigPath(itemKey string) (string, error) {
item, ok := u.configMap[itemKey]
if !ok {
return "", fmt.Errorf("config item %s not found", itemKey)
}
return joinPath(u.kvmGadgetPath, item.configPath), nil
}
func mountConfigFS() error {
_, err := os.Stat(gadgetPath)
// TODO: check if it's mounted properly
if err == nil {
return nil
}
if os.IsNotExist(err) {
err = exec.Command("mount", "-t", "configfs", "none", configFSPath).Run()
if err != nil {
return fmt.Errorf("failed to mount configfs: %w", err)
}
} else {
return fmt.Errorf("unable to access usb gadget path: %w", err)
}
return nil
}
func (u *UsbGadget) Init() error {
u.configLock.Lock()
defer u.configLock.Unlock()
u.loadGadgetConfig()
udcs := getUdcs()
if len(udcs) < 1 {
u.log.Error("no udc found, skipping USB stack init")
return nil
}
u.udc = udcs[0]
_, err := os.Stat(u.kvmGadgetPath)
if err == nil {
u.log.Info("usb gadget already exists")
}
if err := mountConfigFS(); err != nil {
u.log.Errorf("failed to mount configfs: %v, usb stack might not function properly", err)
}
if err := os.MkdirAll(u.configC1Path, 0755); err != nil {
u.log.Errorf("failed to create config path: %v", err)
}
if err := u.writeGadgetConfig(); err != nil {
u.log.Errorf("failed to start gadget: %v", err)
}
return nil
}
func (u *UsbGadget) UpdateGadgetConfig() error {
u.configLock.Lock()
defer u.configLock.Unlock()
u.loadGadgetConfig()
if err := u.writeGadgetConfig(); err != nil {
u.log.Errorf("failed to update gadget: %v", err)
}
return nil
}
func (u *UsbGadget) getOrderedConfigItems() orderedGadgetConfigItems {
items := make([]gadgetConfigItemWithKey, 0)
for key, item := range u.configMap {
items = append(items, gadgetConfigItemWithKey{key, item})
}
sort.Slice(items, func(i, j int) bool {
return items[i].item.order < items[j].item.order
})
return items
}
func (u *UsbGadget) writeGadgetConfig() error {
// create kvm gadget path
err := os.MkdirAll(u.kvmGadgetPath, 0755)
if err != nil {
return err
}
u.log.Tracef("writing gadget config")
for _, val := range u.getOrderedConfigItems() {
key := val.key
item := val.item
// check if the item is enabled in the config
if !u.isGadgetConfigItemEnabled(key) {
u.log.Tracef("disabling gadget config: %s", key)
err = u.disableGadgetItemConfig(item)
if err != nil {
return err
}
continue
}
u.log.Tracef("writing gadget config: %s", key)
err = u.writeGadgetItemConfig(item)
if err != nil {
return err
}
}
if err = u.writeUDC(); err != nil {
u.log.Errorf("failed to write UDC: %v", err)
return err
}
if err = u.rebindUsb(true); err != nil {
u.log.Infof("failed to rebind usb: %v", err)
}
return nil
}
func (u *UsbGadget) disableGadgetItemConfig(item gadgetConfigItem) error {
// remove symlink if exists
if item.configPath == nil {
return nil
}
configPath := joinPath(u.configC1Path, item.configPath)
if _, err := os.Lstat(configPath); os.IsNotExist(err) {
u.log.Tracef("symlink %s does not exist", item.configPath)
return nil
}
if err := os.Remove(configPath); err != nil {
return fmt.Errorf("failed to remove symlink %s: %w", item.configPath, err)
}
return nil
}
func (u *UsbGadget) writeGadgetItemConfig(item gadgetConfigItem) error {
// create directory for the item
gadgetItemPath := joinPath(u.kvmGadgetPath, item.path)
err := os.MkdirAll(gadgetItemPath, 0755)
if err != nil {
return fmt.Errorf("failed to create path %s: %w", gadgetItemPath, err)
}
if len(item.attrs) > 0 {
// write attributes for the item
err = u.writeGadgetAttrs(gadgetItemPath, item.attrs)
if err != nil {
return fmt.Errorf("failed to write attributes for %s: %w", gadgetItemPath, err)
}
}
// write report descriptor if available
if item.reportDesc != nil {
err = u.writeIfDifferent(path.Join(gadgetItemPath, "report_desc"), item.reportDesc, 0644)
if err != nil {
return err
}
}
// create config directory if configAttrs are set
if len(item.configAttrs) > 0 {
configItemPath := joinPath(u.configC1Path, item.configPath)
err = os.MkdirAll(configItemPath, 0755)
if err != nil {
return fmt.Errorf("failed to create path %s: %w", configItemPath, err)
}
err = u.writeGadgetAttrs(configItemPath, item.configAttrs)
if err != nil {
return fmt.Errorf("failed to write config attributes for %s: %w", configItemPath, err)
}
}
// create symlink if configPath is set
if item.configPath != nil && item.configAttrs == nil {
configPath := joinPath(u.configC1Path, item.configPath)
u.log.Tracef("Creating symlink from %s to %s", configPath, gadgetItemPath)
if err := ensureSymlink(configPath, gadgetItemPath); err != nil {
return err
}
}
return nil
}
func (u *UsbGadget) writeGadgetAttrs(basePath string, attrs gadgetAttributes) error {
for key, val := range attrs {
filePath := filepath.Join(basePath, key)
err := u.writeIfDifferent(filePath, []byte(val), 0644)
if err != nil {
return fmt.Errorf("failed to write to %s: %w", filePath, err)
}
}
return nil
}

View File

@ -0,0 +1,3 @@
package usbgadget
const dwc3Path = "/sys/bus/platform/drivers/dwc3"

11
internal/usbgadget/hid.go Normal file
View File

@ -0,0 +1,11 @@
package usbgadget
import "time"
func (u *UsbGadget) resetUserInputTime() {
u.lastUserInput = time.Now()
}
func (u *UsbGadget) GetLastUserInputTime() time.Time {
return u.lastUserInput
}

View File

@ -0,0 +1,95 @@
package usbgadget
import (
"fmt"
"os"
)
var keyboardConfig = gadgetConfigItem{
order: 1000,
device: "hid.usb0",
path: []string{"functions", "hid.usb0"},
configPath: []string{"hid.usb0"},
attrs: gadgetAttributes{
"protocol": "1",
"subclass": "1",
"report_length": "8",
},
reportDesc: keyboardReportDesc,
}
// Source: https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt
var keyboardReportDesc = []byte{
0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */
0x09, 0x06, /* USAGE (Keyboard) */
0xa1, 0x01, /* COLLECTION (Application) */
0x05, 0x07, /* USAGE_PAGE (Keyboard) */
0x19, 0xe0, /* USAGE_MINIMUM (Keyboard LeftControl) */
0x29, 0xe7, /* USAGE_MAXIMUM (Keyboard Right GUI) */
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
0x25, 0x01, /* LOGICAL_MAXIMUM (1) */
0x75, 0x01, /* REPORT_SIZE (1) */
0x95, 0x08, /* REPORT_COUNT (8) */
0x81, 0x02, /* INPUT (Data,Var,Abs) */
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x08, /* REPORT_SIZE (8) */
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
0x95, 0x05, /* REPORT_COUNT (5) */
0x75, 0x01, /* REPORT_SIZE (1) */
0x05, 0x08, /* USAGE_PAGE (LEDs) */
0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */
0x29, 0x05, /* USAGE_MAXIMUM (Kana) */
0x91, 0x02, /* OUTPUT (Data,Var,Abs) */
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x03, /* REPORT_SIZE (3) */
0x91, 0x03, /* OUTPUT (Cnst,Var,Abs) */
0x95, 0x06, /* REPORT_COUNT (6) */
0x75, 0x08, /* REPORT_SIZE (8) */
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
0x25, 0x65, /* LOGICAL_MAXIMUM (101) */
0x05, 0x07, /* USAGE_PAGE (Keyboard) */
0x19, 0x00, /* USAGE_MINIMUM (Reserved) */
0x29, 0x65, /* USAGE_MAXIMUM (Keyboard Application) */
0x81, 0x00, /* INPUT (Data,Ary,Abs) */
0xc0, /* END_COLLECTION */
}
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
if u.keyboardHidFile == nil {
var err error
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
if err != nil {
return fmt.Errorf("failed to open hidg0: %w", err)
}
}
_, err := u.keyboardHidFile.Write(data)
if err != nil {
u.log.Errorf("failed to write to hidg0: %w", err)
u.keyboardHidFile.Close()
u.keyboardHidFile = nil
return err
}
return nil
}
func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
if len(keys) > 6 {
keys = keys[:6]
}
if len(keys) < 6 {
keys = append(keys, make([]uint8, 6-len(keys))...)
}
err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]})
if err != nil {
return err
}
u.resetUserInputTime()
return nil
}

View File

@ -0,0 +1,128 @@
package usbgadget
import (
"fmt"
"os"
)
var absoluteMouseConfig = gadgetConfigItem{
order: 1001,
device: "hid.usb1",
path: []string{"functions", "hid.usb1"},
configPath: []string{"hid.usb1"},
attrs: gadgetAttributes{
"protocol": "2",
"subclass": "1",
"report_length": "6",
},
reportDesc: absoluteMouseCombinedReportDesc,
}
var absoluteMouseCombinedReportDesc = []byte{
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
0x09, 0x02, // Usage (Mouse)
0xA1, 0x01, // Collection (Application)
// Report ID 1: Absolute Mouse Movement
0x85, 0x01, // Report ID (1)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (0x01)
0x29, 0x03, // Usage Maximum (0x03)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x03, // Report Count (3)
0x81, 0x02, // Input (Data, Var, Abs)
0x95, 0x01, // Report Count (1)
0x75, 0x05, // Report Size (5)
0x81, 0x03, // Input (Cnst, Var, Abs)
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x16, 0x00, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x7F, // Logical Maximum (32767)
0x36, 0x00, 0x00, // Physical Minimum (0)
0x46, 0xFF, 0x7F, // Physical Maximum (32767)
0x75, 0x10, // Report Size (16)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data, Var, Abs)
0xC0, // End Collection
// Report ID 2: Relative Wheel Movement
0x85, 0x02, // Report ID (2)
0x09, 0x38, // Usage (Wheel)
0x15, 0x81, // Logical Minimum (-127)
0x25, 0x7F, // Logical Maximum (127)
0x75, 0x08, // Report Size (8)
0x95, 0x01, // Report Count (1)
0x81, 0x06, // Input (Data, Var, Rel)
0xC0, // End Collection
}
func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
if u.absMouseHidFile == nil {
var err error
u.absMouseHidFile, err = os.OpenFile("/dev/hidg1", os.O_RDWR, 0666)
if err != nil {
return fmt.Errorf("failed to open hidg1: %w", err)
}
}
_, err := u.absMouseHidFile.Write(data)
if err != nil {
u.log.Errorf("failed to write to hidg1: %w", err)
u.absMouseHidFile.Close()
u.absMouseHidFile = nil
return err
}
return nil
}
func (u *UsbGadget) AbsMouseReport(x, y int, buttons uint8) error {
u.absMouseLock.Lock()
defer u.absMouseLock.Unlock()
err := u.absMouseWriteHidFile([]byte{
1, // Report ID 1
buttons, // Buttons
uint8(x), // X Low Byte
uint8(x >> 8), // X High Byte
uint8(y), // Y Low Byte
uint8(y >> 8), // Y High Byte
})
if err != nil {
return err
}
u.resetUserInputTime()
return nil
}
func (u *UsbGadget) AbsMouseWheelReport(wheelY int8) error {
u.absMouseLock.Lock()
defer u.absMouseLock.Unlock()
// Accumulate the wheelY value
u.absMouseAccumulatedWheelY += float64(wheelY) / 8.0
// Only send a report if the accumulated value is significant
if abs(u.absMouseAccumulatedWheelY) < 1.0 {
return nil
}
scaledWheelY := int8(u.absMouseAccumulatedWheelY)
err := u.absMouseWriteHidFile([]byte{
2, // Report ID 2
byte(scaledWheelY), // Scaled Wheel Y (signed)
})
// Reset the accumulator, keeping any remainder
u.absMouseAccumulatedWheelY -= float64(scaledWheelY)
u.resetUserInputTime()
return err
}

View File

@ -0,0 +1,92 @@
package usbgadget
import (
"fmt"
"os"
)
var relativeMouseConfig = gadgetConfigItem{
order: 1002,
device: "hid.usb2",
path: []string{"functions", "hid.usb2"},
configPath: []string{"hid.usb2"},
attrs: gadgetAttributes{
"protocol": "2",
"subclass": "1",
"report_length": "4",
},
reportDesc: relativeMouseCombinedReportDesc,
}
// from: https://github.com/NicoHood/HID/blob/b16be57caef4295c6cd382a7e4c64db5073647f7/src/SingleReport/BootMouse.cpp#L26
var relativeMouseCombinedReportDesc = []byte{
0x05, 0x01, // USAGE_PAGE (Generic Desktop) 54
0x09, 0x02, // USAGE (Mouse)
0xa1, 0x01, // COLLECTION (Application)
// Pointer and Physical are required by Apple Recovery
0x09, 0x01, // USAGE (Pointer)
0xa1, 0x00, // COLLECTION (Physical)
// 8 Buttons
0x05, 0x09, // USAGE_PAGE (Button)
0x19, 0x01, // USAGE_MINIMUM (Button 1)
0x29, 0x08, // USAGE_MAXIMUM (Button 8)
0x15, 0x00, // LOGICAL_MINIMUM (0)
0x25, 0x01, // LOGICAL_MAXIMUM (1)
0x95, 0x08, // REPORT_COUNT (8)
0x75, 0x01, // REPORT_SIZE (1)
0x81, 0x02, // INPUT (Data,Var,Abs)
// X, Y, Wheel
0x05, 0x01, // USAGE_PAGE (Generic Desktop)
0x09, 0x30, // USAGE (X)
0x09, 0x31, // USAGE (Y)
0x09, 0x38, // USAGE (Wheel)
0x15, 0x81, // LOGICAL_MINIMUM (-127)
0x25, 0x7f, // LOGICAL_MAXIMUM (127)
0x75, 0x08, // REPORT_SIZE (8)
0x95, 0x03, // REPORT_COUNT (3)
0x81, 0x06, // INPUT (Data,Var,Rel)
// End
0xc0, // End Collection (Physical)
0xc0, // End Collection
}
func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
if u.relMouseHidFile == nil {
var err error
u.relMouseHidFile, err = os.OpenFile("/dev/hidg2", os.O_RDWR, 0666)
if err != nil {
return fmt.Errorf("failed to open hidg1: %w", err)
}
}
_, err := u.relMouseHidFile.Write(data)
if err != nil {
u.log.Errorf("failed to write to hidg2: %w", err)
u.relMouseHidFile.Close()
u.relMouseHidFile = nil
return err
}
return nil
}
func (u *UsbGadget) RelMouseReport(mx, my int8, buttons uint8) error {
u.relMouseLock.Lock()
defer u.relMouseLock.Unlock()
err := u.relMouseWriteHidFile([]byte{
buttons, // Buttons
uint8(mx), // X
uint8(my), // Y
0, // Wheel
})
if err != nil {
return err
}
u.resetUserInputTime()
return nil
}

View File

@ -0,0 +1,23 @@
package usbgadget
var massStorageBaseConfig = gadgetConfigItem{
order: 3000,
device: "mass_storage.usb0",
path: []string{"functions", "mass_storage.usb0"},
configPath: []string{"mass_storage.usb0"},
attrs: gadgetAttributes{
"stall": "1",
},
}
var massStorageLun0Config = gadgetConfigItem{
order: 3001,
path: []string{"functions", "mass_storage.usb0", "lun.0"},
attrs: gadgetAttributes{
"cdrom": "1",
"ro": "1",
"removable": "1",
"file": "\n",
"inquiry_string": "JetKVM Virtual Media",
},
}

109
internal/usbgadget/udc.go Normal file
View File

@ -0,0 +1,109 @@
package usbgadget
import (
"fmt"
"os"
"path"
"strings"
)
func getUdcs() []string {
var udcs []string
files, err := os.ReadDir("/sys/devices/platform/usbdrd")
if err != nil {
return nil
}
for _, file := range files {
if !file.IsDir() || !strings.HasSuffix(file.Name(), ".usb") {
continue
}
udcs = append(udcs, file.Name())
}
return udcs
}
func rebindUsb(udc string, ignoreUnbindError bool) error {
err := os.WriteFile(path.Join(dwc3Path, "unbind"), []byte(udc), 0644)
if err != nil && !ignoreUnbindError {
return err
}
err = os.WriteFile(path.Join(dwc3Path, "bind"), []byte(udc), 0644)
if err != nil {
return err
}
return nil
}
func (u *UsbGadget) rebindUsb(ignoreUnbindError bool) error {
u.log.Infof("rebinding USB gadget to UDC %s", u.udc)
return rebindUsb(u.udc, ignoreUnbindError)
}
// RebindUsb rebinds the USB gadget to the UDC.
func (u *UsbGadget) RebindUsb(ignoreUnbindError bool) error {
u.configLock.Lock()
defer u.configLock.Unlock()
return u.rebindUsb(ignoreUnbindError)
}
func (u *UsbGadget) writeUDC() error {
path := path.Join(u.kvmGadgetPath, "UDC")
u.log.Tracef("writing UDC %s to %s", u.udc, path)
err := u.writeIfDifferent(path, []byte(u.udc), 0644)
if err != nil {
return fmt.Errorf("failed to write UDC: %w", err)
}
return nil
}
// GetUsbState returns the current state of the USB gadget
func (u *UsbGadget) GetUsbState() (state string) {
stateFile := path.Join("/sys/class/udc", u.udc, "state")
stateBytes, err := os.ReadFile(stateFile)
if err != nil {
if os.IsNotExist(err) {
return "not attached"
} else {
u.log.Tracef("failed to read usb state: %v", err)
}
return "unknown"
}
return strings.TrimSpace(string(stateBytes))
}
// IsUDCBound checks if the UDC state is bound.
func (u *UsbGadget) IsUDCBound() (bool, error) {
udcFilePath := path.Join(dwc3Path, u.udc)
_, err := os.Stat(udcFilePath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("error checking USB emulation state: %w", err)
}
return true, nil
}
// BindUDC binds the gadget to the UDC.
func (u *UsbGadget) BindUDC() error {
err := os.WriteFile(path.Join(dwc3Path, "bind"), []byte(u.udc), 0644)
if err != nil {
return fmt.Errorf("error binding UDC: %w", err)
}
return nil
}
// UnbindUDC unbinds the gadget from the UDC.
func (u *UsbGadget) UnbindUDC() error {
err := os.WriteFile(path.Join(dwc3Path, "unbind"), []byte(u.udc), 0644)
if err != nil {
return fmt.Errorf("error unbinding UDC: %w", err)
}
return nil
}

View File

@ -0,0 +1,110 @@
// Package usbgadget provides a high-level interface to manage USB gadgets
// THIS PACKAGE IS FOR INTERNAL USE ONLY AND ITS API MAY CHANGE WITHOUT NOTICE
package usbgadget
import (
"os"
"path"
"sync"
"time"
"github.com/pion/logging"
)
// Devices is a struct that represents the USB devices that can be enabled on a USB gadget.
type Devices struct {
AbsoluteMouse bool `json:"absolute_mouse"`
RelativeMouse bool `json:"relative_mouse"`
Keyboard bool `json:"keyboard"`
MassStorage bool `json:"mass_storage"`
}
// Config is a struct that represents the customizations for a USB gadget.
// TODO: rename to something else that won't confuse with the USB gadget configuration
type Config struct {
VendorId string `json:"vendor_id"`
ProductId string `json:"product_id"`
SerialNumber string `json:"serial_number"`
Manufacturer string `json:"manufacturer"`
Product string `json:"product"`
isEmpty bool
}
var defaultUsbGadgetDevices = Devices{
AbsoluteMouse: true,
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
}
// UsbGadget is a struct that represents a USB gadget.
type UsbGadget struct {
name string
udc string
kvmGadgetPath string
configC1Path string
configMap map[string]gadgetConfigItem
customConfig Config
configLock sync.Mutex
keyboardHidFile *os.File
keyboardLock sync.Mutex
absMouseHidFile *os.File
absMouseLock sync.Mutex
relMouseHidFile *os.File
relMouseLock sync.Mutex
enabledDevices Devices
absMouseAccumulatedWheelY float64
lastUserInput time.Time
log logging.LeveledLogger
}
const configFSPath = "/sys/kernel/config"
const gadgetPath = "/sys/kernel/config/usb_gadget"
var defaultLogger = logging.NewDefaultLoggerFactory().NewLogger("usbgadget")
// NewUsbGadget creates a new UsbGadget.
func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *logging.LeveledLogger) *UsbGadget {
if logger == nil {
logger = &defaultLogger
}
if enabledDevices == nil {
enabledDevices = &defaultUsbGadgetDevices
}
if config == nil {
config = &Config{isEmpty: true}
}
g := &UsbGadget{
name: name,
kvmGadgetPath: path.Join(gadgetPath, name),
configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
configMap: defaultGadgetConfig,
customConfig: *config,
configLock: sync.Mutex{},
keyboardLock: sync.Mutex{},
absMouseLock: sync.Mutex{},
relMouseLock: sync.Mutex{},
enabledDevices: *enabledDevices,
lastUserInput: time.Now(),
log: *logger,
absMouseAccumulatedWheelY: 0,
}
if err := g.Init(); err != nil {
g.log.Errorf("failed to init USB gadget: %v", err)
return nil
}
return g
}

View File

@ -0,0 +1,63 @@
package usbgadget
import (
"bytes"
"fmt"
"os"
"path/filepath"
)
// Helper function to get absolute value of float64
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
func joinPath(basePath string, paths []string) string {
pathArr := append([]string{basePath}, paths...)
return filepath.Join(pathArr...)
}
func ensureSymlink(linkPath string, target string) error {
if _, err := os.Lstat(linkPath); err == nil {
currentTarget, err := os.Readlink(linkPath)
if err != nil || currentTarget != target {
err = os.Remove(linkPath)
if err != nil {
return fmt.Errorf("failed to remove existing symlink %s: %w", linkPath, err)
}
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to check if symlink exists: %w", err)
}
if err := os.Symlink(target, linkPath); err != nil {
return fmt.Errorf("failed to create symlink from %s to %s: %w", linkPath, target, err)
}
return nil
}
func (u *UsbGadget) writeIfDifferent(filePath string, content []byte, permMode os.FileMode) error {
if _, err := os.Stat(filePath); err == nil {
oldContent, err := os.ReadFile(filePath)
if err == nil {
if bytes.Equal(oldContent, content) {
u.log.Tracef("skipping writing to %s as it already has the correct content", filePath)
return nil
}
if len(oldContent) == len(content)+1 &&
bytes.Equal(oldContent[:len(content)], content) &&
oldContent[len(content)] == 10 {
u.log.Tracef("skipping writing to %s as it already has the correct content", filePath)
return nil
}
u.log.Tracef("writing to %s as it has different content old%v new%v", filePath, oldContent, content)
}
}
return os.WriteFile(filePath, content, permMode)
}

View File

@ -6,10 +6,6 @@ import (
var lastUserInput = time.Now()
func resetUserInputTime() {
lastUserInput = time.Now()
}
var jigglerEnabled = false
func rpcSetJigglerState(enabled bool) {
@ -20,6 +16,8 @@ func rpcGetJigglerState() bool {
}
func init() {
ensureConfigLoaded()
go runJiggler()
}

View File

@ -5,13 +5,17 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"time"
"github.com/pion/webrtc/v4"
"go.bug.st/serial"
"github.com/jetkvm/kvm/internal/usbgadget"
)
type JSONRPCRequest struct {
@ -34,15 +38,21 @@ type JSONRPCEvent struct {
Params interface{} `json:"params,omitempty"`
}
type BacklightSettings struct {
MaxBrightness int `json:"max_brightness"`
DimAfter int `json:"dim_after"`
OffAfter int `json:"off_after"`
}
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
responseBytes, err := json.Marshal(response)
if err != nil {
log.Println("Error marshalling JSONRPC response:", err)
logger.Warnf("Error marshalling JSONRPC response: %v", err)
return
}
err = session.RPCChannel.SendText(string(responseBytes))
if err != nil {
log.Println("Error sending JSONRPC response:", err)
logger.Warnf("Error sending JSONRPC response: %v", err)
return
}
}
@ -55,16 +65,16 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
}
requestBytes, err := json.Marshal(request)
if err != nil {
log.Println("Error marshalling JSONRPC event:", err)
logger.Warnf("Error marshalling JSONRPC event: %v", err)
return
}
if session == nil || session.RPCChannel == nil {
log.Println("RPC channel not available")
logger.Info("RPC channel not available")
return
}
err = session.RPCChannel.SendText(string(requestBytes))
if err != nil {
log.Println("Error sending JSONRPC event:", err)
logger.Warnf("Error sending JSONRPC event: %v", err)
return
}
}
@ -85,7 +95,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
//log.Printf("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID)
//logger.Infof("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID)
handler, ok := rpcHandlers[request.Method]
if !ok {
errorResponse := JSONRPCResponse{
@ -138,7 +148,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
}
func rpcSetStreamQualityFactor(factor float64) error {
log.Printf("Setting stream quality factor to: %f", factor)
logger.Infof("Setting stream quality factor to: %f", factor)
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
if err != nil {
return err
@ -174,15 +184,19 @@ func rpcGetEDID() (string, error) {
func rpcSetEDID(edid string) error {
if edid == "" {
log.Println("Restoring EDID to default")
logger.Info("Restoring EDID to default")
edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
} else {
log.Printf("Setting EDID to: %s", edid)
logger.Infof("Setting EDID to: %s", edid)
}
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid})
if err != nil {
return err
}
// Save EDID to config, allowing it to be restored on reboot.
config.EdidString = edid
_ = SaveConfig()
return nil
}
@ -219,6 +233,52 @@ func rpcTryUpdate() error {
return nil
}
func rpcSetBacklightSettings(params BacklightSettings) error {
blConfig := params
// NOTE: by default, the frontend limits the brightness to 64, as that's what the device originally shipped with.
if blConfig.MaxBrightness > 255 || blConfig.MaxBrightness < 0 {
return fmt.Errorf("maxBrightness must be between 0 and 255")
}
if blConfig.DimAfter < 0 {
return fmt.Errorf("dimAfter must be a positive integer")
}
if blConfig.OffAfter < 0 {
return fmt.Errorf("offAfter must be a positive integer")
}
config.DisplayMaxBrightness = blConfig.MaxBrightness
config.DisplayDimAfterSec = blConfig.DimAfter
config.DisplayOffAfterSec = blConfig.OffAfter
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
logger.Infof("rpc: display: settings applied, max_brightness: %d, dim after: %ds, off after: %ds", config.DisplayMaxBrightness, config.DisplayDimAfterSec, config.DisplayOffAfterSec)
// If the device started up with auto-dim and/or auto-off set to zero, the display init
// method will not have started the tickers. So in case that has changed, attempt to start the tickers now.
startBacklightTickers()
// Wake the display after the settings are altered, this ensures the tickers
// are reset to the new settings, and will bring the display up to maxBrightness.
// Calling with force set to true, to ignore the current state of the display, and force
// it to reset the tickers.
wakeDisplay(true)
return nil
}
func rpcGetBacklightSettings() (*BacklightSettings, error) {
return &BacklightSettings{
MaxBrightness: config.DisplayMaxBrightness,
DimAfter: int(config.DisplayDimAfterSec),
OffAfter: int(config.DisplayOffAfterSec),
}, nil
}
const (
devModeFile = "/userdata/jetkvm/devmode.enable"
sshKeyDir = "/userdata/dropbear/.ssh"
@ -379,7 +439,7 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac
}
args[i] = reflect.ValueOf(newStruct).Elem()
} else {
return nil, fmt.Errorf("invalid parameter type for: %s", paramName)
return nil, fmt.Errorf("invalid parameter type for: %s, type: %s", paramName, paramType.Kind())
}
} else {
args[i] = convertedValue.Convert(paramType)
@ -418,23 +478,23 @@ type RPCHandler struct {
}
func rpcSetMassStorageMode(mode string) (string, error) {
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
var cdrom bool
if mode == "cdrom" {
cdrom = true
} else if mode != "file" {
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode)
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode)
return "", fmt.Errorf("invalid mode: %s", mode)
}
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
err := setMassStorageMode(cdrom)
if err != nil {
return "", fmt.Errorf("failed to set mass storage mode: %w", err)
}
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Mass storage mode set to %s", mode)
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Mass storage mode set to %s", mode)
// Get the updated mode after setting
return rpcGetMassStorageMode()
@ -457,29 +517,31 @@ func rpcIsUpdatePending() (bool, error) {
return IsUpdatePending(), nil
}
var udcFilePath = filepath.Join("/sys/bus/platform/drivers/dwc3", udc)
func rpcGetUsbEmulationState() (bool, error) {
_, err := os.Stat(udcFilePath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("error checking USB emulation state: %w", err)
}
return true, nil
return gadget.IsUDCBound()
}
func rpcSetUsbEmulationState(enabled bool) error {
if enabled {
return os.WriteFile("/sys/bus/platform/drivers/dwc3/bind", []byte(udc), 0644)
return gadget.BindUDC()
} else {
return os.WriteFile("/sys/bus/platform/drivers/dwc3/unbind", []byte(udc), 0644)
return gadget.UnbindUDC()
}
}
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
func rpcGetUsbConfig() (usbgadget.Config, error) {
LoadConfig()
return *config.UsbConfig, nil
}
func rpcSetUsbConfig(usbConfig usbgadget.Config) error {
LoadConfig()
config.UsbConfig = &usbConfig
gadget.SetGadgetConfig(config.UsbConfig)
return updateUsbRelatedConfig()
}
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
if config.WakeOnLanDevices == nil {
return []WakeOnLanDevice{}, nil
}
@ -491,23 +553,245 @@ type SetWakeOnLanDevicesParams struct {
}
func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error {
LoadConfig()
config.WakeOnLanDevices = params.Devices
return SaveConfig()
}
func rpcResetConfig() error {
LoadConfig()
config = defaultConfig
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to reset config: %w", err)
}
log.Println("Configuration reset to default")
logger.Info("Configuration reset to default")
return nil
}
type DCPowerState struct {
IsOn bool `json:"isOn"`
Voltage float64 `json:"voltage"`
Current float64 `json:"current"`
Power float64 `json:"power"`
}
func rpcGetDCPowerState() (DCPowerState, error) {
return dcState, nil
}
func rpcSetDCPowerState(enabled bool) error {
logger.Infof("[jsonrpc.go:rpcSetDCPowerState] Setting DC power state to: %v", enabled)
err := setDCPowerState(enabled)
if err != nil {
return fmt.Errorf("failed to set DC power state: %w", err)
}
return nil
}
func rpcGetActiveExtension() (string, error) {
return config.ActiveExtension, nil
}
func rpcSetActiveExtension(extensionId string) error {
if config.ActiveExtension == extensionId {
return nil
}
if config.ActiveExtension == "atx-power" {
_ = unmountATXControl()
} else if config.ActiveExtension == "dc-power" {
_ = unmountDCControl()
}
config.ActiveExtension = extensionId
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
if extensionId == "atx-power" {
_ = mountATXControl()
} else if extensionId == "dc-power" {
_ = mountDCControl()
}
return nil
}
func rpcSetATXPowerAction(action string) error {
logger.Debugf("[jsonrpc.go:rpcSetATXPowerAction] Executing ATX power action: %s", action)
switch action {
case "power-short":
logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating short power button press")
return pressATXPowerButton(200 * time.Millisecond)
case "power-long":
logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating long power button press")
return pressATXPowerButton(5 * time.Second)
case "reset":
logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating reset button press")
return pressATXResetButton(200 * time.Millisecond)
default:
return fmt.Errorf("invalid action: %s", action)
}
}
type ATXState struct {
Power bool `json:"power"`
HDD bool `json:"hdd"`
}
func rpcGetATXState() (ATXState, error) {
state := ATXState{
Power: ledPWRState,
HDD: ledHDDState,
}
return state, nil
}
type SerialSettings struct {
BaudRate string `json:"baudRate"`
DataBits string `json:"dataBits"`
StopBits string `json:"stopBits"`
Parity string `json:"parity"`
}
func rpcGetSerialSettings() (SerialSettings, error) {
settings := SerialSettings{
BaudRate: strconv.Itoa(serialPortMode.BaudRate),
DataBits: strconv.Itoa(serialPortMode.DataBits),
StopBits: "1",
Parity: "none",
}
switch serialPortMode.StopBits {
case serial.OneStopBit:
settings.StopBits = "1"
case serial.OnePointFiveStopBits:
settings.StopBits = "1.5"
case serial.TwoStopBits:
settings.StopBits = "2"
}
switch serialPortMode.Parity {
case serial.NoParity:
settings.Parity = "none"
case serial.OddParity:
settings.Parity = "odd"
case serial.EvenParity:
settings.Parity = "even"
case serial.MarkParity:
settings.Parity = "mark"
case serial.SpaceParity:
settings.Parity = "space"
}
return settings, nil
}
var serialPortMode = defaultMode
func rpcSetSerialSettings(settings SerialSettings) error {
baudRate, err := strconv.Atoi(settings.BaudRate)
if err != nil {
return fmt.Errorf("invalid baud rate: %v", err)
}
dataBits, err := strconv.Atoi(settings.DataBits)
if err != nil {
return fmt.Errorf("invalid data bits: %v", err)
}
var stopBits serial.StopBits
switch settings.StopBits {
case "1":
stopBits = serial.OneStopBit
case "1.5":
stopBits = serial.OnePointFiveStopBits
case "2":
stopBits = serial.TwoStopBits
default:
return fmt.Errorf("invalid stop bits: %s", settings.StopBits)
}
var parity serial.Parity
switch settings.Parity {
case "none":
parity = serial.NoParity
case "odd":
parity = serial.OddParity
case "even":
parity = serial.EvenParity
case "mark":
parity = serial.MarkParity
case "space":
parity = serial.SpaceParity
default:
return fmt.Errorf("invalid parity: %s", settings.Parity)
}
serialPortMode = &serial.Mode{
BaudRate: baudRate,
DataBits: dataBits,
StopBits: stopBits,
Parity: parity,
}
_ = port.SetMode(serialPortMode)
return nil
}
func rpcGetUsbDevices() (usbgadget.Devices, error) {
return *config.UsbDevices, nil
}
func updateUsbRelatedConfig() error {
if err := gadget.UpdateGadgetConfig(); err != nil {
return fmt.Errorf("failed to write gadget config: %w", err)
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcSetUsbDevices(usbDevices usbgadget.Devices) error {
config.UsbDevices = &usbDevices
gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
}
func rpcSetUsbDeviceState(device string, enabled bool) error {
switch device {
case "absoluteMouse":
config.UsbDevices.AbsoluteMouse = enabled
case "relativeMouse":
config.UsbDevices.RelativeMouse = enabled
case "keyboard":
config.UsbDevices.Keyboard = enabled
case "massStorage":
config.UsbDevices.MassStorage = enabled
default:
return fmt.Errorf("invalid device: %s", device)
}
gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
}
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
config.CloudURL = apiUrl
config.CloudAppURL = appUrl
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
var currentScrollSensitivity string = "default"
func rpcGetScrollSensitivity() (string, error) {
return currentScrollSensitivity, nil
}
func rpcSetScrollSensitivity(sensitivity string) error {
currentScrollSensitivity = sensitivity
return nil
}
// TODO: replace this crap with code generator
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID},
@ -542,6 +826,8 @@ var rpcHandlers = map[string]RPCHandler{
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"getUsbConfig": {Func: rpcGetUsbConfig},
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace},
@ -554,4 +840,20 @@ var rpcHandlers = map[string]RPCHandler{
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
"getBacklightSettings": {Func: rpcGetBacklightSettings},
"getDCPowerState": {Func: rpcGetDCPowerState},
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
"getActiveExtension": {Func: rpcGetActiveExtension},
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getATXState": {Func: rpcGetATXState},
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
"getSerialSettings": {Func: rpcGetSerialSettings},
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
"getUsbDevices": {Func: rpcGetUsbDevices},
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
}

2
log.go
View File

@ -5,4 +5,4 @@ import "github.com/pion/logging"
// we use logging framework from pion
// ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm")
var usbLogger = logging.NewDefaultLoggerFactory().NewLogger("usb")
var cloudLogger = logging.NewDefaultLoggerFactory().NewLogger("cloud")

18
main.go
View File

@ -2,7 +2,6 @@ package kvm
import (
"context"
"log"
"net/http"
"os"
"os/signal"
@ -44,11 +43,13 @@ func Main() {
}
}()
initUsbGadget()
go func() {
time.Sleep(15 * time.Minute)
for {
logger.Debugf("UPDATING - Auto update enabled: %v", config.AutoUpdateEnabled)
if config.AutoUpdateEnabled == false {
if !config.AutoUpdateEnabled {
return
}
if currentSession != nil {
@ -66,19 +67,24 @@ func Main() {
}()
//go RunFuseServer()
go RunWebServer()
go RunWebsocketClient()
// If the cloud token isn't set, the client won't be started by default.
// However, if the user adopts the device via the web interface, handleCloudRegister will start the client.
if config.CloudToken != "" {
go RunWebsocketClient()
}
initSerialPort()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
log.Println("JetKVM Shutting Down")
logger.Info("JetKVM Shutting Down")
//if fuseServer != nil {
// err := setMassStorageImage(" ")
// if err != nil {
// log.Printf("Failed to unmount mass storage image: %v", err)
// logger.Infof("Failed to unmount mass storage image: %v", err)
// }
// err = fuseServer.Unmount()
// if err != nil {
// log.Printf("Failed to unmount fuse: %v", err)
// logger.Infof("Failed to unmount fuse: %v", err)
// }
// os.Exit(0)

View File

@ -5,14 +5,15 @@ import (
"encoding/json"
"fmt"
"io"
"kvm/resource"
"log"
"net"
"os"
"os/exec"
"sync"
"syscall"
"time"
"github.com/jetkvm/kvm/resource"
"github.com/pion/webrtc/v4/pkg/media"
)
@ -60,7 +61,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
}
fmt.Println("sending ctrl action", string(jsonData))
logger.Infof("sending ctrl action: %s", string(jsonData))
err = WriteCtrlMessage(jsonData)
if err != nil {
@ -90,8 +91,8 @@ func WriteCtrlMessage(message []byte) error {
return err
}
var nativeCtrlSocketListener net.Listener
var nativeVideoSocketListener net.Listener
var nativeCtrlSocketListener net.Listener //nolint:unused
var nativeVideoSocketListener net.Listener //nolint:unused
var ctrlClientConnected = make(chan struct{})
@ -103,16 +104,18 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
// Remove the socket file if it already exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
log.Fatalf("Failed to remove existing socket file %s: %v", socketPath, err)
logger.Errorf("Failed to remove existing socket file %s: %v", socketPath, err)
os.Exit(1)
}
}
listener, err := net.Listen("unixpacket", socketPath)
if err != nil {
log.Fatalf("Failed to start server on %s: %v", socketPath, err)
logger.Errorf("Failed to start server on %s: %v", socketPath, err)
os.Exit(1)
}
log.Printf("Server listening on %s", socketPath)
logger.Infof("Server listening on %s", socketPath)
go func() {
conn, err := listener.Accept()
@ -151,6 +154,9 @@ func handleCtrlClient(conn net.Conn) {
ctrlSocketConn = conn
// Restore HDMI EDID if applicable
go restoreHdmiEdid()
readBuf := make([]byte, 4096)
for {
n, err := conn.Read(readBuf)
@ -184,24 +190,23 @@ func handleCtrlClient(conn net.Conn) {
func handleVideoClient(conn net.Conn) {
defer conn.Close()
log.Printf("Native video socket client connected: %v", conn.RemoteAddr())
logger.Infof("Native video socket client connected: %v", conn.RemoteAddr())
inboundPacket := make([]byte, maxFrameSize)
lastFrame := time.Now()
for {
n, err := conn.Read(inboundPacket)
if err != nil {
log.Println("error during read: %s", err)
logger.Warnf("error during read: %v", err)
return
}
now := time.Now()
sinceLastFrame := now.Sub(lastFrame)
lastFrame = now
//fmt.Println("Video packet received", n, sinceLastFrame)
if currentSession != nil {
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
if err != nil {
log.Println("Error writing sample", err)
logger.Warnf("error writing sample: %v", err)
}
}
}
@ -224,6 +229,12 @@ func ExtractAndRunNativeBin() error {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Set the process group ID so we can kill the process and its children when this process exits
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pdeathsig: syscall.SIGKILL,
}
// Start the command
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start binary: %w", err)
@ -240,7 +251,7 @@ func ExtractAndRunNativeBin() error {
}
}()
fmt.Printf("Binary started with PID: %d\n", cmd.Process.Pid)
logger.Infof("Binary started with PID: %d", cmd.Process.Pid)
return nil
}
@ -297,3 +308,15 @@ func ensureBinaryUpdated(destPath string) error {
return nil
}
// Restore the HDMI EDID value from the config.
// Called after successful connection to jetkvm_native.
func restoreHdmiEdid() {
if config.EdidString != "" {
logger.Infof("Restoring HDMI EDID to %v", config.EdidString)
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString})
if err != nil {
logger.Errorf("Failed to restore HDMI EDID: %v", err)
}
}
}

View File

@ -1,22 +1,35 @@
package kvm
import (
"bytes"
"fmt"
"net"
"os"
"strings"
"time"
"os/exec"
"github.com/hashicorp/go-envparse"
"github.com/pion/mdns/v2"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"net"
"time"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
)
var networkState struct {
var mDNSConn *mdns.Conn
var networkState NetworkState
type NetworkState struct {
Up bool
IPv4 string
IPv6 string
MAC string
checked bool
}
type LocalIpInfo struct {
@ -25,44 +38,93 @@ type LocalIpInfo struct {
MAC string
}
const (
NetIfName = "eth0"
DHCPLeaseFile = "/run/udhcpc.%s.info"
)
// setDhcpClientState sends signals to udhcpc to change it's current mode
// of operation. Setting active to true will force udhcpc to renew the DHCP lease.
// Setting active to false will put udhcpc into idle mode.
func setDhcpClientState(active bool) {
var signal string
if active {
signal = "-SIGUSR1"
} else {
signal = "-SIGUSR2"
}
cmd := exec.Command("/usr/bin/killall", signal, "udhcpc")
if err := cmd.Run(); err != nil {
logger.Warnf("network: setDhcpClientState: failed to change udhcpc state: %s", err)
}
}
func checkNetworkState() {
iface, err := netlink.LinkByName("eth0")
iface, err := netlink.LinkByName(NetIfName)
if err != nil {
fmt.Printf("failed to get eth0 interface: %v\n", err)
logger.Warnf("failed to get [%s] interface: %v", NetIfName, err)
return
}
newState := struct {
Up bool
IPv4 string
IPv6 string
MAC string
}{
newState := NetworkState{
Up: iface.Attrs().OperState == netlink.OperUp,
MAC: iface.Attrs().HardwareAddr.String(),
checked: true,
}
addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL)
if err != nil {
fmt.Printf("failed to get addresses for eth0: %v\n", err)
logger.Warnf("failed to get addresses for [%s]: %v", NetIfName, err)
}
// If the link is going down, put udhcpc into idle mode.
// If the link is coming back up, activate udhcpc and force it to renew the lease.
if newState.Up != networkState.Up {
setDhcpClientState(newState.Up)
}
for _, addr := range addrs {
if addr.IP.To4() != nil {
newState.IPv4 = addr.IP.String()
if !newState.Up && networkState.Up {
// If the network is going down, remove all IPv4 addresses from the interface.
logger.Infof("network: state transitioned to down, removing IPv4 address %s", addr.IP.String())
err := netlink.AddrDel(iface, &addr)
if err != nil {
logger.Warnf("network: failed to delete %s", addr.IP.String())
}
newState.IPv4 = "..."
} else {
newState.IPv4 = addr.IP.String()
}
} else if addr.IP.To16() != nil && newState.IPv6 == "" {
newState.IPv6 = addr.IP.String()
}
}
if newState != networkState {
logger.Info("network state changed")
// restart MDNS
_ = startMDNS()
networkState = newState
fmt.Println("network state changed")
requestDisplayUpdate()
}
}
func startMDNS() error {
// If server was previously running, stop it
if mDNSConn != nil {
logger.Info("Stopping mDNS server")
err := mDNSConn.Close()
if err != nil {
logger.Warnf("failed to stop mDNS server: %v", err)
}
}
// Start a new server
logger.Info("Starting mDNS server on jetkvm.local")
addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4)
if err != nil {
return err
@ -83,22 +145,58 @@ func startMDNS() error {
return err
}
_, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{
mDNSConn, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{
LocalNames: []string{"jetkvm.local"}, //TODO: make it configurable
})
if err != nil {
mDNSConn = nil
return err
}
//defer server.Close()
return nil
}
func getNTPServersFromDHCPInfo() ([]string, error) {
buf, err := os.ReadFile(fmt.Sprintf(DHCPLeaseFile, NetIfName))
if err != nil {
// do not return error if file does not exist
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to load udhcpc info: %w", err)
}
// parse udhcpc info
env, err := envparse.Parse(bytes.NewReader(buf))
if err != nil {
return nil, fmt.Errorf("failed to parse udhcpc info: %w", err)
}
val, ok := env["ntpsrv"]
if !ok {
return nil, nil
}
var servers []string
for _, server := range strings.Fields(val) {
if net.ParseIP(server) == nil {
logger.Infof("invalid NTP server IP: %s, ignoring", server)
}
servers = append(servers, server)
}
return servers, nil
}
func init() {
ensureConfigLoaded()
updates := make(chan netlink.LinkUpdate)
done := make(chan struct{})
if err := netlink.LinkSubscribe(updates, done); err != nil {
fmt.Println("failed to subscribe to link updates: %v", err)
logger.Warnf("failed to subscribe to link updates: %v", err)
return
}
@ -111,8 +209,8 @@ func init() {
for {
select {
case update := <-updates:
if update.Link.Attrs().Name == "eth0" {
fmt.Printf("link update: %+v\n", update)
if update.Link.Attrs().Name == NetIfName {
logger.Infof("link update: %+v", update)
checkNetworkState()
}
case <-ticker.C:
@ -122,9 +220,8 @@ func init() {
}
}
}()
fmt.Println("Starting mDNS server")
err := startMDNS()
if err != nil {
fmt.Println("failed to run mDNS: %v", err)
logger.Warnf("failed to run mDNS: %v", err)
}
}

66
ntp.go
View File

@ -3,7 +3,6 @@ package kvm
import (
"errors"
"fmt"
"log"
"net/http"
"os/exec"
"time"
@ -11,20 +10,54 @@ import (
"github.com/beevik/ntp"
)
var timeSynced = false
const (
timeSyncRetryStep = 5 * time.Second
timeSyncRetryMaxInt = 1 * time.Minute
timeSyncWaitNetChkInt = 100 * time.Millisecond
timeSyncWaitNetUpInt = 3 * time.Second
timeSyncInterval = 1 * time.Hour
timeSyncTimeout = 2 * time.Second
)
var (
timeSyncRetryInterval = 0 * time.Second
defaultNTPServers = []string{
"time.cloudflare.com",
"time.apple.com",
}
)
func TimeSyncLoop() {
for {
fmt.Println("Syncing system time")
if !networkState.checked {
time.Sleep(timeSyncWaitNetChkInt)
continue
}
if !networkState.Up {
logger.Infof("Waiting for network to come up")
time.Sleep(timeSyncWaitNetUpInt)
continue
}
logger.Infof("Syncing system time")
start := time.Now()
err := SyncSystemTime()
if err != nil {
log.Printf("Failed to sync system time: %v", err)
logger.Warnf("Failed to sync system time: %v", err)
// retry after a delay
timeSyncRetryInterval += timeSyncRetryStep
time.Sleep(timeSyncRetryInterval)
// reset the retry interval if it exceeds the max interval
if timeSyncRetryInterval > timeSyncRetryMaxInt {
timeSyncRetryInterval = 0
}
continue
}
log.Printf("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))
timeSynced = true
time.Sleep(1 * time.Hour) //once the first sync is done, sync every hour
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
}
}
@ -41,13 +74,22 @@ func SyncSystemTime() (err error) {
}
func queryNetworkTime() (*time.Time, error) {
ntpServers := []string{
"time.cloudflare.com",
"time.apple.com",
ntpServers, err := getNTPServersFromDHCPInfo()
if err != nil {
logger.Warnf("failed to get NTP servers from DHCP info: %v\n", err)
}
if ntpServers == nil {
ntpServers = defaultNTPServers
logger.Infof("Using default NTP servers: %v\n", ntpServers)
} else {
logger.Infof("Using NTP servers from DHCP: %v\n", ntpServers)
}
for _, server := range ntpServers {
now, err := queryNtpServer(server, 2*time.Second)
now, err := queryNtpServer(server, timeSyncTimeout)
if err == nil {
logger.Infof("NTP server [%s] returned time: %v\n", server, now)
return now, nil
}
}
@ -56,7 +98,7 @@ func queryNetworkTime() (*time.Time, error) {
"http://cloudflare.com",
}
for _, url := range httpUrls {
now, err := queryHttpTime(url, 2*time.Second)
now, err := queryHttpTime(url, timeSyncTimeout)
if err == nil {
return now, nil
}

25
ota.go
View File

@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
@ -77,7 +76,7 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
updateUrl.RawQuery = query.Encode()
fmt.Println("Checking for updates at:", updateUrl.String())
logger.Infof("Checking for updates at: %s", updateUrl)
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
if err != nil {
@ -230,7 +229,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32) error
}
hashSum := hash.Sum(nil)
fmt.Printf("SHA256 hash of %s: %x\n", path, hashSum)
logger.Infof("SHA256 hash of %s: %x", path, hashSum)
if hex.EncodeToString(hashSum) != expectedHash {
return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash)
@ -272,7 +271,7 @@ var otaState = OTAState{}
func triggerOTAStateUpdate() {
go func() {
if currentSession == nil {
log.Println("No active RPC session, skipping update state update")
logger.Info("No active RPC session, skipping update state update")
return
}
writeJSONRPCEvent("otaState", otaState, currentSession)
@ -280,7 +279,7 @@ func triggerOTAStateUpdate() {
}
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
log.Println("Trying to update...")
logger.Info("Trying to update...")
if otaState.Updating {
return fmt.Errorf("update already in progress")
}
@ -315,7 +314,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
rebootNeeded := false
if appUpdateAvailable {
fmt.Printf("App update available: %s -> %s\n", local.AppVersion, remote.AppVersion)
logger.Infof("App update available: %s -> %s", local.AppVersion, remote.AppVersion)
err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress)
if err != nil {
@ -341,14 +340,14 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.AppUpdateProgress = 1
triggerOTAStateUpdate()
fmt.Println("App update downloaded")
logger.Info("App update downloaded")
rebootNeeded = true
} else {
fmt.Println("App is up to date")
logger.Info("App is up to date")
}
if systemUpdateAvailable {
fmt.Printf("System update available: %s -> %s\n", local.SystemVersion, remote.SystemVersion)
logger.Infof("System update available: %s -> %s", local.SystemVersion, remote.SystemVersion)
err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress)
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
@ -366,7 +365,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
triggerOTAStateUpdate()
return err
}
fmt.Println("System update downloaded")
logger.Info("System update downloaded")
verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished
otaState.SystemVerificationProgress = 1
@ -413,17 +412,17 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output)
}
fmt.Printf("rk_ota success, output: %s\n", output)
logger.Infof("rk_ota success, output: %s", output)
otaState.SystemUpdateProgress = 1
otaState.SystemUpdatedAt = &verifyFinished
triggerOTAStateUpdate()
rebootNeeded = true
} else {
fmt.Println("System is up to date")
logger.Info("System is up to date")
}
if rebootNeeded {
fmt.Println("System Rebooting in 10s...")
logger.Info("System Rebooting in 10s")
time.Sleep(10 * time.Second)
cmd := exec.Command("reboot")
err := cmd.Start()

View File

@ -49,7 +49,7 @@ func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) (
if err != nil {
return nil, err
}
buf := make([]byte, 0)
var buf []byte
for {
select {
case data := <-diskReadChan:

269
serial.go Normal file
View File

@ -0,0 +1,269 @@
package kvm
import (
"bufio"
"io"
"strconv"
"strings"
"time"
"github.com/pion/webrtc/v4"
"go.bug.st/serial"
)
const serialPortPath = "/dev/ttyS3"
var port serial.Port
func mountATXControl() error {
_ = port.SetMode(defaultMode)
go runATXControl()
return nil
}
func unmountATXControl() error {
_ = reopenSerialPort()
return nil
}
var (
ledHDDState bool
ledPWRState bool
btnRSTState bool
btnPWRState bool
)
func runATXControl() {
reader := bufio.NewReader(port)
for {
line, err := reader.ReadString('\n')
if err != nil {
logger.Errorf("Error reading from serial port: %v", err)
return
}
// Each line should be 4 binary digits + newline
if len(line) != 5 {
logger.Warnf("Invalid line length: %d", len(line))
continue
}
// Parse new states
newLedHDDState := line[0] == '0'
newLedPWRState := line[1] == '0'
newBtnRSTState := line[2] == '1'
newBtnPWRState := line[3] == '1'
if currentSession != nil {
writeJSONRPCEvent("atxState", ATXState{
Power: newLedPWRState,
HDD: newLedHDDState,
}, currentSession)
}
if newLedHDDState != ledHDDState ||
newLedPWRState != ledPWRState ||
newBtnRSTState != btnRSTState ||
newBtnPWRState != btnPWRState {
logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v",
newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState)
// Update states
ledHDDState = newLedHDDState
ledPWRState = newLedPWRState
btnRSTState = newBtnRSTState
btnPWRState = newBtnPWRState
}
}
}
func pressATXPowerButton(duration time.Duration) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
_, err = port.Write([]byte("BTN_PWR_ON\n"))
if err != nil {
return err
}
time.Sleep(duration)
_, err = port.Write([]byte("BTN_PWR_OFF\n"))
if err != nil {
return err
}
return nil
}
func pressATXResetButton(duration time.Duration) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
_, err = port.Write([]byte("BTN_RST_ON\n"))
if err != nil {
return err
}
time.Sleep(duration)
_, err = port.Write([]byte("BTN_RST_OFF\n"))
if err != nil {
return err
}
return nil
}
func mountDCControl() error {
_ = port.SetMode(defaultMode)
go runDCControl()
return nil
}
func unmountDCControl() error {
_ = reopenSerialPort()
return nil
}
var dcState DCPowerState
func runDCControl() {
reader := bufio.NewReader(port)
for {
line, err := reader.ReadString('\n')
if err != nil {
logger.Errorf("Error reading from serial port: %v", err)
return
}
// Split the line by semicolon
parts := strings.Split(strings.TrimSpace(line), ";")
if len(parts) != 4 {
logger.Warnf("Invalid line: %s", line)
continue
}
// Parse new states
powerState, err := strconv.Atoi(parts[0])
if err != nil {
logger.Warnf("Invalid power state: %v", err)
continue
}
dcState.IsOn = powerState == 1
milliVolts, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
logger.Warnf("Invalid voltage: %v", err)
continue
}
volts := milliVolts / 1000 // Convert mV to V
milliAmps, err := strconv.ParseFloat(parts[2], 64)
if err != nil {
logger.Warnf("Invalid current: %v", err)
continue
}
amps := milliAmps / 1000 // Convert mA to A
milliWatts, err := strconv.ParseFloat(parts[3], 64)
if err != nil {
logger.Warnf("Invalid power: %v", err)
continue
}
watts := milliWatts / 1000 // Convert mW to W
dcState.Voltage = volts
dcState.Current = amps
dcState.Power = watts
if currentSession != nil {
writeJSONRPCEvent("dcState", dcState, currentSession)
}
}
}
func setDCPowerState(on bool) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
command := "PWR_OFF\n"
if on {
command = "PWR_ON\n"
}
_, err = port.Write([]byte(command))
if err != nil {
return err
}
return nil
}
var defaultMode = &serial.Mode{
BaudRate: 115200,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
}
func initSerialPort() {
_ = reopenSerialPort()
if config.ActiveExtension == "atx-power" {
_ = mountATXControl()
} else if config.ActiveExtension == "dc-power" {
_ = mountDCControl()
}
}
func reopenSerialPort() error {
if port != nil {
port.Close()
}
var err error
port, err = serial.Open(serialPortPath, defaultMode)
if err != nil {
logger.Errorf("Error opening serial port: %v", err)
}
return nil
}
func handleSerialChannel(d *webrtc.DataChannel) {
d.OnOpen(func() {
go func() {
buf := make([]byte, 1024)
for {
n, err := port.Read(buf)
if err != nil {
if err != io.EOF {
logger.Errorf("Failed to read from serial port: %v", err)
}
break
}
err = d.Send(buf[:n])
if err != nil {
logger.Errorf("Failed to send serial output: %v", err)
break
}
}
}()
})
d.OnMessage(func(msg webrtc.DataChannelMessage) {
if port == nil {
return
}
_, err := port.Write(msg.Data)
if err != nil {
logger.Errorf("Failed to write to serial: %v", err)
}
})
d.OnClose(func() {
})
}

View File

@ -55,11 +55,13 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
var size TerminalSize
err := json.Unmarshal([]byte(msg.Data), &size)
if err == nil {
pty.Setsize(ptmx, &pty.Winsize{
err = pty.Setsize(ptmx, &pty.Winsize{
Rows: uint16(size.Rows),
Cols: uint16(size.Cols),
})
return
if err == nil {
return
}
}
logger.Errorf("Failed to parse terminal size: %v", err)
}
@ -74,7 +76,7 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
ptmx.Close()
}
if cmd != nil && cmd.Process != nil {
cmd.Process.Kill()
_ = cmd.Process.Kill()
}
})
}

View File

@ -0,0 +1,4 @@
# No need for VITE_CLOUD_APP it's only needed for the device build
# We use this for all the cloud API requests from the browser
VITE_CLOUD_API=http://localhost:3000

4
ui/.env.cloud-production Normal file
View File

@ -0,0 +1,4 @@
# No need for VITE_CLOUD_APP it's only needed for the device build
# We use this for all the cloud API requests from the browser
VITE_CLOUD_API=https://api.jetkvm.com

4
ui/.env.cloud-staging Normal file
View File

@ -0,0 +1,4 @@
# No need for VITE_CLOUD_APP it's only needed for the device build
# We use this for all the cloud API requests from the browser
VITE_CLOUD_API=https://staging-api.jetkvm.com

View File

@ -1,4 +0,0 @@
VITE_SIGNAL_API=http://localhost:3000
VITE_CLOUD_APP=http://localhost:5173
VITE_CLOUD_API=http://localhost:3000

View File

@ -1,4 +0,0 @@
VITE_SIGNAL_API= # Uses the KVM device's IP address as the signal API endpoint
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com

View File

@ -1,4 +0,0 @@
VITE_SIGNAL_API=https://api.jetkvm.com
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com

19
ui/dev_device.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# Check if an IP address was provided as an argument
if [ -z "$1" ]; then
echo "Usage: $0 <JetKVM IP Address>"
exit 1
fi
ip_address="$1"
# Print header
echo "┌──────────────────────────────────────┐"
echo "│ JetKVM Development Setup │"
echo "└──────────────────────────────────────┘"
# Set the environment variable and run Vite
echo "Starting development server with JetKVM device at: $ip_address"
sleep 1
JETKVM_PROXY_URL="http://$ip_address" npx vite dev --mode=device

609
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,59 +7,65 @@
"node": "21.1.0"
},
"scripts": {
"dev": "vite dev --mode=development",
"dev": "./dev_device.sh",
"dev:cloud": "vite dev --mode=cloud-development",
"build": "npm run build:prod",
"build:device": "tsc && vite build --mode=device --emptyOutDir",
"build:prod": "tsc && vite build --mode=production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
"build:staging": "tsc && vite build --mode=cloud-staging",
"build:prod": "tsc && vite build --mode=cloud-production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "^2.1.10",
"@headlessui/tailwindcss": "^0.2.0",
"@heroicons/react": "^2.1.3",
"@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.2",
"@heroicons/react": "^2.2.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.1",
"focus-trap-react": "^10.2.3",
"framer-motion": "^11.0.28",
"framer-motion": "^11.15.0",
"lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4",
"motion": "^12.4.7",
"react": "^18.2.0",
"react-animate-height": "^3.2.3",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0",
"react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0",
"react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.7.112",
"recharts": "^2.12.6",
"tailwind-merge": "^2.2.2",
"usehooks-ts": "^3.1.0",
"react-xtermjs": "^1.0.9",
"recharts": "^2.15.1",
"semver": "^7.7.1",
"tailwind-merge": "^2.5.5",
"usehooks-ts": "^3.1.1",
"validator": "^13.12.0",
"xterm": "^5.3.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.12",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.3.0",
"@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.19",
"@vitejs/plugin-react-swc": "^3.8.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"postcss": "^8.5.3",
"prettier": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.5.13",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2"
}

View File

@ -2,12 +2,12 @@ import { Button } from "@components/Button";
import {
useHidStore,
useMountMediaStore,
useUiStore,
useSettingsStore,
useUiStore,
} from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container";
import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6";
@ -16,19 +16,22 @@ import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import ExtensionPopover from "./popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
export default function Actionbar({
requestFullscreen,
}: {
requestFullscreen: () => Promise<void>;
}) {
const { navigateTo } = useDeviceUiNavigation();
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const terminalType = useUiStore(state => state.terminalType);
const setTerminalType = useUiStore(state => state.setTerminalType);
const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState,
);
@ -53,7 +56,7 @@ export default function Actionbar({
);
return (
<Container className="bg-white border-b border-b-slate-800/20 dark:bg-slate-900 dark:border-b-slate-300/20">
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
<div
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
@ -66,7 +69,7 @@ export default function Actionbar({
theme="light"
text="Web Terminal"
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setEnableTerminal(!enableTerminal)}
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
/>
)}
<Popover>
@ -92,7 +95,7 @@ export default function Actionbar({
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<div className="mx-auto w-full max-w-xl">
<PasteModal />
</div>
);
@ -134,7 +137,7 @@ export default function Actionbar({
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<div className="mx-auto w-full max-w-xl">
<MountPopopover />
</div>
);
@ -148,7 +151,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Wake on Lan"
text="Wake on LAN"
onClick={() => {
setDisableFocusTrap(true);
}}
@ -186,7 +189,7 @@ export default function Actionbar({
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<div className="mx-auto w-full max-w-xl">
<WakeOnLanModal />
</div>
);
@ -206,6 +209,33 @@ export default function Actionbar({
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Extension"
LeadingIcon={LuCable}
onClick={() => {
setDisableFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return <ExtensionPopover />;
}}
</PopoverPanel>
</Popover>
<div className="block lg:hidden">
<Button
size="XS"
@ -232,16 +262,17 @@ export default function Actionbar({
/>
</div>
<div className="hidden xs:block ">
<div>
<Button
size="XS"
theme="light"
text="Settings"
LeadingIcon={LuSettings}
onClick={() => toggleSidebarView("system")}
onClick={() => navigateTo("/settings")}
/>
</div>
<div className="items-center hidden gap-x-2 lg:flex">
<div className="hidden items-center gap-x-2 lg:flex">
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
<Button
size="XS"

View File

@ -6,6 +6,7 @@ import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import Fieldset from "@components/Fieldset";
import GridBackground from "@components/GridBackground";
import StepCounter from "@components/StepCounter";
import { CLOUD_API } from "@/ui.config";
type AuthLayoutProps = {
title: string;
@ -62,7 +63,7 @@ export default function AuthLayout({
<Fieldset className="space-y-12">
<div className="max-w-sm mx-auto space-y-4">
<form
action={`${import.meta.env.VITE_CLOUD_API}/oidc/google`}
action={`${CLOUD_API}/oidc/google`}
method="POST"
>
{/*This could be the KVM ID*/}

View File

@ -22,7 +22,7 @@ const AutoHeight = ({ children, ...props }: { children: React.ReactNode }) => {
{...props}
height={height}
duration={300}
contentClassName="auto-content pointer-events-none"
contentClassName="h-fit"
contentRef={contentDiv}
disableDisplayNone
>

View File

@ -156,7 +156,16 @@ function ButtonContent(props: ButtonContentPropsType) {
type ButtonPropsType = Pick<
JSX.IntrinsicElements["button"],
"type" | "disabled" | "onClick" | "name" | "value" | "formNoValidate" | "onMouseLeave"
| "type"
| "disabled"
| "onClick"
| "name"
| "value"
| "formNoValidate"
| "onMouseLeave"
| "onMouseDown"
| "onMouseUp"
| "onMouseLeave"
> &
React.ComponentProps<typeof ButtonContent> & {
fetcher?: FetcherWithComponents<unknown>;
@ -179,6 +188,9 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
type={type}
disabled={disabled}
onClick={onClick}
onMouseDown={props?.onMouseDown}
onMouseUp={props?.onMouseUp}
onMouseLeave={props?.onMouseLeave}
name={props.name}
value={props.value}
>

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { forwardRef } from "react";
import { cx } from "@/cva.config";
type CardPropsType = {
@ -16,23 +16,28 @@ export const GridCard = ({
return (
<Card className={cx("overflow-hidden", cardClassName)}>
<div className="relative h-full">
<div className="absolute inset-0 z-0 w-full h-full transition-colors duration-300 ease-in-out bg-gradient-to-tr from-blue-50/30 to-blue-50/20 dark:from-slate-800/30 dark:to-slate-800/20" />
<div className="absolute inset-0 z-0 h-full w-full bg-gradient-to-tr from-blue-50/30 to-blue-50/20 transition-colors duration-300 ease-in-out dark:from-slate-800/30 dark:to-slate-800/20" />
<div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" />
<div className="h-full isolate">{children}</div>
<div className="isolate h-full">{children}</div>
</div>
</Card>
);
};
export default function Card({ children, className }: CardPropsType) {
const Card = forwardRef<HTMLDivElement, CardPropsType>(({ children, className }, ref) => {
return (
<div
ref={ref}
className={cx(
"w-full rounded border-none dark:bg-slate-800 dark:outline-slate-300/20 bg-white shadow outline outline-1 outline-slate-800/30",
"w-full rounded border-none bg-white shadow outline outline-1 outline-slate-800/30 dark:bg-slate-800 dark:outline-slate-300/20",
className,
)}
>
{children}
</div>
);
}
});
Card.displayName = "Card";
export default Card;

View File

@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useFeatureFlag } from "../hooks/useFeatureFlag";
export function FeatureFlag({
minAppVersion,
name = "unnamed",
fallback = null,
children,
}: {
minAppVersion: string;
name?: string;
fallback?: React.ReactNode;
children: React.ReactNode;
}) {
const { isEnabled, appVersion } = useFeatureFlag(minAppVersion);
useEffect(() => {
if (!appVersion) return;
console.log(
`Feature '${name}' ${isEnabled ? "ENABLED" : "DISABLED"}: ` +
`Current version: ${appVersion}, ` +
`Required min version: ${minAppVersion || "N/A"}`,
);
}, [isEnabled, name, minAppVersion, appVersion]);
return isEnabled ? children : fallback;
}

View File

@ -26,7 +26,7 @@ export default function FieldLabel({
>
{label}
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
{description}
</span>
)}
@ -34,12 +34,12 @@ export default function FieldLabel({
);
} else if (as === "span") {
return (
<div className="flex flex-col select-none">
<span className="font-display text-[13px] font-medium leading-snug text-black">
<div className="flex select-none flex-col">
<span className="font-display text-[13px] font-medium leading-snug text-black dark:text-white">
{label}
</span>
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
{description}
</span>
)}

View File

@ -1,7 +1,7 @@
import { Fragment, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton, Transition } from "@headlessui/react";
import { Menu, MenuButton } from "@headlessui/react";
import Container from "@/components/Container";
import Card from "@/components/Card";
import { LuMonitorSmartphone } from "react-icons/lu";
@ -14,6 +14,7 @@ import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import api from "../api";
import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
interface NavbarProps {
isLoggedIn: boolean;
@ -36,24 +37,22 @@ export default function DashboardNavbar({
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice
? `${import.meta.env.VITE_SIGNAL_API}/auth/logout`
: `${import.meta.env.VITE_CLOUD_API}/logout`;
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
const res = await api.POST(logoutUrl);
if (!res.ok) return;
setUser(null);
// The root route will redirect to appropiate login page, be it the local one or the cloud one
// The root route will redirect to appropriate login page, be it the local one or the cloud one
navigate("/");
}, [navigate, setUser]);
const usbState = useHidStore(state => state.usbState);
return (
<div className="w-full bg-white border-b select-none border-b-slate-800/20 dark:border-b-slate-300/20 dark:bg-slate-900">
<div className="w-full select-none border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
<Container>
<div className="flex items-center justify-between h-14">
<div className="flex items-center shrink-0 gap-x-8">
<div className="flex h-14 items-center justify-between">
<div className="flex shrink-0 items-center gap-x-8">
<div className="inline-block shrink-0">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
@ -74,10 +73,10 @@ export default function DashboardNavbar({
})}
</div>
</div>
<div className="flex items-center justify-end w-full gap-x-2">
<div className="flex items-center space-x-4 shrink-0">
<div className="flex w-full items-center justify-end gap-x-2">
<div className="flex shrink-0 items-center space-x-4">
{showConnectionStatus && (
<div className="items-center hidden gap-x-2 md:flex">
<div className="hidden items-center gap-x-2 md:flex">
<div className="w-[159px]">
<PeerConnectionStatusCard
state={peerConnectionState}
@ -104,66 +103,55 @@ export default function DashboardNavbar({
text={
<>
{picture ? <></> : userEmail}
<ChevronDownIcon className="w-4 h-4 shrink-0 text-slate-900 dark:text-white" />
<ChevronDownIcon className="h-4 w-4 shrink-0 text-slate-900 dark:text-white" />
</>
}
LeadingIcon={({ className }) => (
LeadingIcon={({ className }) =>
picture && (
<img
src={picture}
alt="Avatar"
className={cx(
className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)}
className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)}
/>
)
)}
}
/>
</MenuButton>
</div>
<Transition
as={Fragment}
enter="transition ease-in-out duration-75"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in-out duration-75"
leaveFrom="transform opacity-75"
leaveTo="transform opacity-0"
>
<Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
<Card className="overflow-hidden">
<div className="p-1 space-y-1 dark:text-white">
{userEmail && (
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<Menu.Item>
<div className="p-2">
<div className="text-xs font-display">
Logged in as
</div>
<div className="w-[200px] truncate font-display text-sm font-semibold">
{userEmail}
</div>
</div>
</Menu.Item>
</div>
)}
<div>
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right focus:outline-none">
<Card className="overflow-hidden">
<div className="space-y-1 p-1 dark:text-white">
{userEmail && (
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<Menu.Item>
<div onClick={onLogout}>
<button className="block w-full">
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-600 dark:hover:bg-slate-700">
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" />
<div className="font-display">Log out</div>
</div>
</button>
<div className="p-2">
<div className="font-display text-xs">Logged in as</div>
<div className="w-[200px] truncate font-display text-sm font-semibold">
{userEmail}
</div>
</div>
</Menu.Item>
</div>
)}
<div>
<Menu.Item>
<div onClick={onLogout}>
<button className="block w-full">
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700">
<ArrowLeftEndOnRectangleIcon className="h-4 w-4" />
<div className="font-display">Log out</div>
</div>
</button>
</div>
</Menu.Item>
</div>
</Card>
</Menu.Items>
</Transition>
</div>
</Card>
</Menu.Items>
</Menu>
</>
) : null}

View File

@ -12,7 +12,7 @@ function getRelativeTimeString(date: Date | number, lang = navigator.language):
// Get the amount of seconds between the given date and now
const deltaSeconds = Math.round((timeMs - Date.now()) / 1000);
// Array reprsenting one minute, hour, day, week, month, etc in seconds
// Array representing one minute, hour, day, week, month, etc in seconds
const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
// Array equivalent to the above but in the string representation of the units
@ -52,7 +52,7 @@ export default function KvmCard({
return (
<Card>
<div className="px-5 py-5 space-y-3">
<div className="flex justify-between items-cente">
<div className="flex justify-between items-center">
<div className="space-y-1.5">
<div className="text-lg font-bold leading-none text-black dark:text-white">
{title}

View File

@ -2,7 +2,7 @@ import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config";
export default function Modal({
const Modal = React.memo(function Modal({
children,
className,
open,
@ -17,22 +17,25 @@ export default function Modal({
<Dialog open={open} onClose={onClose} className="relative z-10">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 dark:bg-slate-900/90 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in dark:bg-slate-900/90"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
{/* TODO: This doesn't work well with other-sessions */}
<div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4">
<DialogPanel
transition
className={cx(
"pointer-events-none relative w-full sm:my-8",
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-300 data-[enter]:ease-out data-[leave]:ease-in",
"pointer-events-none relative w-full md:my-8 md:!mt-[10vh]",
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in",
className,
)}
>
<div className="inline-block w-full text-left pointer-events-auto">
<div className="pointer-events-auto inline-block w-full text-left">
<div className="flex justify-center" onClick={onClose}>
<div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}>
<div
className="pointer-events-none w-full"
onClick={e => e.stopPropagation()}
>
{children}
</div>
</div>
@ -42,4 +45,6 @@ export default function Modal({
</div>
</Dialog>
);
}
});
export default Modal;

View File

@ -19,7 +19,7 @@ type SelectMenuProps = Pick<
direction?: "vertical" | "horizontal";
error?: string;
fullWidth?: boolean;
} & React.ComponentProps<typeof FieldLabel>;
} & Partial<React.ComponentProps<typeof FieldLabel>>;
const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs",
@ -60,7 +60,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
)}
>
{label && <FieldLabel label={label} id={id} as="span" />}
<Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0">
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
<select
ref={ref}
name={name}
@ -69,10 +69,13 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
classes,
// General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0",
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
// Hover
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white dark:hover:bg-slate-800/80 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-900",
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white",
// Dark mode
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-800",
// Invalid
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
@ -82,9 +85,6 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
// Disabled
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
// Dark mode text
"dark:bg-slate-900 dark:text-white"
)}
value={value}
id={id}

View File

@ -1,6 +1,6 @@
import { ReactNode } from "react";
export function SectionHeader({
export function SettingsPageHeader({
title,
description,
}: {
@ -8,8 +8,8 @@ export function SectionHeader({
description: string | ReactNode;
}) {
return (
<div>
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
<div className="select-none">
<h2 className=" text-xl font-extrabold text-black dark:text-white">{title}</h2>
<div className="text-sm text-black dark:text-slate-300">{description}</div>
</div>
);

View File

@ -0,0 +1,16 @@
import { ReactNode } from "react";
export function SettingsSectionHeader({
title,
description,
}: {
title: string | ReactNode;
description: string | ReactNode;
}) {
return (
<div className="select-none">
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
<div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>
</div>
);
}

View File

@ -1,32 +1,190 @@
import "react-simple-keyboard/build/css/index.css";
import { useUiStore, useRTCStore } from "@/hooks/stores";
import { XTerm } from "./Xterm";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
import { Button } from "./Button";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { cx } from "../cva.config";
import { Transition } from "@headlessui/react";
import { cx } from "@/cva.config";
import { useEffect } from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { ClipboardAddon } from "@xterm/addon-clipboard";
function TerminalWrapper() {
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const terminalChannel = useRTCStore(state => state.terminalChannel);
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Terminal theme configuration
const SOLARIZED_THEME = {
background: "#0f172a", // Solarized base03
foreground: "#839496", // Solarized base0
cursor: "#93a1a1", // Solarized base1
cursorAccent: "#002b36", // Solarized base03
black: "#073642", // Solarized base02
red: "#dc322f", // Solarized red
green: "#859900", // Solarized green
yellow: "#b58900", // Solarized yellow
blue: "#268bd2", // Solarized blue
magenta: "#d33682", // Solarized magenta
cyan: "#2aa198", // Solarized cyan
white: "#eee8d5", // Solarized base2
brightBlack: "#002b36", // Solarized base03
brightRed: "#cb4b16", // Solarized orange
brightGreen: "#586e75", // Solarized base01
brightYellow: "#657b83", // Solarized base00
brightBlue: "#839496", // Solarized base0
brightMagenta: "#6c71c4", // Solarized violet
brightCyan: "#93a1a1", // Solarized base1
brightWhite: "#fdf6e3", // Solarized base3
} as const;
const TERMINAL_CONFIG = {
theme: SOLARIZED_THEME,
fontFamily: "'Fira Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 13,
allowProposedApi: true,
scrollback: 1000,
cursorBlink: true,
smoothScrollDuration: 100,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
convertEol: true,
linuxMode: false,
// Add these configurations:
cursorStyle: "block",
rendererType: "canvas", // Ensure we're using the canvas renderer
} as const;
function Terminal({
title,
dataChannel,
type,
}: {
title: string;
dataChannel: RTCDataChannel;
type: AvailableTerminalTypes;
}) {
const enableTerminal = useUiStore(state => state.terminalType == type);
const setTerminalType = useUiStore(state => state.setTerminalType);
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
useEffect(() => {
setTimeout(() => {
setDisableKeyboardFocusTrap(enableTerminal);
}, 500);
return () => {
setDisableKeyboardFocusTrap(false);
};
}, [enableTerminal, instance, ref, setDisableKeyboardFocusTrap, type]);
const readyState = dataChannel.readyState;
useEffect(() => {
if (readyState !== "open") return;
const abortController = new AbortController();
const binaryType = dataChannel.binaryType;
dataChannel.addEventListener(
"message",
e => {
// Handle binary data differently based on browser implementation
// Firefox sends data as blobs, chrome sends data as arraybuffer
if (binaryType === "arraybuffer") {
instance?.write(new Uint8Array(e.data));
} else if (binaryType === "blob") {
const reader = new FileReader();
reader.onload = () => {
if (!instance) return;
if (!reader.result) return;
instance.write(new Uint8Array(reader.result as ArrayBuffer));
};
reader.readAsArrayBuffer(e.data);
}
},
{ signal: abortController.signal },
);
const onDataHandler = instance?.onData(data => {
dataChannel.send(data);
});
// Setup escape key handler
const onKeyHandler = instance?.onKey(e => {
const { domEvent } = e;
if (domEvent.key === "Escape") {
setTerminalType("none");
setDisableKeyboardFocusTrap(false);
domEvent.preventDefault();
}
});
// Send initial terminal size
if (dataChannel.readyState === "open") {
dataChannel.send(JSON.stringify({ rows: instance?.rows, cols: instance?.cols }));
}
return () => {
abortController.abort();
onDataHandler?.dispose();
onKeyHandler?.dispose();
};
}, [dataChannel, instance, readyState, setDisableKeyboardFocusTrap, setTerminalType]);
useEffect(() => {
if (!instance) return;
// Load the fit addon
const fitAddon = new FitAddon();
instance?.loadAddon(fitAddon);
instance?.loadAddon(new ClipboardAddon());
instance?.loadAddon(new Unicode11Addon());
instance?.loadAddon(new WebLinksAddon());
instance.unicode.activeVersion = "11";
if (isWebGl2Supported) {
const webGl2Addon = new WebglAddon();
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
instance?.loadAddon(webGl2Addon);
}
const handleResize = () => fitAddon.fit();
// Handle resize event
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [ref, instance, dataChannel]);
return (
<div onKeyDown={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}>
<Transition show={enableTerminal} appear>
<div
onKeyDown={e => {
e.stopPropagation();
}}
onKeyUp={e => e.stopPropagation()}
>
<div>
<div
className={cx([
// Base styles
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
"translate-y-[0px]",
"data-[enter]:translate-y-[500px]",
"data-[closed]:translate-y-[500px]",
])}
className={cx(
[
// Base styles
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
"translate-y-[0px]",
],
{
"pointer-events-none translate-y-[500px] opacity-100 transition duration-300":
!enableTerminal,
"pointer-events-auto translate-y-[0px] opacity-100 transition duration-300":
enableTerminal,
},
)}
>
<div className="h-[500px] w-full bg-[#0f172a]">
<div className="flex items-center justify-center px-2 py-1 bg-white dark:bg-slate-800 border-y border-y-slate-800/30 dark:border-y-slate-300/20">
<div className="flex items-center justify-center border-y border-y-slate-800/30 bg-white px-2 py-1 dark:border-y-slate-300/20 dark:bg-slate-800">
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Web Terminal
{title}
</h2>
<div className="absolute right-2">
<Button
@ -34,18 +192,19 @@ function TerminalWrapper() {
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setEnableTerminal(false)}
onClick={() => setTerminalType("none")}
/>
</div>
</div>
<div className="h-[calc(100%-36px)] p-3">
<XTerm terminalChannel={terminalChannel} />
<div ref={ref} style={{ height: "100%", width: "100%" }} />
</div>
</div>
</div>
</Transition>
</div>
</div>
);
}
export default TerminalWrapper;
export default Terminal;

View File

@ -0,0 +1,117 @@
import { Button } from "@components/Button";
import { InputFieldWithLabel } from "./InputField";
import { UsbConfigState } from "@/hooks/stores";
import { useEffect, useCallback, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { USBConfig } from "./UsbConfigSetting";
export default function UpdateUsbConfigModal({
onSetUsbConfig,
onRestoreToDefault,
}: {
onSetUsbConfig: (usbConfig: USBConfig) => void;
onRestoreToDefault: () => void;
}) {
const [usbConfigState, setUsbConfigState] = useState<USBConfig>({
vendor_id: "",
product_id: "",
serial_number: "",
manufacturer: "",
product: "",
});
const [send] = useJsonRpc();
const syncUsbConfig = useCallback(() => {
send("getUsbConfig", {}, resp => {
if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error);
} else {
setUsbConfigState(resp.result as UsbConfigState);
}
});
}, [send, setUsbConfigState]);
// Load stored usb config from the backend
useEffect(() => {
syncUsbConfig();
}, [syncUsbConfig]);
const handleUsbVendorIdChange = (value: string) => {
setUsbConfigState({ ...usbConfigState, vendor_id: value });
};
const handleUsbProductIdChange = (value: string) => {
setUsbConfigState({ ...usbConfigState, product_id: value });
};
const handleUsbSerialChange = (value: string) => {
setUsbConfigState({ ...usbConfigState, serial_number: value });
};
const handleUsbManufacturer = (value: string) => {
setUsbConfigState({ ...usbConfigState, manufacturer: value });
};
const handleUsbProduct = (value: string) => {
setUsbConfigState({ ...usbConfigState, product: value });
};
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<InputFieldWithLabel
required
label="Vendor ID"
placeholder="Enter Vendor ID"
pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.vendor_id}
onChange={e => handleUsbVendorIdChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Product ID"
placeholder="Enter Product ID"
pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.product_id}
onChange={e => handleUsbProductIdChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Serial Number"
placeholder="Enter Serial Number"
defaultValue={usbConfigState?.serial_number}
onChange={e => handleUsbSerialChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Manufacturer"
placeholder="Enter Manufacturer"
defaultValue={usbConfigState?.manufacturer}
onChange={e => handleUsbManufacturer(e.target.value)}
/>
<InputFieldWithLabel
required
label="Product Name"
placeholder="Enter Product Name"
defaultValue={usbConfigState?.product}
onChange={e => handleUsbProduct(e.target.value)}
/>
</div>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text="Update USB Config"
onClick={() => onSetUsbConfig(usbConfigState)}
/>
<Button
size="SM"
theme="light"
text="Restore to Default"
onClick={onRestoreToDefault}
/>
</div>
</div>
);
}

View File

@ -68,7 +68,7 @@ export default function USBStateStatus({
};
const props = StatusCardProps[state];
if (!props) {
console.log("Unsupport USB state: ", state);
console.log("Unsupported USB state: ", state);
return;
}

View File

@ -2,25 +2,19 @@ import { cx } from "@/cva.config";
import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
import { UpdateState } from "@/hooks/stores";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
interface UpdateInProgressStatusCardProps {
setIsUpdateDialogOpen: (isOpen: boolean) => void;
setModalView: (view: UpdateState["modalView"]) => void;
}
export default function UpdateInProgressStatusCard() {
const { navigateTo } = useDeviceUiNavigation();
export default function UpdateInProgressStatusCard({
setIsUpdateDialogOpen,
setModalView,
}: UpdateInProgressStatusCardProps) {
return (
<div className="w-full transition-all duration-300 ease-in-out opacity-100 select-none">
<div className="w-full select-none opacity-100 transition-all duration-300 ease-in-out">
<GridCard cardClassName="!shadow-xl">
<div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white">
<div className="flex items-center gap-x-3">
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
<div className="space-y-1">
<div className="text-sm font-semibold leading-none transition text-ellipsis">
<div className="text-ellipsis text-sm font-semibold leading-none transition">
Update in Progress
</div>
<div className="text-sm leading-none">
@ -37,10 +31,7 @@ export default function UpdateInProgressStatusCard({
className="pointer-events-auto"
theme="light"
text="View Details"
onClick={() => {
setModalView("updating");
setIsUpdateDialogOpen(true);
}}
onClick={() => navigateTo("/settings/general/update")}
/>
</div>
</GridCard>

View File

@ -0,0 +1,177 @@
import { useMemo } from "react";
import { useCallback } from "react";
import { useEffect, useState } from "react";
import { UsbConfigState } from "../hooks/stores";
import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
import { SelectMenuBasic } from "./SelectMenuBasic";
import USBConfigDialog from "./USBConfigDialog";
const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&");
function generateNumber(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
function generateHex(min: number, max: number) {
const len = generateNumber(min, max);
const n = (Math.random() * 0xfffff * 1000000).toString(16);
return n.slice(0, len);
}
export interface USBConfig {
vendor_id: string;
product_id: string;
serial_number: string;
manufacturer: string;
product: string;
}
const usbConfigs = [
{
label: "JetKVM Default",
value: "USB Emulation Device",
},
{
label: "Logitech Universal Adapter",
value: "Logitech USB Input Device",
},
{
label: "Microsoft Wireless MultiMedia Keyboard",
value: "Wireless MultiMedia Keyboard",
},
{
label: "Dell Multimedia Pro Keyboard",
value: "Multimedia Pro Keyboard",
},
];
type UsbConfigMap = Record<string, USBConfig>;
export function UsbConfigSetting() {
const [send] = useJsonRpc();
const [usbConfigProduct, setUsbConfigProduct] = useState("");
const [deviceId, setDeviceId] = useState("");
const usbConfigData: UsbConfigMap = useMemo(
() => ({
"USB Emulation Device": {
vendor_id: "0x1d6b",
product_id: "0x0104",
serial_number: deviceId,
manufacturer: "JetKVM",
product: "USB Emulation Device",
},
"Logitech USB Input Device": {
vendor_id: "0x046d",
product_id: "0xc52b",
serial_number: generatedSerialNumber,
manufacturer: "Logitech (x64)",
product: "Logitech USB Input Device",
},
"Wireless MultiMedia Keyboard": {
vendor_id: "0x045e",
product_id: "0x005f",
serial_number: generatedSerialNumber,
manufacturer: "Microsoft",
product: "Wireless MultiMedia Keyboard",
},
"Multimedia Pro Keyboard": {
vendor_id: "0x413c",
product_id: "0x2011",
serial_number: generatedSerialNumber,
manufacturer: "Dell Inc.",
product: "Multimedia Pro Keyboard",
},
}),
[deviceId],
);
const syncUsbConfigProduct = useCallback(() => {
send("getUsbConfig", {}, resp => {
if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error);
notifications.error(
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`,
);
} else {
console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result);
const usbConfigState = resp.result as UsbConfigState;
const product = usbConfigs.map(u => u.value).includes(usbConfigState.product)
? usbConfigState.product
: "custom";
setUsbConfigProduct(product);
}
});
}, [send]);
const handleUsbConfigChange = useCallback(
(usbConfig: USBConfig) => {
send("setUsbConfig", { usbConfig }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
);
return;
}
// setUsbConfigProduct(usbConfig.product);
notifications.success(
`USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`,
);
syncUsbConfigProduct();
});
},
[send, syncUsbConfigProduct],
);
useEffect(() => {
send("getDeviceID", {}, async resp => {
if ("error" in resp) {
return notifications.error(
`Failed to get device ID: ${resp.error.data || "Unknown error"}`,
);
}
setDeviceId(resp.result as string);
});
syncUsbConfigProduct();
}, [send, syncUsbConfigProduct]);
return (
<>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsItem
title="USB Device Emulation"
description="Set a Preconfigured USB Device"
>
<SelectMenuBasic
size="SM"
label=""
className="max-w-[192px]"
value={usbConfigProduct}
onChange={e => {
if (e.target.value === "custom") {
setUsbConfigProduct(e.target.value);
} else {
const usbConfig = usbConfigData[e.target.value];
handleUsbConfigChange(usbConfig);
}
}}
options={[...usbConfigs, { value: "custom", label: "Custom" }]}
/>
</SettingsItem>
{usbConfigProduct === "custom" && (
<USBConfigDialog
onSetUsbConfig={usbConfig => handleUsbConfigChange(usbConfig)}
onRestoreToDefault={() =>
handleUsbConfigChange(usbConfigData[usbConfigs[0].value])
}
/>
)}
</>
);
}

View File

@ -0,0 +1,129 @@
import { useCallback } from "react";
import { useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
import Checkbox from "./Checkbox";
export interface USBConfig {
vendor_id: string;
product_id: string;
serial_number: string;
manufacturer: string;
product: string;
}
export interface UsbDeviceConfig {
keyboard: boolean;
absolute_mouse: boolean;
relative_mouse: boolean;
mass_storage: boolean;
}
const defaultUsbDeviceConfig: UsbDeviceConfig = {
keyboard: true,
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
}
export function UsbDeviceSetting() {
const [send] = useJsonRpc();
const [usbDeviceConfig, setUsbDeviceConfig] = useState<UsbDeviceConfig>(defaultUsbDeviceConfig);
const syncUsbDeviceConfig = useCallback(() => {
send("getUsbDevices", {}, resp => {
if ("error" in resp) {
console.error("Failed to load USB devices:", resp.error);
notifications.error(
`Failed to load USB devices: ${resp.error.data || "Unknown error"}`,
);
} else {
console.log("syncUsbDeviceConfig#getUsbDevices result:", resp.result);
const usbConfigState = resp.result as UsbDeviceConfig;
setUsbDeviceConfig(usbConfigState);
}
});
}, [send]);
const handleUsbConfigChange = useCallback(
(devices: UsbDeviceConfig) => {
send("setUsbDevices", { devices }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success(
`USB Devices updated`
);
syncUsbDeviceConfig();
});
},
[send, syncUsbDeviceConfig],
);
const onUsbConfigItemChange = useCallback((key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => {
setUsbDeviceConfig((val) => {
val[key] = e.target.checked;
handleUsbConfigChange(val);
return val;
});
}, [handleUsbConfigChange]);
useEffect(() => {
syncUsbDeviceConfig();
}, [syncUsbDeviceConfig]);
return (
<>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="space-y-4">
<SettingsItem
title="Enable Keyboard"
description="Enable Keyboard"
>
<Checkbox
checked={usbDeviceConfig.keyboard}
onChange={onUsbConfigItemChange("keyboard")}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Enable Absolute Mouse (Pointer)"
description="Enable Absolute Mouse (Pointer)"
>
<Checkbox
checked={usbDeviceConfig.absolute_mouse}
onChange={onUsbConfigItemChange("absolute_mouse")}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Enable Relative Mouse"
description="Enable Relative Mouse"
>
<Checkbox
checked={usbDeviceConfig.relative_mouse}
onChange={onUsbConfigItemChange("relative_mouse")}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Enable USB Mass Storage"
description="Sometimes it might need to be disabled to prevent issues with certain devices"
>
<Checkbox
checked={usbDeviceConfig.mass_storage}
onChange={onUsbConfigItemChange("mass_storage")}
/>
</SettingsItem>
</div>
</>
);
}

View File

@ -1,10 +1,10 @@
import React from "react";
import { Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner";
import { GridCard } from "@components/Card";
import { motion, AnimatePresence } from "motion/react";
interface OverlayContentProps {
children: React.ReactNode;
@ -12,7 +12,7 @@ interface OverlayContentProps {
function OverlayContent({ children }: OverlayContentProps) {
return (
<GridCard cardClassName="h-full pointer-events-auto !outline-none">
<div className="flex flex-col items-center justify-center w-full h-full border rounded-md border-slate-800/30 dark:border-slate-300/20">
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-slate-800/30 dark:border-slate-300/20">
{children}
</div>
</GridCard>
@ -25,28 +25,31 @@ interface LoadingOverlayProps {
export function LoadingOverlay({ show }: LoadingOverlayProps) {
return (
<Transition
show={show}
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-center justify-center gap-y-1">
<div className="flex items-center justify-center w-12 h-12 animate">
<LoadingSpinner className="w-8 h-8 text-blue-800 dark:text-blue-200" />
<AnimatePresence>
{show && (
<motion.div
className="absolute inset-0 aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: show ? 0.3 : 0.1,
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">
Loading video stream...
</p>
</div>
<p className="text-sm text-center text-slate-700 dark:text-slate-300">
Loading video stream...
</p>
</div>
</OverlayContent>
</div>
</Transition>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}
@ -56,45 +59,48 @@ interface ConnectionErrorOverlayProps {
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
return (
<Transition
show={show}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 z-10 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<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="pl-4 space-y-2 text-left list-disc">
<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>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Troubleshooting Guide"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
<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="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>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Troubleshooting Guide"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}
@ -109,85 +115,92 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
return (
<>
<Transition
show={show && isNoSignal}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-all duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>Ensure the HDMI cable securely connected at both ends</li>
<li>Ensure source device is powered on and outputting a signal</li>
<li>
If using an adapter, it&apos;s compatible and functioning
correctly
</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
<AnimatePresence>
{show && isNoSignal && (
<motion.div
className="absolute inset-0 w-full h-full aspect-video"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut"
}}
>
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Ensure the HDMI cable securely connected at both ends</li>
<li>Ensure source device is powered on and outputting a signal</li>
<li>
If using an adapter, it&apos;s compatible and functioning
correctly
</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
<Transition
show={show && isOtherError}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-300 "
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>A loose or faulty HDMI connection</li>
<li>Incompatible resolution or refresh rate settings</li>
<li>Issues with the source device&apos;s HDMI output</li>
</ul>
</div>
<div>
<LinkButton
to={"/help/hdmi-error"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{show && isOtherError && (
<motion.div
className="absolute inset-0 aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut"
}}
>
<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">HDMI signal error detected.</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>A loose or faulty HDMI connection</li>
<li>Incompatible resolution or refresh rate settings</li>
<li>Issues with the source device&apos;s HDMI output</li>
</ul>
</div>
<div>
<LinkButton
to={"/help/hdmi-error"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@ -5,7 +5,7 @@ import Card from "@components/Card";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore } from "@/hooks/stores";
import { Transition } from "@headlessui/react";
import { motion, AnimatePresence } from "motion/react";
import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard";
@ -182,276 +182,277 @@ function KeyboardWrapper() {
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
}}
>
<Transition
show={virtualKeyboard}
unmount={false}
enter="transition-all transform-gpu duration-500 ease-in-out"
enterFrom="opacity-0 translate-y-[100%]"
enterTo="opacity-100 translate-y-[0%]"
leave="transition-all duration-500 ease-in-out"
leaveFrom="opacity-100 translate-y-[0%]"
leaveTo="opacity-0 translate-y-[100%]"
>
<div>
<div
className={cx(
!showAttachedVirtualKeyboard
? "fixed left-0 top-0 z-50 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
<AnimatePresence>
{virtualKeyboard && (
<motion.div
initial={{ opacity: 0, y: "100%" }}
animate={{ opacity: 1, y: "0%" }}
exit={{ opacity: 0, y: "100%" }}
transition={{
duration: 0.5,
ease: "easeInOut",
}}
>
<Card
className={cx("overflow-hidden", {
"rounded-none": showAttachedVirtualKeyboard,
})}
<div
className={cx(
!showAttachedVirtualKeyboard
? "fixed left-0 top-0 z-50 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}}
>
<div className="flex items-center justify-center px-2 py-1 bg-white border-b dark:bg-slate-800 border-b-slate-800/30 dark:border-b-slate-300/20">
<div className="absolute flex items-center left-2 gap-x-2">
{showAttachedVirtualKeyboard ? (
<Card
className={cx("overflow-hidden", {
"rounded-none": showAttachedVirtualKeyboard,
})}
>
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
<div className="absolute left-2 flex items-center gap-x-2">
{showAttachedVirtualKeyboard ? (
<Button
size="XS"
theme="light"
text="Detach"
onClick={() => setShowAttachedVirtualKeyboard(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Detach"
onClick={() => setShowAttachedVirtualKeyboard(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
/>
</div>
</div>
<div>
<div className="flex flex-col dark:bg-slate-700 bg-blue-50/80 md:flex-row">
<Keyboard
baseClass="simple-keyboard-main"
layoutName={layoutName}
onKeyPress={onKeyDown}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape",
},
]}
display={{
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
Escape: "esc",
Tab: "tab",
Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter",
CapsLock: "caps lock",
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "meta",
MetaRight: "meta",
KeyQ: "q",
KeyW: "w",
KeyE: "e",
KeyR: "r",
KeyT: "t",
KeyY: "y",
KeyU: "u",
KeyI: "i",
KeyO: "o",
KeyP: "p",
KeyA: "a",
KeyS: "s",
KeyD: "d",
KeyF: "f",
KeyG: "g",
KeyH: "h",
KeyJ: "j",
KeyK: "k",
KeyL: "l",
KeyZ: "z",
KeyX: "x",
KeyC: "c",
KeyV: "v",
KeyB: "b",
KeyN: "n",
KeyM: "m",
"(KeyQ)": "Q",
"(KeyW)": "W",
"(KeyE)": "E",
"(KeyR)": "R",
"(KeyT)": "T",
"(KeyY)": "Y",
"(KeyU)": "U",
"(KeyI)": "I",
"(KeyO)": "O",
"(KeyP)": "P",
"(KeyA)": "A",
"(KeyS)": "S",
"(KeyD)": "D",
"(KeyF)": "F",
"(KeyG)": "G",
"(KeyH)": "H",
"(KeyJ)": "J",
"(KeyK)": "K",
"(KeyL)": "L",
"(KeyZ)": "Z",
"(KeyX)": "X",
"(KeyC)": "C",
"(KeyV)": "V",
"(KeyB)": "B",
"(KeyN)": "N",
"(KeyM)": "M",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Digit0: "0",
"(Digit1)": "!",
"(Digit2)": "@",
"(Digit3)": "#",
"(Digit4)": "$",
"(Digit5)": "%",
"(Digit6)": "^",
"(Digit7)": "&",
"(Digit8)": "*",
"(Digit9)": "(",
"(Digit0)": ")",
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
BracketRight: "]",
"(BracketLeft)": "{",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": '"',
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Space: " ",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
}}
layout={{
default: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}}
disableButtonHold={true}
mergeDisplay={true}
debug={false}
/>
<div className="controlArrows">
<Keyboard
baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default"
layout={{
default: ["Home Pageup", "Delete End Pagedown"],
}}
display={{
Home: "home",
Pageup: "pageup",
Delete: "delete",
End: "end",
Pagedown: "pagedown",
}}
syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true}
debug={false}
/>
<Keyboard
baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default"
display={{
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
}}
layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}}
onKeyPress={onKeyDown}
debug={false}
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
/>
</div>
</div>
</div>
</Card>
</div>
</div>
</Transition>
<div>
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
<Keyboard
baseClass="simple-keyboard-main"
layoutName={layoutName}
onKeyPress={onKeyDown}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape",
},
]}
display={{
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
Escape: "esc",
Tab: "tab",
Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter",
CapsLock: "caps lock",
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "meta",
MetaRight: "meta",
KeyQ: "q",
KeyW: "w",
KeyE: "e",
KeyR: "r",
KeyT: "t",
KeyY: "y",
KeyU: "u",
KeyI: "i",
KeyO: "o",
KeyP: "p",
KeyA: "a",
KeyS: "s",
KeyD: "d",
KeyF: "f",
KeyG: "g",
KeyH: "h",
KeyJ: "j",
KeyK: "k",
KeyL: "l",
KeyZ: "z",
KeyX: "x",
KeyC: "c",
KeyV: "v",
KeyB: "b",
KeyN: "n",
KeyM: "m",
"(KeyQ)": "Q",
"(KeyW)": "W",
"(KeyE)": "E",
"(KeyR)": "R",
"(KeyT)": "T",
"(KeyY)": "Y",
"(KeyU)": "U",
"(KeyI)": "I",
"(KeyO)": "O",
"(KeyP)": "P",
"(KeyA)": "A",
"(KeyS)": "S",
"(KeyD)": "D",
"(KeyF)": "F",
"(KeyG)": "G",
"(KeyH)": "H",
"(KeyJ)": "J",
"(KeyK)": "K",
"(KeyL)": "L",
"(KeyZ)": "Z",
"(KeyX)": "X",
"(KeyC)": "C",
"(KeyV)": "V",
"(KeyB)": "B",
"(KeyN)": "N",
"(KeyM)": "M",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Digit0: "0",
"(Digit1)": "!",
"(Digit2)": "@",
"(Digit3)": "#",
"(Digit4)": "$",
"(Digit5)": "%",
"(Digit6)": "^",
"(Digit7)": "&",
"(Digit8)": "*",
"(Digit9)": "(",
"(Digit0)": ")",
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
BracketRight: "]",
"(BracketLeft)": "{",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": '"',
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Space: " ",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
}}
layout={{
default: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}}
disableButtonHold={true}
mergeDisplay={true}
debug={false}
/>
<div className="controlArrows">
<Keyboard
baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default"
layout={{
default: ["Home Pageup", "Delete End Pagedown"],
}}
display={{
Home: "home",
Pageup: "pageup",
Delete: "delete",
End: "end",
Pagedown: "pagedown",
}}
syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true}
debug={false}
/>
<Keyboard
baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default"
display={{
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
}}
layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}}
onKeyPress={onKeyDown}
debug={false}
/>
</div>
</div>
</div>
</Card>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
useDeviceSettingsStore,
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useUiStore,
useVideoStore,
} from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
@ -15,7 +15,9 @@ import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay";
import { HDMIErrorOverlay } from "./VideoOverlay";
import { ConnectionErrorOverlay } from "./VideoOverlay";
import { LoadingOverlay } from "./VideoOverlay";
export default function WebRTCVideo() {
// Video and stream related refs and states
@ -30,6 +32,8 @@ export default function WebRTCVideo() {
const {
setClientSize: setVideoClientSize,
setSize: setVideoSize,
width: videoWidth,
height: videoHeight,
clientWidth: videoClientWidth,
clientHeight: videoClientHeight,
} = useVideoStore();
@ -102,35 +106,67 @@ export default function WebRTCVideo() {
const mouseMoveHandler = useCallback(
(e: MouseEvent) => {
if (!videoClientWidth || !videoClientHeight) return;
const { buttons } = e;
// Get the aspect ratios of the video element and the video stream
const videoElementAspectRatio = videoClientWidth / videoClientHeight;
const videoStreamAspectRatio = videoWidth / videoHeight;
// Clamp mouse position within the video boundaries
const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth);
const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight);
// Calculate the effective video display area
let effectiveWidth = videoClientWidth;
let effectiveHeight = videoClientHeight;
let offsetX = 0;
let offsetY = 0;
// Normalize mouse position to 0-32767 range (HID absolute coordinate system)
const x = Math.round((currMouseX / videoClientWidth) * 32767);
const y = Math.round((currMouseY / videoClientHeight) * 32767);
if (videoElementAspectRatio > videoStreamAspectRatio) {
// Pillarboxing: black bars on the left and right
effectiveWidth = videoClientHeight * videoStreamAspectRatio;
offsetX = (videoClientWidth - effectiveWidth) / 2;
} else if (videoElementAspectRatio < videoStreamAspectRatio) {
// Letterboxing: black bars on the top and bottom
effectiveHeight = videoClientWidth / videoStreamAspectRatio;
offsetY = (videoClientHeight - effectiveHeight) / 2;
}
// Clamp mouse position within the effective video boundaries
const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth);
const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight);
// Map clamped mouse position to the video stream's coordinate system
const relativeX = (clampedX - offsetX) / effectiveWidth;
const relativeY = (clampedY - offsetY) / effectiveHeight;
// Convert to HID absolute coordinate system (0-32767 range)
const x = Math.round(relativeX * 32767);
const y = Math.round(relativeY * 32767);
// Send mouse movement
const { buttons } = e;
sendMouseMovement(x, y, buttons);
},
[sendMouseMovement, videoClientHeight, videoClientWidth],
[sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight],
);
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
const mouseSensitivity = useDeviceSettingsStore(state => state.mouseSensitivity);
const clampMin = useDeviceSettingsStore(state => state.clampMin);
const clampMax = useDeviceSettingsStore(state => state.clampMax);
const blockDelay = useDeviceSettingsStore(state => state.blockDelay);
const trackpadThreshold = useDeviceSettingsStore(state => state.trackpadThreshold);
const mouseWheelHandler = useCallback(
(e: WheelEvent) => {
if (blockWheelEvent) return;
e.preventDefault();
// Define a scaling factor to adjust scrolling sensitivity
const scrollSensitivity = 0.8; // Adjust this value to change scroll speed
// Determine if the wheel event is from a trackpad or a mouse wheel
const isTrackpad = Math.abs(e.deltaY) < trackpadThreshold;
// Apply appropriate sensitivity based on input device
const scrollSensitivity = isTrackpad ? trackpadSensitivity : mouseSensitivity;
// Calculate the scroll value
const scroll = e.deltaY * scrollSensitivity;
// Clamp the scroll value to a reasonable range (e.g., -15 to 15)
const clampedScroll = Math.max(-4, Math.min(4, scroll));
// Apply clamping
const clampedScroll = Math.max(clampMin, Math.min(clampMax, scroll));
// Round to the nearest integer
const roundedScroll = Math.round(clampedScroll);
@ -138,13 +174,22 @@ export default function WebRTCVideo() {
// Invert the scroll value to match expected behavior
const invertedScroll = -roundedScroll;
console.log("wheelReport", { wheelY: invertedScroll });
send("wheelReport", { wheelY: invertedScroll });
// Apply blocking delay
setBlockWheelEvent(true);
setTimeout(() => setBlockWheelEvent(false), 50);
setTimeout(() => setBlockWheelEvent(false), blockDelay);
},
[blockWheelEvent, send],
[
blockDelay,
blockWheelEvent,
clampMax,
clampMin,
mouseSensitivity,
send,
trackpadSensitivity,
trackpadThreshold,
],
);
const resetMousePosition = useCallback(() => {
@ -260,11 +305,6 @@ export default function WebRTCVideo() {
e.preventDefault();
const prev = useHidStore.getState();
// if (document.activeElement?.id !== "videoFocusTrap") {
// console.log("KEYUP: Not focusing on the video", document.activeElement);
// return;
// }
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
@ -311,6 +351,18 @@ export default function WebRTCVideo() {
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
);
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video
// there is no way to prevent this, so we need to simply force play the video when it's paused.
// Fix only works in chrome based browsers.
if (e.code === "Space") {
if (videoElm.current?.paused == true) {
console.log("Force playing video");
videoElm.current?.play();
}
}
}, []);
useEffect(
function setupVideoEventListeners() {
let videoElmRefValue = null;
@ -322,8 +374,11 @@ export default function WebRTCVideo() {
videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal });
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
videoElmRefValue.addEventListener(
"contextmenu",
(e: MouseEvent) => e.preventDefault(),
@ -339,7 +394,13 @@ export default function WebRTCVideo() {
if (videoElmRefValue) abortController.abort();
};
},
[mouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler],
[
mouseMoveHandler,
resetMousePosition,
onVideoPlaying,
mouseWheelHandler,
videoKeyUpHandler,
],
);
useEffect(
@ -364,28 +425,8 @@ export default function WebRTCVideo() {
],
);
// Focus trap management
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const sidebarView = useUiStore(state => state.sidebarView);
useEffect(() => {
setTimeout(function () {
if (["connection-stats", "system"].includes(sidebarView ?? "")) {
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
sendKeyboardEvent([], []);
setDisableVideoFocusTrap(true);
// For some reason, the focus trap is not disabled immediately
// so we need to blur the active element
// (document.activeElement as HTMLElement)?.blur();
console.log("Just disabled focus trap");
} else {
setDisableVideoFocusTrap(false);
}
}, 300);
}, [sendKeyboardEvent, setDisableVideoFocusTrap, sidebarView]);
return (
<div className="grid w-full h-full grid-rows-layout">
<div className="grid h-full w-full grid-rows-layout">
<div className="min-h-[39.5px]">
<fieldset disabled={peerConnectionState !== "connected"}>
<Actionbar
@ -402,18 +443,18 @@ export default function WebRTCVideo() {
<div className="relative h-full">
<div
className={cx(
"absolute inset-0 bg-blue-50/40 dark:bg-slate-800/40 opacity-80",
"absolute inset-0 bg-blue-50/40 opacity-80 dark:bg-slate-800/40",
"[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]",
"[background-position:0_0,10px_10px]",
"[background-size:20px_20px]",
)}
/>
<div className="flex flex-col h-full">
<div className="flex h-full flex-col">
<div className="relative flex-grow overflow-hidden">
<div className="flex flex-col h-full">
<div className="grid flex-grow overflow-hidden grid-rows-bodyFooter">
<div className="relative flex items-center justify-center mx-4 my-2 overflow-hidden">
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex h-full flex-col">
<div className="grid flex-grow grid-rows-bodyFooter overflow-hidden">
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
<div className="relative flex h-full w-full items-center justify-center">
<video
ref={videoElm}
autoPlay={true}
@ -425,18 +466,18 @@ export default function WebRTCVideo() {
disablePictureInPicture
controlsList="nofullscreen"
className={cx(
"outline-50 max-h-full max-w-full rounded-md object-contain transition-all duration-1000",
"outline-50 max-h-full max-w-full object-contain transition-all duration-1000",
{
"cursor-none": settings.isCursorHidden,
"opacity-0": isLoading || isConnectionError || hdmiError,
"animate-slideUpFade border border-slate-800/30 dark:border-slate-300/20 opacity-0 shadow":
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
isPlaying,
},
)}
/>
<div
style={{ animationDuration: "500ms" }}
className="absolute inset-0 flex items-center justify-center opacity-0 pointer-events-none animate-slideUpFade"
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<LoadingOverlay show={isLoading} />

View File

@ -1,201 +0,0 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { Terminal } from "xterm";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebglAddon } from "@xterm/addon-webgl";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import "xterm/css/xterm.css";
import { useRTCStore, useUiStore } from "../hooks/stores";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Add this debounce function at the top of the file
function debounce(func: (...args: any[]) => void, wait: number) {
let timeout: number | null = null;
return (...args: any[]) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Terminal theme configuration
const SOLARIZED_THEME = {
background: "#0f172a", // Solarized base03
foreground: "#839496", // Solarized base0
cursor: "#93a1a1", // Solarized base1
cursorAccent: "#002b36", // Solarized base03
black: "#073642", // Solarized base02
red: "#dc322f", // Solarized red
green: "#859900", // Solarized green
yellow: "#b58900", // Solarized yellow
blue: "#268bd2", // Solarized blue
magenta: "#d33682", // Solarized magenta
cyan: "#2aa198", // Solarized cyan
white: "#eee8d5", // Solarized base2
brightBlack: "#002b36", // Solarized base03
brightRed: "#cb4b16", // Solarized orange
brightGreen: "#586e75", // Solarized base01
brightYellow: "#657b83", // Solarized base00
brightBlue: "#839496", // Solarized base0
brightMagenta: "#6c71c4", // Solarized violet
brightCyan: "#93a1a1", // Solarized base1
brightWhite: "#fdf6e3", // Solarized base3
} as const;
const TERMINAL_CONFIG = {
theme: SOLARIZED_THEME,
fontFamily: "'Fira Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 13,
allowProposedApi: true,
scrollback: 1000,
cursorBlink: true,
smoothScrollDuration: 100,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
// Add these configurations:
convertEol: true,
linuxMode: false, // Disable Linux mode which might affect line endings
} as const;
interface XTermProps {
terminalChannel: RTCDataChannel | null;
}
export function XTerm({ terminalChannel }: XTermProps) {
const xtermRef = useRef<Terminal | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalElmRef = useRef<HTMLDivElement | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const peerConnection = useRTCStore(state => state.peerConnection);
useEffect(() => {
setDisableKeyboardFocusTrap(true);
return () => {
setDisableKeyboardFocusTrap(false);
};
}, [setDisableKeyboardFocusTrap]);
const initializeTerminalAddons = (term: Terminal) => {
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new ClipboardAddon());
term.loadAddon(new Unicode11Addon());
term.loadAddon(new WebLinksAddon());
term.unicode.activeVersion = "11";
if (isWebGl2Supported) {
const webGl2Addon = new WebglAddon();
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
term.loadAddon(webGl2Addon);
}
return fitAddon;
};
const setupTerminalChannel = (
term: Terminal,
channel: RTCDataChannel,
abortController: AbortController,
) => {
channel.onopen = () => {
// Handle terminal input
term.onData(data => {
if (channel.readyState === "open") {
channel.send(data);
}
});
// Handle terminal output
channel.addEventListener(
"message",
(event: MessageEvent) => {
term.write(new Uint8Array(event.data));
},
{ signal: abortController.signal },
);
// Send initial terminal size
if (channel.readyState === "open") {
channel.send(JSON.stringify({ rows: term.rows, cols: term.cols }));
}
};
};
useLayoutEffect(() => {
if (!terminalElmRef.current) return;
// Ensure the container has dimensions before initializing
if (!terminalElmRef.current.offsetHeight || !terminalElmRef.current.offsetWidth) {
return;
}
const term = new Terminal(TERMINAL_CONFIG);
const fitAddon = initializeTerminalAddons(term);
const abortController = new AbortController();
// Setup escape key handler
term.onKey(e => {
const { domEvent } = e;
if (domEvent.key === "Escape") {
setEnableTerminal(false);
setDisableKeyboardFocusTrap(false);
domEvent.preventDefault();
}
});
let elm: HTMLDivElement | null = terminalElmRef.current;
// Initialize terminal
setTimeout(() => {
if (elm) {
console.log("opening terminal");
term.open(elm);
fitAddon.fit();
}
}, 800);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Setup resize handling
const debouncedResizeHandler = debounce(() => fitAddon.fit(), 100);
const resizeObserver = new ResizeObserver(debouncedResizeHandler);
resizeObserver.observe(terminalElmRef.current);
// Focus terminal after a short delay
setTimeout(() => {
term.focus();
terminalElmRef.current?.focus();
}, 500);
// Setup terminal channel if available
const channel = peerConnection?.createDataChannel("terminal");
if (channel) {
setupTerminalChannel(term, channel, abortController);
}
// Cleanup
return () => {
resizeObserver.disconnect();
abortController.abort();
term.dispose();
elm = null;
xtermRef.current = null;
fitAddonRef.current = null;
};
}, [peerConnection, setDisableKeyboardFocusTrap, setEnableTerminal, terminalChannel]);
return (
<div className="w-full h-full" ref={containerRef}>
<div
className="w-full h-full terminal-container"
ref={terminalElmRef}
style={{ display: "flex", minHeight: "100%" }}
></div>
</div>
);
}

View File

@ -0,0 +1,171 @@
import { Button } from "@components/Button";
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useEffect, useState } from "react";
import notifications from "@/notifications";
import { useJsonRpc } from "../../hooks/useJsonRpc";
import LoadingSpinner from "../LoadingSpinner";
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
interface ATXState {
power: boolean;
hdd: boolean;
}
export function ATXPowerControl() {
const [isPowerPressed, setIsPowerPressed] = useState(false);
const [powerPressTimer, setPowerPressTimer] = useState<ReturnType<
typeof setTimeout
> | null>(null);
const [atxState, setAtxState] = useState<ATXState | null>(null);
const [send] = useJsonRpc(function onRequest(resp) {
if (resp.method === "atxState") {
setAtxState(resp.params as ATXState);
}
});
// Request initial state
useEffect(() => {
send("getATXState", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setAtxState(resp.result as ATXState);
});
}, [send]);
const handlePowerPress = (pressed: boolean) => {
// Prevent phantom releases
if (!pressed && !isPowerPressed) return;
setIsPowerPressed(pressed);
// Handle button press
if (pressed) {
// Start long press timer
const timer = setTimeout(() => {
// Send long press action
console.log("Sending long press ATX power action");
send("setATXPowerAction", { action: "power-long" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
}
setIsPowerPressed(false);
});
}, LONG_PRESS_DURATION);
setPowerPressTimer(timer);
}
// Handle button release
else {
// If timer exists, was a short press
if (powerPressTimer) {
clearTimeout(powerPressTimer);
setPowerPressTimer(null);
// Send short press action
console.log("Sending short press ATX power action");
send("setATXPowerAction", { action: "power-short" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
}
});
}
}
};
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (powerPressTimer) {
clearTimeout(powerPressTimer);
}
};
}, [powerPressTimer]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="ATX Power Control"
description="Control your ATX power settings"
/>
{atxState === null ? (
<Card className="flex h-[120px] items-center justify-center p-3">
<LoadingSpinner className="w-6 h-6 text-blue-500 dark:text-blue-400" />
</Card>
) : (
<Card className="h-[120px] animate-fadeIn opacity-0">
<div className="p-3 space-y-4">
{/* Control Buttons */}
<div className="flex items-center space-x-2">
<Button
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power"
onMouseDown={() => handlePowerPress(true)}
onMouseUp={() => handlePowerPress(false)}
onMouseLeave={() => handlePowerPress(false)}
className={isPowerPressed ? "opacity-75" : ""}
/>
<Button
size="SM"
theme="light"
LeadingIcon={LuRotateCcw}
text="Reset"
onClick={() => {
send("setATXPowerAction", { action: "reset" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
return;
}
});
}}
/>
</div>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Status Indicators */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
<LuPower
strokeWidth={3}
className={`mr-1 inline ${
atxState?.power ? "text-green-600" : "text-slate-300"
}`}
/>
Power LED
</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
<LuHardDrive
strokeWidth={3}
className={`mr-1 inline ${
atxState?.hdd ? "text-blue-400" : "text-slate-300"
}`}
/>
HDD LED
</span>
</div>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,114 @@
import { Button } from "@components/Button";
import { LuPower } from "react-icons/lu";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import FieldLabel from "../FieldLabel";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useCallback, useEffect, useState } from "react";
import notifications from "@/notifications";
import LoadingSpinner from "../LoadingSpinner";
interface DCPowerState {
isOn: boolean;
voltage: number;
current: number;
power: number;
}
export function DCPowerControl() {
const [send] = useJsonRpc();
const [powerState, setPowerState] = useState<DCPowerState | null>(null);
const getDCPowerState = useCallback(() => {
send("getDCPowerState", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get DC power state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setPowerState(resp.result as DCPowerState);
});
}, [send]);
const handlePowerToggle = (enabled: boolean) => {
send("setDCPowerState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
);
return;
}
getDCPowerState(); // Refresh state after change
});
};
useEffect(() => {
getDCPowerState();
// Set up polling interval to update status
const interval = setInterval(getDCPowerState, 1000);
return () => clearInterval(interval);
}, [getDCPowerState]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="DC Power Control"
description="Control your DC power settings"
/>
{powerState === null ? (
<Card className="flex h-[160px] justify-center p-3">
<LoadingSpinner className="w-6 h-6 text-blue-500 dark:text-blue-400" />
</Card>
) : (
<Card className="h-[160px] animate-fadeIn opacity-0">
<div className="p-3 space-y-4">
{/* Power Controls */}
<div className="flex items-center space-x-2">
<Button
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power On"
onClick={() => handlePowerToggle(true)}
disabled={powerState.isOn}
/>
<Button
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power Off"
disabled={!powerState.isOn}
onClick={() => handlePowerToggle(false)}
/>
</div>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Status Display */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<FieldLabel label="Voltage" />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.voltage.toFixed(1)}V
</p>
</div>
<div className="space-y-1">
<FieldLabel label="Current" />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.current.toFixed(1)}A
</p>
</div>
<div className="space-y-1">
<FieldLabel label="Power" />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.power.toFixed(1)}W
</p>
</div>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,130 @@
import { Button } from "@components/Button";
import { LuTerminal } from "react-icons/lu";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SelectMenuBasic } from "../SelectMenuBasic";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useEffect, useState } from "react";
import notifications from "@/notifications";
import { useUiStore } from "@/hooks/stores";
interface SerialSettings {
baudRate: string;
dataBits: string;
stopBits: string;
parity: string;
}
export function SerialConsole() {
const [send] = useJsonRpc();
const [settings, setSettings] = useState<SerialSettings>({
baudRate: "9600",
dataBits: "8",
stopBits: "1",
parity: "none",
});
useEffect(() => {
send("getSerialSettings", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get serial settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
setSettings(resp.result as SerialSettings);
});
}, [send]);
const handleSettingChange = (setting: keyof SerialSettings, value: string) => {
const newSettings = { ...settings, [setting]: value };
send("setSerialSettings", { settings: newSettings }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update serial settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
setSettings(newSettings);
});
};
const setTerminalType = useUiStore(state => state.setTerminalType);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Serial Console"
description="Configure your serial console settings"
/>
<Card className="animate-fadeIn opacity-0">
<div className="space-y-4 p-3">
{/* Open Console Button */}
<div className="flex items-center">
<Button
size="SM"
theme="primary"
LeadingIcon={LuTerminal}
text="Open Console"
onClick={() => {
setTerminalType("serial");
console.log("Opening serial console with settings: ", settings);
}}
/>
</div>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Settings */}
<div className="grid grid-cols-2 gap-4">
<SelectMenuBasic
label="Baud Rate"
options={[
{ label: "1200", value: "1200" },
{ label: "2400", value: "2400" },
{ label: "4800", value: "4800" },
{ label: "9600", value: "9600" },
{ label: "19200", value: "19200" },
{ label: "38400", value: "38400" },
{ label: "57600", value: "57600" },
{ label: "115200", value: "115200" },
]}
value={settings.baudRate}
onChange={e => handleSettingChange("baudRate", e.target.value)}
/>
<SelectMenuBasic
label="Data Bits"
options={[
{ label: "8", value: "8" },
{ label: "7", value: "7" },
]}
value={settings.dataBits}
onChange={e => handleSettingChange("dataBits", e.target.value)}
/>
<SelectMenuBasic
label="Stop Bits"
options={[
{ label: "1", value: "1" },
{ label: "1.5", value: "1.5" },
{ label: "2", value: "2" },
]}
value={settings.stopBits}
onChange={e => handleSettingChange("stopBits", e.target.value)}
/>
<SelectMenuBasic
label="Parity"
options={[
{ label: "None", value: "none" },
{ label: "Even", value: "even" },
{ label: "Odd", value: "odd" },
]}
value={settings.parity}
onChange={e => handleSettingChange("parity", e.target.value)}
/>
</div>
</div>
</Card>
</div>
);
}

View File

@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { Button } from "../Button";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
import { DCPowerControl } from "@components/extensions/DCPowerControl";
import { SerialConsole } from "@components/extensions/SerialConsole";
import notifications from "../../notifications";
interface Extension {
id: string;
name: string;
description: string;
icon: any;
}
const AVAILABLE_EXTENSIONS: Extension[] = [
{
id: "atx-power",
name: "ATX Power Control",
description: "Control your ATX Power extension",
icon: LuPower,
},
{
id: "dc-power",
name: "DC Power Control",
description: "Control your DC Power extension",
icon: LuPlugZap,
},
{
id: "serial-console",
name: "Serial Console",
description: "Access your serial console extension",
icon: LuTerminal,
},
];
export default function ExtensionPopover() {
const [send] = useJsonRpc();
const [activeExtension, setActiveExtension] = useState<Extension | null>(null);
// Load active extension on component mount
useEffect(() => {
send("getActiveExtension", {}, resp => {
if ("error" in resp) return;
const extensionId = resp.result as string;
if (extensionId) {
const extension = AVAILABLE_EXTENSIONS.find(ext => ext.id === extensionId);
if (extension) {
setActiveExtension(extension);
}
}
});
}, [send]);
const handleSetActiveExtension = (extension: Extension | null) => {
send("setActiveExtension", { extensionId: extension?.id || "" }, resp => {
if ("error" in resp) {
notifications.error(`Failed to set active extension: ${resp.error.data || "Unknown error"}`);
return;
}
setActiveExtension(extension);
});
};
const renderActiveExtension = () => {
switch (activeExtension?.id) {
case "atx-power":
return <ATXPowerControl />;
case "dc-power":
return <DCPowerControl />;
case "serial-console":
return <SerialConsole />;
default:
return null;
}
};
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="space-y-4">
{activeExtension ? (
// Extension Control View
<div className="space-y-4">
{renderActiveExtension()}
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="light"
text="Unload Extension"
onClick={() => handleSetActiveExtension(null)}
/>
</div>
</div>
) : (
// Extensions List View
<div className="space-y-4">
<SettingsPageHeader
title="Extensions"
description="Load and manage your extensions"
/>
<Card className="opacity-0 animate-fadeIn">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{AVAILABLE_EXTENSIONS.map(extension => (
<div
key={extension.id}
className="flex items-center justify-between p-3"
>
<div className="space-y-0.5">
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">
{extension.name}
</p>
<p className="text-sm text-slate-600 dark:text-slate-400">
{extension.description}
</p>
</div>
<Button
size="XS"
theme="light"
text="Load"
onClick={() => handleSetActiveExtension(extension)}
/>
</div>
))}
</div>
</Card>
</div>
)}
</div>
</div>
</div>
</GridCard>
);
}

View File

@ -5,7 +5,7 @@ import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SectionHeader } from "@components/SectionHeader";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import {
LuArrowUpFromLine,
LuCheckCheck,
@ -15,19 +15,15 @@ import {
} from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications";
import MountMediaModal from "../MountMediaDialog";
import { useClose } from "@headlessui/react";
import { useLocation } from "react-router-dom";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const [send] = useJsonRpc();
const {
remoteVirtualMediaState,
isMountMediaDialogOpen,
setModalView,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState,
} = useMountMediaStore();
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
useMountMediaStore();
const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null;
@ -78,7 +74,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
<PlusCircleIcon className="h-4 w-4 shrink-0 text-blue-700 dark:text-white" />
</div>
</Card>
</div>
@ -103,20 +99,25 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="space-y-1">
<div className="flex items-center gap-x-2">
<LuCheckCheck className="h-5 text-green-500" />
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from Browser</h3>
<h3 className="text-base font-semibold text-black dark:text-white">
Streaming from Browser
</h3>
</div>
<Card className="w-auto px-2 py-1">
<div className="w-full text-sm text-black truncate dark:text-white">
<div className="w-full truncate text-sm text-black dark:text-white">
{formatters.truncateMiddle(filename, 50)}
</div>
</Card>
</div>
<div className="flex flex-col items-center my-2 gap-y-2">
<div className="my-2 flex flex-col items-center gap-y-2">
<div className="w-full text-sm text-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between">
<span>{formatters.bytes(size ?? 0)}</span>
<div className="flex items-center gap-x-1">
<LuArrowUpFromLine className="h-4 text-blue-700 dark:text-blue-500" strokeWidth={2} />
<LuArrowUpFromLine
className="h-4 text-blue-700 dark:text-blue-500"
strokeWidth={2}
/>
<span>
{bytesSentPerSecond !== null
? `${formatters.bytes(bytesSentPerSecond)}/s`
@ -131,33 +132,49 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
case "HTTP":
return (
<div className="">
<div className="inline-block mb-0">
<div className="mb-0 inline-block">
<Card>
<div className="p-1">
<LuLink className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
<LuLink className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-500" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from URL</h3>
<p className="text-sm truncate text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(url, 55)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
<h3 className="text-base font-semibold text-black dark:text-white">
Streaming from URL
</h3>
<p className="truncate text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(url, 55)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(filename, 30)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.bytes(size ?? 0)}
</p>
</div>
);
case "Storage":
return (
<div className="">
<div className="inline-block mb-0">
<div className="mb-0 inline-block">
<Card>
<div className="p-1">
<LuRadioReceiver className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
<LuRadioReceiver className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-500" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Mounted from JetKVM Storage</h3>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(path, 50)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
<h3 className="text-base font-semibold text-black dark:text-white">
Mounted from JetKVM Storage
</h3>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(path, 50)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(filename, 30)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.bytes(size ?? 0)}
</p>
</div>
);
default:
@ -165,18 +182,21 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
}
};
const close = useClose();
const location = useLocation();
useEffect(() => {
syncRemoteVirtualMediaState();
}, [syncRemoteVirtualMediaState, isMountMediaDialogOpen]);
}, [syncRemoteVirtualMediaState, location.pathname]);
const { navigateTo } = useDeviceUiNavigation();
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="space-y-4 p-4 py-3">
<div ref={ref} className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4 ">
<div className="space-y-4">
<SectionHeader
<SettingsPageHeader
title="Virtual Media"
description="Mount an image to boot from or install an operating system."
/>
@ -185,7 +205,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
<ExclamationTriangleIcon className="h-4 text-yellow-500" />
<div className="flex items-center w-full text-black">
<div className="flex w-full items-center text-black">
<div>Closing this tab will unmount the image</div>
</div>
</div>
@ -193,7 +213,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
) : null}
<div
className="space-y-2 opacity-0 animate-fadeIn"
className="animate-fadeIn space-y-2 opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -203,7 +223,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="group">
<Card>
<div className="w-full px-4 py-8">
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="flex h-full flex-col items-center justify-center text-center">
{renderGridCardContent()}
</div>
</div>
@ -211,8 +231,8 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</div>
</div>
{remoteVirtualMediaState ? (
<div className="flex items-center justify-between text-xs select-none">
<div className="text-white select-none dark:text-slate-300">
<div className="flex select-none items-center justify-between text-xs">
<div className="select-none text-white dark:text-slate-300">
<span>Mounted as</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
@ -244,7 +264,10 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor"
/>
<path d="M10 7.49976H0V9.22453H10V7.49976Z" fill="currentColor" />
<path
d="M10 7.49976H0V9.22453H10V7.49976Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_3137_1186">
@ -261,16 +284,11 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</div>
</div>
</div>
<MountMediaModal
open={isMountMediaDialogOpen}
setOpen={setIsMountMediaDialogOpen}
/>
</div>
{!remoteVirtualMediaState && (
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
@ -290,7 +308,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
text="Add New Media"
onClick={() => {
setModalView("mode");
setIsMountMediaDialogOpen(true);
navigateTo("/mount");
}}
LeadingIcon={LuPlus}
/>

View File

@ -1,7 +1,7 @@
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea";
import { SectionHeader } from "@components/SectionHeader";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "../../notifications";
@ -75,7 +75,7 @@ export default function PasteModal() {
<div className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4">
<div className="space-y-4">
<SectionHeader
<SettingsPageHeader
title="Paste text"
description="Paste text from your client to the remote host"
/>

View File

@ -1,5 +1,5 @@
import { GridCard } from "@components/Card";
import { SectionHeader } from "@components/SectionHeader";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications";
@ -102,7 +102,7 @@ export default function WakeOnLanModal() {
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="space-y-4">
<SectionHeader
<SettingsPageHeader
title="Wake On LAN"
description="Send a Magic Packet to wake up a remote device."
/>

View File

@ -1,6 +1,5 @@
import SidebarHeader from "@components/SidebarHeader";
import { GridCard } from "@components/Card";
import { useEffect } from "react";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@components/StatChart";
import { useInterval } from "usehooks-ts";
@ -36,19 +35,7 @@ function createChartArray<T, K extends keyof T>(
});
}
export default function ConnectionStatsSidebar () {
const setModalView = useUiStore(state => state.setModalView);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setModalView(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [setModalView]);
export default function ConnectionStatsSidebar() {
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
const candidatePairStats = useRTCStore(state => state.candidatePairStats);
@ -111,9 +98,9 @@ export default function ConnectionStatsSidebar () {
}, 500);
return (
<div className="grid h-full shadow-sm grid-rows-headerBody">
<div className="grid h-full grid-rows-headerBody shadow-sm">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<div className="h-full px-4 py-2 pb-8 space-y-4 overflow-y-scroll bg-white dark:bg-slate-900">
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
<div className="space-y-4">
{/*
The entire sidebar component is always rendered, with a display none when not visible

View File

@ -1,899 +0,0 @@
import SidebarHeader from "@components/SidebarHeader";
import {
useLocalAuthModalStore,
useSettingsStore,
useUiStore,
useUpdateStore,
} from "@/hooks/stores";
import { Checkbox } from "@components/Checkbox";
import { Button, LinkButton } from "@components/Button";
import { TextAreaWithLabel } from "@components/TextArea";
import { SectionHeader } from "@components/SectionHeader";
import { GridCard } from "@components/Card";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { cx } from "@/cva.config";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { isOnDevice } from "@/main";
import PointingFinger from "@/assets/pointing-finger.svg";
import MouseIcon from "@/assets/mouse-icon.svg";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "../SelectMenuBasic";
import { SystemVersionInfo } from "@components/UpdateDialog";
import notifications from "@/notifications";
import api from "../../api";
import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog";
import { LocalDevice } from "@routes/devices.$id";
import { useRevalidator } from "react-router-dom";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
export function SettingsItem({
title,
description,
children,
className,
}: {
title: string;
description: string | React.ReactNode;
children?: React.ReactNode;
className?: string;
name?: string;
}) {
return (
<label className={cx("flex items-center justify-between gap-x-4 rounded", className)}>
<div className="space-y-0.5">
<h3 className="text-base font-semibold text-black dark:text-white">{title}</h3>
<p className="text-sm text-slate-700 dark:text-slate-300">{description}</p>
</div>
{children ? <div>{children}</div> : null}
</label>
);
}
const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
const edids = [
{
value: defaultEdid,
label: "JetKVM Default",
},
{
value:
"00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
label: "Acer B246WL, 1920x1200",
},
{
value:
"00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC",
label: "ASUS PA248QV, 1920x1200",
},
{
value:
"00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F",
label: "DELL D2721H, 1920x1080",
},
];
export default function SettingsSidebar() {
const setSidebarView = useUiStore(state => state.setSidebarView);
const settings = useSettingsStore();
const [send] = useJsonRpc();
const [streamQuality, setStreamQuality] = useState("1");
const [autoUpdate, setAutoUpdate] = useState(true);
const [devChannel, setDevChannel] = useState(false);
const [jiggler, setJiggler] = useState(false);
const [edid, setEdid] = useState<string | null>(null);
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [isAdopted, setAdopted] = useState(false);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [sshKey, setSSHKey] = useState<string>("");
const [localDevice, setLocalDevice] = useState<LocalDevice | null>(null);
const sidebarRef = useRef<HTMLDivElement>(null);
const hideCursor = useSettingsStore(state => state.isCursorHidden);
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const [currentVersions, setCurrentVersions] = useState<{
appVersion: string;
systemVersion: string;
} | null>(null);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const getUsbEmulationState = useCallback(() => {
send("getUsbEmulationState", {}, resp => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
}, [send]);
const handleUsbEmulationToggle = useCallback(
(enabled: boolean) => {
send("setUsbEmulationState", { enabled: enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
);
return;
}
setUsbEmulationEnabled(enabled);
getUsbEmulationState();
});
},
[getUsbEmulationState, send],
);
const getCloudState = useCallback(() => {
send("getCloudState", {}, resp => {
if ("error" in resp) return console.error(resp.error);
const cloudState = resp.result as { connected: boolean };
setAdopted(cloudState.connected);
});
}, [send]);
const deregisterDevice = async () => {
send("deregisterDevice", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
);
return;
}
getCloudState();
return;
});
};
const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
);
return;
}
setStreamQuality(factor);
});
};
const handleAutoUpdateChange = (enabled: boolean) => {
send("setAutoUpdateState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,
);
return;
}
setAutoUpdate(enabled);
});
};
const handleDevChannelChange = (enabled: boolean) => {
send("setDevChannelState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDevChannel(enabled);
});
};
const handleJigglerChange = (enabled: boolean) => {
send("setJigglerState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setJiggler(enabled);
});
};
const handleEDIDChange = (newEdid: string) => {
send("setEDID", { edid: newEdid }, resp => {
if ("error" in resp) {
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
return;
}
// Update the EDID value in the UI
setEdid(newEdid);
});
};
const handleSSHKeyChange = (newKey: string) => {
setSSHKey(newKey);
};
const handleDevModeChange = useCallback(
(developerMode: boolean) => {
send("setDevModeState", { enabled: developerMode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDeveloperMode(developerMode);
setTimeout(() => {
sidebarRef.current?.scrollTo({ top: 5000, behavior: "smooth" });
}, 0);
});
},
[send, setDeveloperMode],
);
const handleUpdateSSHKey = useCallback(() => {
send("setSSHKeyState", { sshKey }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("SSH key updated successfully");
});
}, [send, sshKey]);
const { setIsUpdateDialogOpen, setModalView, otaState } = useUpdateStore();
const handleCheckForUpdates = () => {
if (otaState.updating) {
setModalView("updating");
setIsUpdateDialogOpen(true);
} else {
setModalView("loading");
setIsUpdateDialogOpen(true);
}
};
useEffect(() => {
getCloudState();
send("getDeviceID", {}, async resp => {
if ("error" in resp) return console.error(resp.error);
setDeviceId(resp.result as string);
});
send("getJigglerState", {}, resp => {
if ("error" in resp) return;
setJiggler(resp.result as boolean);
});
send("getAutoUpdateState", {}, resp => {
if ("error" in resp) return;
setAutoUpdate(resp.result as boolean);
});
send("getDevChannelState", {}, resp => {
if ("error" in resp) return;
setDevChannel(resp.result as boolean);
});
send("getStreamQualityFactor", {}, resp => {
if ("error" in resp) return;
setStreamQuality(String(resp.result));
});
send("getEDID", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
return;
}
const receivedEdid = resp.result as string;
const matchingEdid = edids.find(
x => x.value.toLowerCase() === receivedEdid.toLowerCase(),
);
if (matchingEdid) {
// EDID is stored in uppercase in the UI
setEdid(matchingEdid.value.toUpperCase());
// Reset custom EDID value
setCustomEdidValue(null);
} else {
setEdid("custom");
setCustomEdidValue(receivedEdid);
}
});
send("getDevModeState", {}, resp => {
if ("error" in resp) return;
const result = resp.result as { enabled: boolean };
setDeveloperMode(result.enabled);
});
send("getSSHKeyState", {}, resp => {
if ("error" in resp) return;
setSSHKey(resp.result as string);
});
send("getUpdateStatus", {}, resp => {
if ("error" in resp) return;
const result = resp.result as SystemVersionInfo;
setCurrentVersions({
appVersion: result.local.appVersion,
systemVersion: result.local.systemVersion,
});
});
send("getUsbEmulationState", {}, resp => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
}, [getCloudState, send, setDeveloperMode, setHideCursor, setJiggler]);
const getDevice = useCallback(async () => {
try {
const status = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device`)
.then(res => res.json() as Promise<LocalDevice>);
setLocalDevice(status);
} catch (error) {
notifications.error("Failed to get authentication status");
}
}, []);
const { setModalView: setLocalAuthModalView } = useLocalAuthModalStore();
const [isLocalAuthDialogOpen, setIsLocalAuthDialogOpen] = useState(false);
useEffect(() => {
if (isOnDevice) getDevice();
}, [getDevice]);
useEffect(() => {
if (!isOnDevice) return;
// Refresh device status when the local auth dialog is closed
if (!isLocalAuthDialogOpen) {
getDevice();
}
}, [getDevice, isLocalAuthDialogOpen]);
const revalidator = useRevalidator();
const [currentTheme, setCurrentTheme] = useState(() => {
return localStorage.theme || "system";
});
const handleThemeChange = useCallback((value: string) => {
const root = document.documentElement;
if (value === "system") {
localStorage.removeItem("theme");
// Check system preference
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
root.classList.remove("light", "dark");
root.classList.add(systemTheme);
} else {
localStorage.theme = value;
root.classList.remove("light", "dark");
root.classList.add(value);
}
}, []);
const handleResetConfig = useCallback(() => {
send("resetConfig", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Configuration reset to default successfully");
});
}, [send]);
return (
<div
className="grid h-full shadow-sm grid-rows-headerBody"
// Prevent the keyboard entries from propagating to the document where they are listened for and sent to the KVM
onKeyDown={e => e.stopPropagation()}
onKeyUp={e => e.stopPropagation()}
>
<SidebarHeader title="Settings" setSidebarView={setSidebarView} />
<div
className="h-full px-4 py-2 space-y-4 overflow-y-scroll bg-white dark:bg-slate-900"
ref={sidebarRef}
>
<div className="space-y-4">
<div className="flex items-center justify-between mt-2 gap-x-2">
<SettingsItem
title="Check for Updates"
description={
currentVersions ? (
<>
App: {currentVersions.appVersion}
<br />
System: {currentVersions.systemVersion}
</>
) : (
"Loading current versions..."
)
}
/>
<div>
<Button
size="SM"
theme="light"
text="Check for Updates"
onClick={handleCheckForUpdates}
/>
</div>
</div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SectionHeader
title="Mouse"
description="Customize mouse behavior and interaction settings"
/>
<div className="space-y-4">
<SettingsItem
title="Hide Cursor"
description="Hide the cursor when sending mouse movements"
>
<Checkbox
checked={hideCursor}
onChange={e => {
setHideCursor(e.target.checked);
}}
/>
</SettingsItem>
<SettingsItem
title="Jiggler"
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
>
<Checkbox
checked={jiggler}
onChange={e => {
handleJigglerChange(e.target.checked);
}}
/>
</SettingsItem>
<div className="space-y-4">
<SettingsItem title="Modes" description="Choose the mouse input mode" />
<div className="flex items-center gap-4">
<button
className="block group grow"
onClick={() => console.log("Absolute mouse mode clicked")}
>
<GridCard>
<div className="flex items-center px-4 py-3 group gap-x-4">
<img
className="w-6 shrink-0"
src={PointingFinger}
alt="Finger touching a screen"
/>
<div className="flex items-center justify-between grow">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Absolute
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most convenient
</p>
</div>
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
</div>
</div>
</GridCard>
</button>
<button
className="block opacity-50 cursor-not-allowed group grow"
disabled
>
<GridCard>
<div className="flex items-center px-4 py-3 gap-x-4">
<img className="w-6 shrink-0" src={MouseIcon} alt="Mouse icon" />
<div className="flex items-center justify-between grow">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Relative
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Coming soon
</p>
</div>
</div>
</div>
</GridCard>
</button>
</div>
</div>
</div>
</div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="pb-2 space-y-4">
<SectionHeader
title="Video"
description="Configure display settings and EDID for optimal compatibility"
/>
<div className="space-y-4">
<SettingsItem
title="Stream Quality"
description="Adjust the quality of the video stream"
>
<SelectMenuBasic
size="SM"
label=""
value={streamQuality}
options={[
{ value: "1", label: "High" },
{ value: "0.5", label: "Medium" },
{ value: "0.1", label: "Low" },
]}
onChange={e => handleStreamQualityChange(e.target.value)}
/>
</SettingsItem>
<SettingsItem
title="EDID"
description="Adjust the EDID settings for the display"
>
<SelectMenuBasic
size="SM"
label=""
fullWidth
value={customEdidValue ? "custom" : edid || "asd"}
onChange={e => {
if (e.target.value === "custom") {
setEdid("custom");
setCustomEdidValue("");
} else {
handleEDIDChange(e.target.value as string);
}
}}
options={[...edids, { value: "custom", label: "Custom" }]}
/>
</SettingsItem>
{customEdidValue !== null && (
<>
<SettingsItem
title="Custom EDID"
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
/>
<TextAreaWithLabel
label="EDID File"
placeholder="00F..."
rows={3}
value={customEdidValue}
onChange={e => setCustomEdidValue(e.target.value)}
/>
<div className="flex justify-start gap-x-2">
<Button
size="MD"
theme="primary"
text="Set Custom EDID"
onClick={() => handleEDIDChange(customEdidValue)}
/>
<Button
size="MD"
theme="light"
text="Restore to default"
onClick={() => {
setCustomEdidValue(null);
handleEDIDChange(defaultEdid);
}}
/>
</div>
</>
)}
</div>
</div>
{isOnDevice && (
<>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="pb-4 space-y-4">
<SectionHeader
title="JetKVM Cloud"
description="Connect your device to the cloud for secure remote access and management"
/>
<GridCard>
<div className="flex items-start p-4 gap-x-4">
<ShieldCheckIcon className="w-8 h-8 mt-1 text-blue-600 shrink-0 dark:text-blue-500" />
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Cloud Security
</h3>
<div>
<ul className="space-y-1 text-xs text-slate-700 dark:text-slate-300">
<li> End-to-end encryption using WebRTC (DTLS and SRTP)</li>
<li> Zero Trust security model</li>
<li> OIDC (OpenID Connect) authentication</li>
<li> All streams encrypted in transit</li>
</ul>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
All cloud components are open-source and available on{" "}
<a
href="https://github.com/jetkvm"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
>
GitHub
</a>
.
</div>
</div>
<hr className="block w-full dark:border-slate-600" />
<div>
<LinkButton
to="https://jetkvm.com/docs/networking/remote-access"
size="SM"
theme="light"
text="Learn about our cloud security"
/>
</div>
</div>
</div>
</GridCard>
{!isAdopted ? (
<div>
<LinkButton
to={
import.meta.env.VITE_CLOUD_APP +
"/signup?deviceId=" +
deviceId +
`&returnTo=${location.href}adopt`
}
size="MD"
theme="primary"
text="Adopt KVM to Cloud account"
/>
</div>
) : (
<div>
<div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-300">
Your device is adopted to JetKVM Cloud
</p>
<div>
<Button
size="MD"
theme="light"
text="De-register from Cloud"
className="text-red-600"
onClick={() => {
if (deviceId) {
if (
window.confirm(
"Are you sure you want to de-register this device?",
)
) {
deregisterDevice();
}
} else {
notifications.error("No device ID available");
}
}}
/>
</div>
</div>
</div>
)}
</div>
</>
)}
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
{isOnDevice ? (
<>
<div className="pb-2 space-y-4">
<SectionHeader
title="Local Access"
description="Manage the mode of local access to the device"
/>
<div className="space-y-4">
<SettingsItem
title="Authentication Mode"
description={`Current mode: ${localDevice?.authMode === "password" ? "Password protected" : "No password"}`}
>
{localDevice?.authMode === "password" ? (
<Button
size="SM"
theme="light"
text="Disable Protection"
onClick={() => {
setLocalAuthModalView("deletePassword");
setIsLocalAuthDialogOpen(true);
}}
/>
) : (
<Button
size="SM"
theme="light"
text="Enable Password"
onClick={() => {
setLocalAuthModalView("createPassword");
setIsLocalAuthDialogOpen(true);
}}
/>
)}
</SettingsItem>
{localDevice?.authMode === "password" && (
<SettingsItem
title="Change Password"
description="Update your device access password"
>
<Button
size="SM"
theme="light"
text="Change Password"
onClick={() => {
setLocalAuthModalView("updatePassword");
setIsLocalAuthDialogOpen(true);
}}
/>
</SettingsItem>
)}
</div>
</div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
</>
) : null}
<div className="pb-2 space-y-4">
<SectionHeader
title="Updates"
description="Manage software updates and version information"
/>
<div className="space-y-4">
<SettingsItem
title="Auto Update"
description="Automatically update the device to the latest version"
>
<Checkbox
checked={autoUpdate}
onChange={e => {
handleAutoUpdateChange(e.target.checked);
}}
/>
</SettingsItem>
<SettingsItem
title="Dev Channel Updates"
description="Receive early updates from the development channel"
>
<Checkbox
checked={devChannel}
onChange={e => {
handleDevChannelChange(e.target.checked);
}}
/>
</SettingsItem>
</div>
</div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SectionHeader
title="Appearance"
description="Customize the look and feel of the application"
/>
<SettingsItem title="Theme" description="Choose your preferred color theme">
<SelectMenuBasic
size="SM"
label=""
value={currentTheme}
options={[
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
onChange={e => {
setCurrentTheme(e.target.value);
handleThemeChange(e.target.value);
}}
/>
</SettingsItem>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="pb-2 space-y-4">
<SectionHeader
title="Advanced"
description="Access additional settings for troubleshooting and customization"
/>
<div className="pb-4 space-y-4">
<SettingsItem
title="Developer Mode"
description="Enable advanced features for developers"
>
<Checkbox
checked={settings.developerMode}
onChange={e => handleDevModeChange(e.target.checked)}
/>
</SettingsItem>
{settings.developerMode && (
<div className="space-y-4">
<TextAreaWithLabel
label="SSH Public Key"
value={sshKey || ""}
rows={3}
onChange={e => handleSSHKeyChange(e.target.value)}
placeholder="Enter your SSH public key"
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
The default SSH user is <strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Update SSH Key"
onClick={handleUpdateSSHKey}
/>
</div>
</div>
)}
<SettingsItem
title="Troubleshooting Mode"
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
>
<Checkbox
defaultChecked={settings.debugMode}
onChange={e => {
settings.setDebugMode(e.target.checked);
}}
/>
</SettingsItem>
{settings.debugMode && (
<>
<SettingsItem
title="USB Emulation"
description="Control the USB emulation state"
>
<Button
size="SM"
theme="light"
text={
usbEmulationEnabled
? "Disable USB Emulation"
: "Enable USB Emulation"
}
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
/>
</SettingsItem>
</>
)}
{settings.debugMode && (
<SettingsItem
title="Reset Configuration"
description="Reset the configuration file to its default state. This will log you out of the device."
>
<Button
size="SM"
theme="light"
text="Reset Config"
onClick={() => {
handleResetConfig();
window.location.reload();
}}
/>
</SettingsItem>
)}
</div>
</div>
</div>
<LocalAuthPasswordDialog
open={isLocalAuthDialogOpen}
setOpen={x => {
// Revalidate the current route to refresh the local device status and dependent UI components
revalidator.revalidate();
setIsLocalAuthDialogOpen(x);
}}
/>
</div>
);
}

View File

@ -20,8 +20,8 @@ const appendStatToMap = <T extends { timestamp: number }>(
};
// Constants and types
export type AvailableSidebarViews = "system" | "connection-stats";
export type AvailableModalViews = "connection-stats" | "settings";
export type AvailableSidebarViews = "connection-stats";
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
export interface User {
sub: string;
@ -46,19 +46,16 @@ interface UIState {
toggleSidebarView: (view: AvailableSidebarViews) => void;
modalView: AvailableModalViews | null;
setModalView: (view: AvailableModalViews | null) => void;
isAttachedVirtualKeyboardVisible: boolean;
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
enableTerminal: boolean;
setEnableTerminal: (enabled: UIState["enableTerminal"]) => void;
terminalType: AvailableTerminalTypes;
setTerminalType: (enabled: UIState["terminalType"]) => void;
}
export const useUiStore = create<UIState>(set => ({
enableTerminal: false,
setEnableTerminal: enabled => set({ enableTerminal: enabled }),
terminalType: "none",
setTerminalType: type => set({ terminalType: type }),
sidebarView: null,
setSidebarView: view => set({ sidebarView: view }),
@ -78,9 +75,6 @@ export const useUiStore = create<UIState>(set => ({
}
}),
modalView: null,
setModalView: view => set({ modalView: view }),
isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: enabled =>
set({ isAttachedVirtualKeyboardVisible: enabled }),
@ -229,6 +223,12 @@ export interface VideoState {
}) => void;
}
export interface BacklightSettings {
max_brightness: number;
dim_after: number;
off_after: number;
}
export const useVideoStore = create<VideoState>(set => ({
width: 0,
height: 0,
@ -270,6 +270,9 @@ interface SettingsState {
// Add new developer mode state
developerMode: boolean;
setDeveloperMode: (enabled: boolean) => void;
backlightSettings: BacklightSettings;
setBacklightSettings: (settings: BacklightSettings) => void;
}
export const useSettingsStore = create(
@ -287,6 +290,14 @@ export const useSettingsStore = create(
// Add developer mode with default value
developerMode: false,
setDeveloperMode: enabled => set({ developerMode: enabled }),
backlightSettings: {
max_brightness: 100,
dim_after: 10000,
off_after: 50000,
},
setBacklightSettings: (settings: BacklightSettings) =>
set({ backlightSettings: settings }),
}),
{
name: "settings",
@ -295,6 +306,78 @@ export const useSettingsStore = create(
),
);
export interface DeviceSettingsState {
trackpadSensitivity: number;
mouseSensitivity: number;
clampMin: number;
clampMax: number;
blockDelay: number;
trackpadThreshold: number;
scrollSensitivity: "low" | "default" | "high";
setScrollSensitivity: (sensitivity: DeviceSettingsState["scrollSensitivity"]) => void;
}
export const useDeviceSettingsStore = create<DeviceSettingsState>(set => ({
trackpadSensitivity: 3.0,
mouseSensitivity: 5.0,
clampMin: -8,
clampMax: 8,
blockDelay: 25,
trackpadThreshold: 10,
scrollSensitivity: "default",
setScrollSensitivity: sensitivity => {
const wheelSettings: Record<
DeviceSettingsState["scrollSensitivity"],
{
trackpadSensitivity: DeviceSettingsState["trackpadSensitivity"];
mouseSensitivity: DeviceSettingsState["mouseSensitivity"];
clampMin: DeviceSettingsState["clampMin"];
clampMax: DeviceSettingsState["clampMax"];
blockDelay: DeviceSettingsState["blockDelay"];
trackpadThreshold: DeviceSettingsState["trackpadThreshold"];
}
> = {
low: {
trackpadSensitivity: 2.0,
mouseSensitivity: 3.0,
clampMin: -6,
clampMax: 6,
blockDelay: 30,
trackpadThreshold: 10,
},
default: {
trackpadSensitivity: 3.0,
mouseSensitivity: 5.0,
clampMin: -8,
clampMax: 8,
blockDelay: 25,
trackpadThreshold: 10,
},
high: {
trackpadSensitivity: 4.0,
mouseSensitivity: 6.0,
clampMin: -9,
clampMax: 9,
blockDelay: 20,
trackpadThreshold: 10,
},
};
const settings = wheelSettings[sensitivity];
return set({
trackpadSensitivity: settings.trackpadSensitivity,
trackpadThreshold: settings.trackpadThreshold,
mouseSensitivity: settings.mouseSensitivity,
clampMin: settings.clampMin,
clampMax: settings.clampMax,
blockDelay: settings.blockDelay,
scrollSensitivity: sensitivity,
});
},
}));
export interface RemoteVirtualMediaState {
source: "WebRTC" | "HTTP" | "Storage" | null;
mode: "CDROM" | "Disk" | null;
@ -467,8 +550,6 @@ export interface UpdateState {
| "updateCompleted"
| "error";
setModalView: (view: UpdateState["modalView"]) => void;
isUpdateDialogOpen: boolean;
setIsUpdateDialogOpen: (isOpen: boolean) => void;
setUpdateErrorMessage: (errorMessage: string) => void;
updateErrorMessage: string | null;
}
@ -503,12 +584,32 @@ export const useUpdateStore = create<UpdateState>(set => ({
set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
modalView: "loading",
setModalView: view => set({ modalView: view }),
isUpdateDialogOpen: false,
setIsUpdateDialogOpen: isOpen => set({ isUpdateDialogOpen: isOpen }),
updateErrorMessage: null,
setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }),
}));
interface UsbConfigModalState {
modalView: "updateUsbConfig" | "updateUsbConfigSuccess";
errorMessage: string | null;
setModalView: (view: UsbConfigModalState["modalView"]) => void;
setErrorMessage: (message: string | null) => void;
}
export interface UsbConfigState {
vendor_id: string;
product_id: string;
serial_number: string;
manufacturer: string;
product: string;
}
export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
modalView: "updateUsbConfig",
errorMessage: null,
setModalView: view => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }),
}));
interface LocalAuthModalState {
modalView:
| "createPassword"
@ -517,14 +618,26 @@ interface LocalAuthModalState {
| "creationSuccess"
| "deleteSuccess"
| "updateSuccess";
errorMessage: string | null;
setModalView: (view: LocalAuthModalState["modalView"]) => void;
setErrorMessage: (message: string | null) => void;
}
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
modalView: "createPassword",
errorMessage: null,
setModalView: view => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }),
}));
export interface DeviceState {
appVersion: string | null;
systemVersion: string | null;
setAppVersion: (version: string) => void;
setSystemVersion: (version: string) => void;
}
export const useDeviceStore = create<DeviceState>(set => ({
appVersion: null,
systemVersion: null,
setAppVersion: version => set({ appVersion: version }),
setSystemVersion: version => set({ systemVersion: version }),
}));

View File

@ -0,0 +1,66 @@
import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
import { isOnDevice } from "../main";
import { useCallback, useMemo } from "react";
/**
* Generates the correct path based on whether the app is running on device or in cloud mode
*
*/
export function getDeviceUiPath(path: string, deviceId?: string): string {
// Check if it's a relative path (starts with . or ..)
const isRelativePath = path.startsWith(".") || path === "";
// If it's a relative path, don't modify it
if (isRelativePath) return path;
// Ensure absolute path starts with a slash
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (isOnDevice) {
return normalizedPath;
} else {
if (!deviceId) {
console.error("No device ID provided when generating path in cloud mode");
throw new Error("Device ID is required for cloud mode path generation");
}
return `/devices/${deviceId}${normalizedPath}`;
}
}
/**
* Hook that provides context-aware navigation and path generation
* that works in both cloud and device modes.
*
* In cloud mode, paths are prefixed with /devices/:id
* In device mode, paths start from the root
* Relative paths (starting with . or ..) are preserved in both modes
* Supports all React Router navigation options
*/
export function useDeviceUiNavigation() {
const navigate = useNavigate();
const params = useParams();
// Get the device ID from params
const deviceId = useMemo(() => params.id, [params.id]);
// Use the standalone getPath function with the current deviceId
const getPath = useCallback(
(path: string): string => {
return getDeviceUiPath(path, deviceId);
},
[deviceId],
);
// Function to navigate to the correct path with all options
const navigateTo = useCallback(
(path: string, options?: NavigateOptions) => {
navigate(getPath(path), options);
},
[getPath, navigate],
);
return {
navigateTo,
getPath,
};
}

View File

@ -0,0 +1,15 @@
import { useContext } from "react";
import { FeatureFlagContext } from "../providers/FeatureFlagProvider";
export const useFeatureFlag = (minAppVersion: string) => {
const context = useContext(FeatureFlagContext);
if (!context) {
throw new Error("useFeatureFlag must be used within a FeatureFlagProvider");
}
const { isFeatureEnabled, appVersion } = context;
const isEnabled = isFeatureEnabled(minAppVersion);
return { isEnabled, appVersion };
};

View File

@ -8,17 +8,25 @@ export interface JsonRpcRequest {
id: number | string;
}
type JsonRpcResponse =
| {
jsonrpc: string;
result: boolean | number | object | string | [];
id: string | number;
}
| {
jsonrpc: string;
error: { code: number; data?: string; message: string };
id: string | number;
};
export interface JsonRpcError {
code: number;
data?: string;
message: string;
}
export interface JsonRpcSuccessResponse {
jsonrpc: string;
result: boolean | number | object | string | [];
id: string | number;
}
export interface JsonRpcErrorResponse {
jsonrpc: string;
error: JsonRpcError;
id: string | number;
}
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>();
let requestCounter = 0;

View File

@ -105,7 +105,7 @@ video::-webkit-media-controls {
}
.controlArrows {
@apply flex items-center justify-between w-full md:w-1/5;
@apply flex w-full items-center justify-between md:w-1/5;
flex-flow: column;
}
@ -191,3 +191,13 @@ video::-webkit-media-controls {
scrollbar-color: theme("colors.gray.900") #002b36;
scrollbar-width: thin;
}
.hide-scrollbar {
overflow-y: scroll;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}

View File

@ -1,4 +1,3 @@
import React from "react";
import ReactDOM from "react-dom/client";
import Root from "./root";
import "./index.css";
@ -9,7 +8,7 @@ import {
RouterProvider,
useRouteError,
} from "react-router-dom";
import DeviceRoute from "@routes/devices.$id";
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";
@ -25,14 +24,29 @@ import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
import Notifications from "./notifications";
import LoginLocalRoute from "./routes/login-local";
import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
import WelcomeRoute from "./routes/welcome-local";
import WelcomeRoute, { DeviceStatus } from "./routes/welcome-local";
import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
import { CLOUD_API, DEVICE_API } from "./ui.config";
import OtherSessionRoute from "./routes/devices.$id.other-session";
import MountRoute from "./routes/devices.$id.mount";
import * as SettingsRoute from "./routes/devices.$id.settings";
import SettingsKeyboardMouseRoute from "./routes/devices.$id.settings.mouse";
import api from "./api";
import * as SettingsIndexRoute from "./routes/devices.$id.settings._index";
import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced";
import * as SettingsAccessIndexRoute from "./routes/devices.$id.settings.access._index";
import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
import SettingsVideoRoute from "./routes/devices.$id.settings.video";
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
export const isOnDevice = import.meta.env.MODE === "device";
export const isInCloud = !isOnDevice;
export async function checkAuth() {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/me`, {
export async function checkCloudAuth() {
const res = await fetch(`${CLOUD_API}/me`, {
mode: "cors",
credentials: "include",
headers: { "Content-Type": "application/json" },
@ -45,6 +59,27 @@ export async function checkAuth() {
return await res.json();
}
export async function checkDeviceAuth() {
const res = await api
.GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice;
return { authMode: device.authMode };
}
throw new Error("Error fetching device");
}
export async function checkAuth() {
return import.meta.env.MODE === "device" ? checkDeviceAuth() : checkCloudAuth();
}
let router;
if (isOnDevice) {
router = createBrowserRouter([
@ -74,6 +109,73 @@ if (isOnDevice) {
errorElement: <ErrorBoundary />,
element: <DeviceRoute />,
loader: DeviceRoute.loader,
children: [
{
path: "other-session",
element: <OtherSessionRoute />,
},
{
path: "mount",
element: <MountRoute />,
},
{
path: "settings",
element: <SettingsRoute.default />,
children: [
{
index: true,
loader: SettingsIndexRoute.loader,
},
{
path: "general",
children: [
{
index: true,
element: <SettingsGeneralIndexRoute.default />,
},
{
path: "update",
element: <SettingsGeneralUpdateRoute />,
},
],
},
{
path: "mouse",
element: <SettingsKeyboardMouseRoute />,
},
{
path: "advanced",
element: <SettingsAdvancedRoute />,
},
{
path: "hardware",
element: <SettingsHardwareRoute />,
},
{
path: "access",
children: [
{
index: true,
element: <SettingsAccessIndexRoute.default />,
loader: SettingsAccessIndexRoute.loader,
},
{
path: "local-auth",
element: <SecurityAccessLocalAuthRoute />,
},
],
},
{
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,
},
],
},
],
},
{
path: "/adopt",
@ -115,6 +217,73 @@ if (isOnDevice) {
path: "devices/:id",
element: <DeviceRoute />,
loader: DeviceRoute.loader,
children: [
{
path: "other-session",
element: <OtherSessionRoute />,
},
{
path: "mount",
element: <MountRoute />,
},
{
path: "settings",
element: <SettingsRoute.default />,
children: [
{
index: true,
loader: SettingsIndexRoute.loader,
},
{
path: "general",
children: [
{
index: true,
element: <SettingsGeneralIndexRoute.default />,
},
{
path: "update",
element: <SettingsGeneralUpdateRoute />,
},
],
},
{
path: "mouse",
element: <SettingsKeyboardMouseRoute />,
},
{
path: "advanced",
element: <SettingsAdvancedRoute />,
},
{
path: "hardware",
element: <SettingsHardwareRoute />,
},
{
path: "access",
children: [
{
index: true,
element: <SettingsAccessIndexRoute.default />,
loader: SettingsAccessIndexRoute.loader,
},
{
path: "local-auth",
element: <SecurityAccessLocalAuthRoute />,
},
],
},
{
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,
},
],
},
],
},
{
path: "devices/:id/deregister",
@ -138,7 +307,7 @@ if (isOnDevice) {
document.addEventListener("DOMContentLoaded", () => {
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<>
<RouterProvider router={router} />
<Notifications
toastOptions={{
@ -147,7 +316,7 @@ document.addEventListener("DOMContentLoaded", () => {
}}
max={2}
/>
</React.StrictMode>,
</>,
);
});
@ -163,8 +332,8 @@ function ErrorBoundary() {
}
return (
<div className="w-full h-full">
<div className="flex items-center justify-center h-full">
<div className="h-full w-full">
<div className="flex h-full items-center justify-center">
<div className="w-full max-w-2xl">
<EmptyCard
IconElm={ExclamationTriangleIcon}

View File

@ -0,0 +1,42 @@
import { createContext } from "react";
import semver from "semver";
interface FeatureFlagContextType {
appVersion: string | null;
isFeatureEnabled: (minVersion: string) => boolean;
}
// Create the context
export const FeatureFlagContext = createContext<FeatureFlagContextType>({
appVersion: null,
isFeatureEnabled: () => false,
});
// Provider component that fetches version and provides context
export const FeatureFlagProvider = ({
children,
appVersion,
}: {
children: React.ReactNode;
appVersion: string | null;
}) => {
const isFeatureEnabled = (minAppVersion: string) => {
// If no version is set, feature is disabled.
// The feature flag component can decide what to display as a fallback - either omit the component or like a "please upgrade to enable".
if (!appVersion) return false;
// Extract the base versions without prerelease identifier
const baseCurrentVersion = semver.coerce(appVersion)?.version;
const baseMinVersion = semver.coerce(minAppVersion)?.version;
if (!baseCurrentVersion || !baseMinVersion) return false;
return semver.gte(baseCurrentVersion, baseMinVersion);
};
const value = { appVersion, isFeatureEnabled };
return (
<FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>
);
};

View File

@ -1,5 +1,12 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom";
import api from "../api";
import { DEVICE_API } from "@/ui.config";
export interface CloudState {
connected: boolean;
url: string;
appUrl: string;
}
const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
@ -10,18 +17,21 @@ const loader = async ({ request }: LoaderFunctionArgs) => {
const oidcGoogle = searchParams.get("oidcGoogle");
const clientId = searchParams.get("clientId");
const res = await api.POST(
`${import.meta.env.VITE_SIGNAL_API}/cloud/register`,
{
const [cloudStateResponse, registerResponse] = await Promise.all([
api.GET(`${DEVICE_API}/cloud/state`),
api.POST(`${DEVICE_API}/cloud/register`, {
token: tempToken,
cloudApi: import.meta.env.VITE_CLOUD_API,
oidcGoogle,
clientId,
},
);
}),
]);
if (!res.ok) throw new Error("Failed to register device");
return redirect(import.meta.env.VITE_CLOUD_APP + `/devices/${deviceId}/setup`);
if (!cloudStateResponse.ok) throw new Error("Failed to get cloud state");
const cloudState = (await cloudStateResponse.json()) as CloudState;
if (!registerResponse.ok) throw new Error("Failed to register device");
return redirect(cloudState.appUrl + `/devices/${deviceId}/setup`);
};
export default function AdoptRoute() {

View File

@ -14,6 +14,7 @@ import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { CLOUD_API } from "@/ui.config";
interface LoaderData {
device: { id: string; name: string; user: { googleId: string } };
@ -24,7 +25,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
const { deviceId } = Object.fromEntries(await request.formData());
try {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${deviceId}`, {
const res = await fetch(`${CLOUD_API}/devices/${deviceId}`, {
method: "DELETE",
credentials: "include",
headers: { "Content-Type": "application/json" },
@ -46,7 +47,7 @@ const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = params;
try {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
const res = await fetch(`${CLOUD_API}/devices/${id}`, {
method: "GET",
credentials: "include",
mode: "cors",

View File

@ -3,7 +3,6 @@ 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 Modal from "@components/Modal";
import {
MountMediaState,
RemoteVirtualMediaState,
@ -21,44 +20,41 @@ import {
} from "react-icons/lu";
import { formatters } from "@/utils";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import AutoHeight from "./AutoHeight";
import { InputFieldWithLabel } from "./InputField";
import AutoHeight from "@components/AutoHeight";
import { InputFieldWithLabel } from "@/components/InputField";
import DebianIcon from "@/assets/debian-icon.png";
import UbuntuIcon from "@/assets/ubuntu-icon.png";
import FedoraIcon from "@/assets/fedora-icon.png";
import OpenSUSEIcon from "@/assets/opensuse-icon.png";
import ArchIcon from "@/assets/arch-icon.png";
import NetBootIcon from "@/assets/netboot-icon.svg";
import { TrashIcon } from "@heroicons/react/16/solid";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import notifications from "../notifications";
import Fieldset from "./Fieldset";
import Fieldset from "@/components/Fieldset";
import { isOnDevice } from "../main";
import { DEVICE_API } from "@/ui.config";
import { useNavigate } from "react-router-dom";
export default function MountMediaModal({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
export default function MountRoute() {
const navigate = useNavigate();
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigate("..")} />;
}
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
export function Dialog({ onClose }: { onClose: () => void }) {
const {
modalView,
setModalView,
setLocalFile,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState,
errorMessage,
setErrorMessage,
} = useMountMediaStore();
const navigate = useNavigate();
const [incompleteFileName, setIncompleteFileName] = useState<string | null>(null);
const [mountInProgress, setMountInProgress] = useState(false);
@ -97,9 +93,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
clearMountMediaState();
syncRemoteVirtualMediaState()
.then(() => {
setIsMountMediaDialogOpen(false);
})
.then(() => navigate(".."))
.catch(err => {
triggerError(err instanceof Error ? err.message : String(err));
})
@ -107,7 +101,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
setMountInProgress(false);
});
setIsMountMediaDialogOpen(false);
navigate("..");
});
}
@ -121,13 +115,13 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
clearMountMediaState();
syncRemoteVirtualMediaState()
.then(() => {
setIsMountMediaDialogOpen(false);
false;
})
.catch(err => {
triggerError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
// We do this beacues the mounting is too fast and the UI gets choppy
// We do this because the mounting is too fast and the UI gets choppy
// and the modal exit animation for like 500ms
setTimeout(() => {
setMountInProgress(false);
@ -154,7 +148,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
// We need to keep the local file in the store so that the browser can
// continue to stream the file to the device
setLocalFile(file);
setIsMountMediaDialogOpen(false);
navigate("..");
})
.catch(err => {
triggerError(err instanceof Error ? err.message : String(err));
@ -186,16 +180,16 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<img
src={LogoBlueIcon}
alt="JetKVM Logo"
className="h-[24px] dark:hidden block"
className="block h-[24px] dark:hidden"
/>
<img
src={LogoWhiteIcon}
alt="JetKVM Logo"
className="h-[24px] dark:block hidden dark:!mt-0"
className="hidden h-[24px] dark:!mt-0 dark:block"
/>
{modalView === "mode" && (
<ModeSelectionView
onClose={() => setOpen(false)}
onClose={() => onClose()}
selectedMode={selectedMode}
setSelectedMode={setSelectedMode}
/>
@ -259,7 +253,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<ErrorView
errorMessage={errorMessage}
onClose={() => {
setOpen(false);
onClose();
setErrorMessage(null);
}}
onRetry={() => {
@ -289,7 +283,7 @@ function ModeSelectionView({
return (
<div className="w-full space-y-4">
<div className="space-y-0 asnimate-fadeIn">
<div className="animate-fadeIn space-y-0">
<h2 className="text-lg font-bold leading-tight dark:text-white">
Virtual Media Source
</h2>
@ -343,7 +337,7 @@ function ModeSelectionView({
)}
>
<div
className="relative z-50 flex flex-col items-start p-4 select-none"
className="relative z-50 flex select-none flex-col items-start p-4"
onClick={() =>
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device")
}
@ -351,7 +345,7 @@ function ModeSelectionView({
<div>
<Card>
<div className="p-1">
<Icon className="w-4 h-4 text-blue-700 shrink-0 dark:text-blue-400" />
<Icon className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-400" />
</div>
</Card>
</div>
@ -371,7 +365,7 @@ function ModeSelectionView({
value={mode}
disabled={disabled}
checked={selectedMode === mode}
className="absolute w-4 h-4 text-blue-700 right-4 top-4"
className="absolute right-4 top-4 h-4 w-4 text-blue-700"
/>
</div>
</Card>
@ -379,13 +373,13 @@ function ModeSelectionView({
))}
</div>
<div
className="flex justify-end opacity-0 animate-fadeIn"
className="flex animate-fadeIn justify-end opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<div className="flex pt-2 gap-x-2">
<div className="flex gap-x-2 pt-2">
<Button size="MD" theme="blank" onClick={onClose} text="Cancel" />
<Button
size="MD"
@ -443,18 +437,18 @@ function BrowserFileView({
className="block cursor-pointer select-none"
>
<div
className="opacity-0 group animate-fadeIn"
className="group animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
}}
>
<Card className="transition-all duration-300 outline-dashed hover:bg-blue-50/50">
<Card className="outline-dashed transition-all duration-300 hover:bg-blue-50/50">
<div className="w-full px-4 py-12">
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="flex h-full flex-col items-center justify-center text-center">
{selectedFile ? (
<>
<div className="space-y-1">
<LuHardDrive className="w-6 h-6 mx-auto text-blue-700" />
<LuHardDrive className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm font-semibold leading-none">
{formatters.truncateMiddle(selectedFile.name, 40)}
</h3>
@ -465,7 +459,7 @@ function BrowserFileView({
</>
) : (
<div className="space-y-1">
<PlusCircleIcon className="w-6 h-6 mx-auto text-blue-700" />
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm font-semibold leading-none">
Click to select a file
</h3>
@ -489,7 +483,7 @@ function BrowserFileView({
</div>
<div
className="flex items-end justify-between w-full opacity-0 animate-fadeIn"
className="flex w-full animate-fadeIn items-end justify-between opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -529,22 +523,32 @@ function UrlView({
const popularImages = [
{
name: "Ubuntu 24.04 LTS",
url: "https://releases.ubuntu.com/noble/ubuntu-24.04.1-desktop-amd64.iso",
url: "https://releases.ubuntu.com/24.04.2/ubuntu-24.04.2-desktop-amd64.iso",
icon: UbuntuIcon,
},
{
name: "Debian 12",
url: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-12.7.0-amd64-netinst.iso",
url: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-12.9.0-amd64-netinst.iso",
icon: DebianIcon,
},
{
name: "Fedora 38",
url: "https://mirror.ihost.md/fedora/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso",
name: "Fedora 41",
url: "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-41-1.4.iso",
icon: FedoraIcon,
},
{
name: "openSUSE Leap 15.6",
url: "https://download.opensuse.org/distribution/leap/15.6/iso/openSUSE-Leap-15.6-NET-x86_64-Media.iso",
icon: OpenSUSEIcon,
},
{
name: "openSUSE Tumbleweed",
url: "https://download.opensuse.org/tumbleweed/iso/openSUSE-Tumbleweed-NET-x86_64-Current.iso",
icon: OpenSUSEIcon,
},
{
name: "Arch Linux",
url: "https://archlinux.doridian.net/iso/2024.10.01/archlinux-2024.10.01-x86_64.iso",
url: "https://archlinux.doridian.net/iso/2025.02.01/archlinux-2025.02.01-x86_64.iso",
icon: ArchIcon,
},
{
@ -574,7 +578,7 @@ function UrlView({
/>
<div
className="opacity-0 animate-fadeIn"
className="animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
}}
@ -589,7 +593,7 @@ function UrlView({
/>
</div>
<div
className="flex items-end justify-between w-full opacity-0 animate-fadeIn"
className="flex w-full animate-fadeIn items-end justify-between opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -615,7 +619,7 @@ function UrlView({
<hr className="border-slate-800/30 dark:border-slate-300/20" />
<div
className="opacity-0 animate-fadeIn"
className="animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
@ -624,7 +628,7 @@ function UrlView({
<h2 className="mb-2 text-sm font-semibold text-black dark:text-white">
Popular images
</h2>
<Card className="w-full divide-y divide-y-slate-800/30 dark:divide-slate-300/20">
<Card className="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20">
{popularImages.map((image, index) => (
<div key={index} className="flex items-center justify-between gap-x-4 p-3.5">
<div className="flex items-center gap-x-4">
@ -793,7 +797,7 @@ function DeviceFileView({
description="Select an image to mount from the JetKVM storage"
/>
<div
className="w-full opacity-0 animate-fadeIn"
className="w-full animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -804,7 +808,7 @@ function DeviceFileView({
<div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3">
<div className="space-y-1">
<PlusCircleIcon className="w-6 h-6 mx-auto text-blue-700 dark:text-blue-500" />
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" />
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No images available
</h3>
@ -823,7 +827,7 @@ function DeviceFileView({
</div>
</div>
) : (
<div className="w-full divide-y divide-y-slate-800/30 dark:divide-slate-300/20">
<div className="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20">
{currentFiles.map((file, index) => (
<PreUploadedImageItem
key={index}
@ -835,7 +839,9 @@ function DeviceFileView({
onDelete={() => {
const selectedFile = onStorageFiles.find(f => f.name === file.name);
if (!selectedFile) return;
handleDeleteFile(selectedFile);
if (window.confirm("Are you sure you want to delete " + selectedFile.name + "?")) {
handleDeleteFile(selectedFile);
}
}}
onSelect={() => handleOnSelectFile(file)}
onContinueUpload={() => onNewImageClick(file.name)}
@ -876,7 +882,7 @@ function DeviceFileView({
{onStorageFiles.length > 0 ? (
<div
className="flex items-end justify-between opacity-0 animate-fadeIn"
className="flex animate-fadeIn items-end justify-between opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.15s",
@ -904,7 +910,7 @@ function DeviceFileView({
</div>
) : (
<div
className="flex items-end justify-end opacity-0 animate-fadeIn"
className="flex animate-fadeIn items-end justify-end opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.15s",
@ -917,31 +923,39 @@ function DeviceFileView({
)}
<hr className="border-slate-800/20 dark:border-slate-300/20" />
<div
className="space-y-2 opacity-0 animate-fadeIn"
className="animate-fadeIn space-y-2 opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.20s",
}}
>
<div className="flex justify-between text-sm">
<span className="font-medium text-black dark:text-white">Available Storage</span>
<span className="text-slate-700 dark:text-slate-300">{percentageUsed}% used</span>
<span className="font-medium text-black dark:text-white">
Available Storage
</span>
<span className="text-slate-700 dark:text-slate-300">
{percentageUsed}% used
</span>
</div>
<div className="h-3.5 w-full overflow-hidden rounded-sm bg-slate-200 dark:bg-slate-700">
<div
className="h-full transition-all duration-300 ease-in-out bg-blue-700 rounded-sm dark:bg-blue-500"
className="h-full rounded-sm bg-blue-700 transition-all duration-300 ease-in-out dark:bg-blue-500"
style={{ width: `${percentageUsed}%` }}
></div>
</div>
<div className="flex justify-between text-sm text-slate-600">
<span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesUsed)} used</span>
<span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesFree)} free</span>
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesUsed)} used
</span>
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesFree)} free
</span>
</div>
</div>
{onStorageFiles.length > 0 && (
<div
className="w-full opacity-0 animate-fadeIn"
className="w-full animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.25s",
@ -1108,7 +1122,7 @@ function UploadFileView({
alreadyUploadedBytes: number,
dataChannel: string,
) {
const uploadUrl = `${import.meta.env.VITE_SIGNAL_API}/storage/upload?uploadId=${dataChannel}`;
const uploadUrl = `${DEVICE_API}/storage/upload?uploadId=${dataChannel}`;
const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl, true);
@ -1233,7 +1247,7 @@ function UploadFileView({
}
/>
<div
className="space-y-2 opacity-0 animate-fadeIn"
className="animate-fadeIn space-y-2 opacity-0"
style={{
animationDuration: "0.7s",
}}
@ -1249,17 +1263,18 @@ function UploadFileView({
<div className="group">
<Card
className={cx("transition-all duration-300", {
"cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50": uploadState === "idle",
"cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50":
uploadState === "idle",
})}
>
<div className="h-[186px] w-full px-4">
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="flex h-full flex-col items-center justify-center text-center">
{uploadState === "idle" && (
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
<PlusCircleIcon className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
</div>
</Card>
</div>
@ -1279,11 +1294,11 @@ function UploadFileView({
<div className="inline-block">
<Card>
<div className="p-1">
<LuUpload className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
<LuUpload className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
</div>
</Card>
</div>
<h3 className="text-lg font-semibold text-black leading-non dark:text-white">
<h3 className="leading-non text-lg font-semibold text-black dark:text-white">
Uploading {formatters.truncateMiddle(uploadedFileName, 30)}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
@ -1292,7 +1307,7 @@ function UploadFileView({
<div className="w-full space-y-2">
<div className="h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700">
<div
className="h-3.5 rounded-full bg-blue-700 dark:bg-blue-500 transition-all duration-500 ease-linear"
className="h-3.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
@ -1313,7 +1328,7 @@ function UploadFileView({
<div className="inline-block">
<Card>
<div className="p-1">
<LuCheck className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
<LuCheck className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
</div>
</Card>
</div>
@ -1338,13 +1353,15 @@ function UploadFileView({
className="hidden"
accept=".iso, .img"
/>
{fileError && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>}
{fileError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>
)}
</div>
{/* Display upload error if present */}
{uploadError && (
<div
className="mt-2 text-sm text-red-600 truncate opacity-0 dark:text-red-400 animate-fadeIn"
className="mt-2 animate-fadeIn truncate text-sm text-red-600 opacity-0 dark:text-red-400"
style={{ animationDuration: "0.7s" }}
>
Error: {uploadError}
@ -1352,13 +1369,13 @@ function UploadFileView({
)}
<div
className="flex items-end w-full opacity-0 animate-fadeIn"
className="flex w-full animate-fadeIn items-end opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div className="flex justify-end w-full space-x-2">
<div className="flex w-full justify-end space-x-2">
{uploadState === "uploading" ? (
<Button
size="MD"
@ -1400,7 +1417,7 @@ function ErrorView({
<div className="w-full space-y-4">
<div className="space-y-2">
<div className="flex items-center space-x-2 text-red-600">
<ExclamationTriangleIcon className="w-6 h-6" />
<ExclamationTriangleIcon className="h-6 w-6" />
<h2 className="text-lg font-bold leading-tight">Mount Error</h2>
</div>
<p className="text-sm leading-snug text-slate-600">
@ -1408,7 +1425,7 @@ function ErrorView({
</p>
</div>
{errorMessage && (
<Card className="p-4 border border-red-200 bg-red-50">
<Card className="border border-red-200 bg-red-50 p-4">
<p className="text-sm font-medium text-red-800">{errorMessage}</p>
</Card>
)}
@ -1468,12 +1485,12 @@ function PreUploadedImageItem({
<div className="flex items-center gap-x-1 text-slate-600 dark:text-slate-400">
{formatters.date(new Date(uploadedAt), { month: "short" })}
</div>
<div className="mx-1 h-[10px] w-[1px] bg-slate-300 dark:bg-slate-600 text-slate-300"></div>
<div className="mx-1 h-[10px] w-[1px] bg-slate-300 text-slate-300 dark:bg-slate-600"></div>
<div className="text-gray-600 dark:text-slate-400">{size}</div>
</div>
</div>
</div>
<div className="relative flex items-center select-none gap-x-3">
<div className="relative flex select-none items-center gap-x-3">
<div
className={cx("opacity-0 transition-opacity duration-200", {
"w-auto opacity-100": isHovering,
@ -1497,7 +1514,7 @@ function PreUploadedImageItem({
checked={isSelected}
onChange={onSelect}
name={name}
className="w-3 h-3 text-blue-700 bg-white dark:bg-slate-800 border-slate-800/30 dark:border-slate-300/20 focus:ring-blue-500 disabled:opacity-30"
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 focus:ring-blue-500 disabled:opacity-30 dark:border-slate-300/20 dark:bg-slate-800"
onClick={e => e.stopPropagation()} // Prevent double-firing of onSelect
/>
) : (
@ -1537,7 +1554,7 @@ function UsbModeSelector({
setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void;
}) {
return (
<div className="flex flex-col items-start space-y-1 select-none">
<div className="flex select-none flex-col items-start space-y-1">
<label className="text-sm font-semibold text-black dark:text-white">Mount as</label>
<div className="flex space-x-4">
<label htmlFor="cdrom" className="flex items-center">
@ -1547,7 +1564,7 @@ function UsbModeSelector({
name="mountType"
onChange={() => setUsbMode("CDROM")}
checked={usbMode === "CDROM"}
className="w-3 h-3 text-blue-700 transition-opacity bg-white border-slate-800/30 focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
/>
<span className="ml-2 text-sm font-medium text-slate-900 dark:text-white">
CD/DVD
@ -1561,10 +1578,10 @@ function UsbModeSelector({
disabled
checked={usbMode === "Disk"}
onChange={() => setUsbMode("Disk")}
className="w-3 h-3 text-blue-700 transition-opacity bg-white border-slate-800/30 focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
/>
<div className="flex flex-col ml-2 gap-y-0">
<span className="text-sm font-medium leading-none opacity-50 text-slate-900 dark:text-white">
<div className="ml-2 flex flex-col gap-y-0">
<span className="text-sm font-medium leading-none text-slate-900 opacity-50 dark:text-white">
Disk
</span>
<div className="text-[10px] text-slate-500 dark:text-slate-400">

View File

@ -1,24 +1,23 @@
import { useNavigate, useOutletContext } from "react-router-dom";
import { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
export default function OtherSessionConnectedModal({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
interface ContextType {
connectWebRTC: () => Promise<void>;
}
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
export default function OtherSessionRoute() {
const outletContext = useOutletContext<ContextType>();
const navigate = useNavigate();
// Function to handle closing the modal
const handleClose = () => {
outletContext?.connectWebRTC().then(() => navigate(".."));
};
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
return (
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
<div className="p-10">
@ -37,12 +36,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
this session?
</p>
<div className="flex items-center justify-start space-x-4">
<Button
size="SM"
theme="primary"
text="Use Here"
onClick={() => setOpen(false)}
/>
<Button size="SM" theme="primary" text="Use Here" onClick={handleClose} />
</div>
</div>
</div>

View File

@ -16,6 +16,7 @@ import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import api from "../api";
import { CLOUD_API } from "@/ui.config";
interface LoaderData {
device: { id: string; name: string; user: { googleId: string } };
@ -31,7 +32,7 @@ const action = async ({ params, request }: ActionFunctionArgs) => {
}
try {
const res = await api.PUT(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
const res = await api.PUT(`${CLOUD_API}/devices/${id}`, {
name,
});
if (!res.ok) {
@ -49,7 +50,7 @@ const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = params;
try {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
const res = await fetch(`${CLOUD_API}/devices/${id}`, {
method: "GET",
credentials: "include",
mode: "cors",

View File

@ -0,0 +1,6 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom";
import { getDeviceUiPath } from "../hooks/useAppNavigation";
export function loader({ params }: LoaderFunctionArgs) {
return redirect(getDeviceUiPath("/settings/general", params.id));
}

View File

@ -0,0 +1,335 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { useLoaderData, useNavigate } from "react-router-dom";
import { Button, LinkButton } from "../components/Button";
import { DEVICE_API } from "../ui.config";
import api from "../api";
import { LocalDevice } from "./devices.$id";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { GridCard } from "../components/Card";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import notifications from "../notifications";
import { useCallback, useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { InputFieldWithLabel } from "../components/InputField";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsSectionHeader } from "../components/SettingsSectionHeader";
import { isOnDevice } from "../main";
import { CloudState } from "./adopt";
export const loader = async () => {
if (isOnDevice) {
const status = await api
.GET(`${DEVICE_API}/device`)
.then(res => res.json() as Promise<LocalDevice>);
return status;
}
return null;
};
export default function SettingsAccessIndexRoute() {
const loaderData = useLoaderData() as LocalDevice | null;
const { navigateTo } = useDeviceUiNavigation();
const navigate = useNavigate();
const [send] = useJsonRpc();
const [isAdopted, setAdopted] = useState(false);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [cloudApiUrl, setCloudApiUrl] = useState("");
const [cloudAppUrl, setCloudAppUrl] = useState("");
// Use a simple string identifier for the selected provider
const [selectedProvider, setSelectedProvider] = useState<string>("jetkvm");
const getCloudState = useCallback(() => {
send("getCloudState", {}, resp => {
if ("error" in resp) return console.error(resp.error);
const cloudState = resp.result as CloudState;
setAdopted(cloudState.connected);
setCloudApiUrl(cloudState.url);
if (cloudState.appUrl) setCloudAppUrl(cloudState.appUrl);
// Find if the API URL matches any of our predefined providers
const isAPIJetKVMProd = cloudState.url === "https://api.jetkvm.com";
const isAppJetKVMProd = cloudState.appUrl === "https://app.jetkvm.com";
if (isAPIJetKVMProd && isAppJetKVMProd) {
setSelectedProvider("jetkvm");
} else {
setSelectedProvider("custom");
}
});
}, [send]);
const deregisterDevice = async () => {
send("deregisterDevice", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
);
return;
}
getCloudState();
// In cloud mode, we need to navigate to the device overview page, as we don't a connection anymore
if (!isOnDevice) navigate("/");
return;
});
};
const onCloudAdoptClick = useCallback(
(cloudApiUrl: string, cloudAppUrl: string) => {
if (!deviceId) {
notifications.error("No device ID available");
return;
}
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
);
return;
}
const returnTo = new URL(window.location.href);
returnTo.pathname = "/adopt";
returnTo.search = "";
returnTo.hash = "";
window.location.href =
cloudAppUrl +
"/signup?deviceId=" +
deviceId +
`&returnTo=${returnTo.toString()}`;
});
},
[deviceId, send],
);
// Handle provider selection change
const handleProviderChange = (value: string) => {
setSelectedProvider(value);
// If selecting a predefined provider, update both URLs
if (value === "jetkvm") {
setCloudApiUrl("https://api.jetkvm.com");
setCloudAppUrl("https://app.jetkvm.com");
} else {
if (cloudApiUrl || cloudAppUrl) return;
setCloudApiUrl("");
setCloudAppUrl("");
}
};
// Fetch device ID and cloud state on component mount
useEffect(() => {
getCloudState();
send("getDeviceID", {}, async resp => {
if ("error" in resp) return console.error(resp.error);
setDeviceId(resp.result as string);
});
}, [send, getCloudState]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Access"
description="Manage the Access Control of the device"
/>
{loaderData?.authMode && (
<>
<div className="space-y-4">
<SettingsSectionHeader
title="Local"
description="Manage the mode of local access to the device"
/>
<SettingsItem
title="Authentication Mode"
description={`Current mode: ${loaderData.authMode === "password" ? "Password protected" : "No password"}`}
>
{loaderData.authMode === "password" ? (
<Button
size="SM"
theme="light"
text="Disable Protection"
onClick={() => {
navigateTo("./local-auth", { state: { init: "deletePassword" } });
}}
/>
) : (
<Button
size="SM"
theme="light"
text="Enable Password"
onClick={() => {
navigateTo("./local-auth", { state: { init: "createPassword" } });
}}
/>
)}
</SettingsItem>
{loaderData.authMode === "password" && (
<SettingsItem
title="Change Password"
description="Update your device access password"
>
<Button
size="SM"
theme="light"
text="Change Password"
onClick={() => {
navigateTo("./local-auth", { state: { init: "updatePassword" } });
}}
/>
</SettingsItem>
)}
</div>
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
</>
)}
<div className="space-y-4">
<SettingsSectionHeader
title="Remote"
description="Manage the mode of Remote access to the device"
/>
<div className="space-y-4">
{!isAdopted && (
<>
<SettingsItem
title="Cloud Provider"
description="Select the cloud provider for your device"
>
<SelectMenuBasic
size="SM"
value={selectedProvider}
onChange={e => handleProviderChange(e.target.value)}
options={[
{ value: "jetkvm", label: "JetKVM Cloud" },
{ value: "custom", label: "Custom" },
]}
/>
</SettingsItem>
{selectedProvider === "custom" && (
<div className="mt-4 space-y-4">
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud API URL"
value={cloudApiUrl}
onChange={e => setCloudApiUrl(e.target.value)}
placeholder="https://api.example.com"
/>
</div>
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud App URL"
value={cloudAppUrl}
onChange={e => setCloudAppUrl(e.target.value)}
placeholder="https://app.example.com"
/>
</div>
</div>
)}
</>
)}
{/* Show security info for JetKVM Cloud */}
{selectedProvider === "jetkvm" && (
<GridCard>
<div className="flex items-start gap-x-4 p-4">
<ShieldCheckIcon className="mt-1 h-8 w-8 shrink-0 text-blue-600 dark:text-blue-500" />
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Cloud Security
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>End-to-end encryption using WebRTC (DTLS and SRTP)</li>
<li>Zero Trust security model</li>
<li>OIDC (OpenID Connect) authentication</li>
<li>All streams encrypted in transit</li>
</ul>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
All cloud components are open-source and available on{" "}
<a
href="https://github.com/jetkvm"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
>
GitHub
</a>
.
</div>
</div>
<hr className="block w-full dark:border-slate-600" />
<div>
<LinkButton
to="https://jetkvm.com/docs/networking/remote-access"
size="SM"
theme="light"
text="Learn about our cloud security"
/>
</div>
</div>
</div>
</GridCard>
)}
{!isAdopted ? (
<div className="flex items-end gap-x-2">
<Button
onClick={() => onCloudAdoptClick(cloudApiUrl, cloudAppUrl)}
size="SM"
theme="primary"
text="Adopt KVM to Cloud"
/>
</div>
) : (
<div>
<div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-300">
Your device is adopted to the Cloud
</p>
<div>
<Button
size="SM"
theme="light"
text="De-register from Cloud"
className="text-red-600"
onClick={() => {
if (deviceId) {
if (
window.confirm(
"Are you sure you want to de-register this device?",
)
) {
deregisterDevice();
}
} else {
notifications.error("No device ID available");
}
}}
/>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -1,30 +1,35 @@
import { GridCard } from "@/components/Card";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { InputFieldWithLabel } from "./InputField";
import { InputFieldWithLabel } from "@/components/InputField";
import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores";
import { useLocation, useRevalidator } from "react-router-dom";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function LocalAuthPasswordDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
export default function SecurityAccessLocalAuthRoute() {
const { setModalView } = useLocalAuthModalStore();
const { navigateTo } = useDeviceUiNavigation();
const location = useLocation();
const init = location.state?.init;
useEffect(() => {
if (!init) {
navigateTo("..");
} else {
setModalView(init);
}
}, [init, navigateTo, setModalView]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigateTo("..")} />;
}
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
export function Dialog({ onClose }: { onClose: () => void }) {
const { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null);
const revalidator = useRevalidator();
const handleCreatePassword = async (password: string, confirmPassword: string) => {
if (password === "") {
@ -41,6 +46,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const res = await api.POST("/auth/password-local", { password });
if (res.ok) {
setModalView("creationSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while setting the password");
@ -78,6 +85,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
if (res.ok) {
setModalView("updateSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while changing the password");
@ -97,6 +106,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const res = await api.DELETE("/auth/local-password", { password });
if (res.ok) {
setModalView("deleteSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while disabling the password");
@ -107,12 +118,12 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
};
return (
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
<div className="p-10">
<div>
<div>
{modalView === "createPassword" && (
<CreatePasswordModal
onSetPassword={handleCreatePassword}
onCancel={() => setOpen(false)}
onCancel={onClose}
error={error}
/>
)}
@ -120,7 +131,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
{modalView === "deletePassword" && (
<DeletePasswordModal
onDeletePassword={handleDeletePassword}
onCancel={() => setOpen(false)}
onCancel={onClose}
error={error}
/>
)}
@ -128,7 +139,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
{modalView === "updatePassword" && (
<UpdatePasswordModal
onUpdatePassword={handleUpdatePassword}
onCancel={() => setOpen(false)}
onCancel={onClose}
error={error}
/>
)}
@ -137,7 +148,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<SuccessModal
headline="Password Set Successfully"
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
onClose={() => setOpen(false)}
onClose={onClose}
/>
)}
@ -145,7 +156,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<SuccessModal
headline="Password Protection Disabled"
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
onClose={() => setOpen(false)}
onClose={onClose}
/>
)}
@ -153,11 +164,11 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<SuccessModal
headline="Password Updated Successfully"
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
onClose={() => setOpen(false)}
onClose={onClose}
/>
)}
</div>
</GridCard>
</div>
);
}
@ -175,13 +186,16 @@ function CreatePasswordModal({
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<form
className="space-y-4"
onSubmit={e => {
e.preventDefault();
}}
>
<div>
<h2 className="text-lg font-semibold dark:text-white">Local Device Protection</h2>
<h2 className="text-lg font-semibold dark:text-white">
Local Device Protection
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Create a password to protect your device from unauthorized local access.
</p>
@ -191,6 +205,7 @@ function CreatePasswordModal({
type="password"
placeholder="Enter a strong password"
value={password}
autoFocus
onChange={e => setPassword(e.target.value)}
/>
<InputFieldWithLabel
@ -211,7 +226,7 @@ function CreatePasswordModal({
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</form>
</div>
);
}
@ -229,13 +244,11 @@ function DeletePasswordModal({
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Disable Local Device Protection</h2>
<h2 className="text-lg font-semibold dark:text-white">
Disable Local Device Protection
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password to disable local device protection.
</p>
@ -281,13 +294,16 @@ function UpdatePasswordModal({
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<form
className="space-y-4"
onSubmit={e => {
e.preventDefault();
}}
>
<div>
<h2 className="text-lg font-semibold dark:text-white">Change Local Device Password</h2>
<h2 className="text-lg font-semibold dark:text-white">
Change Local Device Password
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password and a new password to update your local device
protection.
@ -324,7 +340,7 @@ function UpdatePasswordModal({
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</form>
</div>
);
}
@ -339,11 +355,7 @@ function SuccessModal({
onClose: () => void;
}) {
return (
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="flex w-full max-w-lg flex-col items-start justify-start space-y-4 text-left">
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>

View File

@ -0,0 +1,263 @@
import { SettingsItem } from "./devices.$id.settings";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import Checkbox from "../components/Checkbox";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { useCallback, useState, useEffect } from "react";
import notifications from "../notifications";
import { TextAreaWithLabel } from "../components/TextArea";
import { isOnDevice } from "../main";
import { Button } from "../components/Button";
import { useSettingsStore } from "../hooks/stores";
import { GridCard } from "@components/Card";
export default function SettingsAdvancedRoute() {
const [send] = useJsonRpc();
const [sshKey, setSSHKey] = useState<string>("");
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const [devChannel, setDevChannel] = useState(false);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const settings = useSettingsStore();
useEffect(() => {
send("getDevModeState", {}, resp => {
if ("error" in resp) return;
const result = resp.result as { enabled: boolean };
setDeveloperMode(result.enabled);
});
send("getSSHKeyState", {}, resp => {
if ("error" in resp) return;
setSSHKey(resp.result as string);
});
send("getUsbEmulationState", {}, resp => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
send("getDevChannelState", {}, resp => {
if ("error" in resp) return;
setDevChannel(resp.result as boolean);
});
}, [send, setDeveloperMode]);
const getUsbEmulationState = useCallback(() => {
send("getUsbEmulationState", {}, resp => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
}, [send]);
const handleUsbEmulationToggle = useCallback(
(enabled: boolean) => {
send("setUsbEmulationState", { enabled: enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
);
return;
}
setUsbEmulationEnabled(enabled);
getUsbEmulationState();
});
},
[getUsbEmulationState, send],
);
const handleResetConfig = useCallback(() => {
send("resetConfig", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Configuration reset to default successfully");
});
}, [send]);
const handleUpdateSSHKey = useCallback(() => {
send("setSSHKeyState", { sshKey }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("SSH key updated successfully");
});
}, [send, sshKey]);
const handleDevModeChange = useCallback(
(developerMode: boolean) => {
send("setDevModeState", { enabled: developerMode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDeveloperMode(developerMode);
});
},
[send, setDeveloperMode],
);
const handleDevChannelChange = (enabled: boolean) => {
send("setDevChannelState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDevChannel(enabled);
});
};
return (
<div className="space-y-4">
<SettingsPageHeader
title="Advanced"
description="Access additional settings for troubleshooting and customization"
/>
<div className="space-y-4">
<SettingsItem
title="Dev Channel Updates"
description="Receive early updates from the development channel"
>
<Checkbox
checked={devChannel}
onChange={e => {
handleDevChannelChange(e.target.checked);
}}
/>
</SettingsItem>
<SettingsItem
title="Developer Mode"
description="Enable advanced features for developers"
>
<Checkbox
checked={settings.developerMode}
onChange={e => handleDevModeChange(e.target.checked)}
/>
</SettingsItem>
{settings.developerMode && (
<GridCard>
<div className="flex select-none items-start gap-x-4 p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
>
<path
fillRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clipRule="evenodd"
/>
</svg>
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Developer Mode Enabled
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>Security is weakened while active</li>
<li>Only use if you understand the risks</li>
</ul>
</div>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
For advanced users only. Not for production use.
</div>
</div>
</div>
</GridCard>
)}
{isOnDevice && settings.developerMode && (
<div className="space-y-4">
<SettingsItem
title="SSH Access"
description="Add your SSH public key to enable secure remote access to the device"
/>
<div className="space-y-4">
<TextAreaWithLabel
label="SSH Public Key"
value={sshKey || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder="Enter your SSH public key"
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
The default SSH user is <strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Update SSH Key"
onClick={handleUpdateSSHKey}
/>
</div>
</div>
</div>
)}
<SettingsItem
title="Troubleshooting Mode"
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
>
<Checkbox
defaultChecked={settings.debugMode}
onChange={e => {
settings.setDebugMode(e.target.checked);
}}
/>
</SettingsItem>
{settings.debugMode && (
<>
<SettingsItem
title="USB Emulation"
description="Control the USB emulation state"
>
<Button
size="SM"
theme="light"
text={
usbEmulationEnabled ? "Disable USB Emulation" : "Enable USB Emulation"
}
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
/>
</SettingsItem>
<SettingsItem
title="Reset Configuration"
description="Reset configuration to default. This will log you out."
>
<Button
size="SM"
theme="light"
text="Reset Config"
onClick={() => {
handleResetConfig();
window.location.reload();
}}
/>
</SettingsItem>
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,53 @@
import { useCallback, useState } from "react";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsAppearanceRoute() {
const [currentTheme, setCurrentTheme] = useState(() => {
return localStorage.theme || "system";
});
const handleThemeChange = useCallback((value: string) => {
const root = document.documentElement;
if (value === "system") {
localStorage.removeItem("theme");
// Check system preference
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
root.classList.remove("light", "dark");
root.classList.add(systemTheme);
} else {
localStorage.theme = value;
root.classList.remove("light", "dark");
root.classList.add(value);
}
}, []);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Appearance"
description="Customize the look and feel of your JetKVM interface"
/>
<SettingsItem title="Theme" description="Choose your preferred color theme">
<SelectMenuBasic
size="SM"
label=""
value={currentTheme}
options={[
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
onChange={e => {
setCurrentTheme(e.target.value);
handleThemeChange(e.target.value);
}}
/>
</SettingsItem>
</div>
);
}

View File

@ -0,0 +1,97 @@
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { useState } from "react";
import { useEffect } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "../components/Button";
import notifications from "../notifications";
import Checkbox from "../components/Checkbox";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { useDeviceStore } from "../hooks/stores";
export default function SettingsGeneralRoute() {
const [send] = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const [autoUpdate, setAutoUpdate] = useState(true);
const currentVersions = useDeviceStore(state => {
const { appVersion, systemVersion } = state;
if (!appVersion || !systemVersion) return null;
return { appVersion, systemVersion };
});
useEffect(() => {
send("getAutoUpdateState", {}, resp => {
if ("error" in resp) return;
setAutoUpdate(resp.result as boolean);
});
}, [send]);
const handleAutoUpdateChange = (enabled: boolean) => {
send("setAutoUpdateState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,
);
return;
}
setAutoUpdate(enabled);
});
};
return (
<div className="space-y-4">
<SettingsPageHeader
title="General"
description="Configure device settings and update preferences"
/>
<div className="space-y-4">
<div className="space-y-4 pb-2">
<div className="mt-2 flex items-center justify-between gap-x-2">
<SettingsItem
title="Check for Updates"
description={
currentVersions ? (
<>
App: {currentVersions.appVersion}
<br />
System: {currentVersions.systemVersion}
</>
) : (
<>
App: Loading...
<br />
System: Loading...
</>
)
}
/>
<div>
<Button
size="SM"
theme="light"
text="Check for Updates"
onClick={() => navigateTo("./update")}
/>
</div>
</div>
<div className="space-y-4">
<SettingsItem
title="Auto Update"
description="Automatically update the device to the latest version"
>
<Checkbox
checked={autoUpdate}
onChange={e => {
handleAutoUpdateChange(e.target.checked);
}}
/>
</SettingsItem>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,14 +1,44 @@
import Card, { GridCard } from "@/components/Card";
import { useLocation, useNavigate } from "react-router-dom";
import Card from "@/components/Card";
import { useCallback, useEffect, useRef, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { UpdateState, useUpdateStore } from "@/hooks/stores";
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "./LoadingSpinner";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate();
const location = useLocation();
const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
setModalView("updating");
}, [send, setModalView]);
useEffect(() => {
if (otaState.updating) {
setModalView("updating");
} else if (otaState.error) {
setModalView("error");
} else if (updateSuccess) {
setModalView("updateCompleted");
} else {
setModalView("loading");
}
}, [otaState.updating, otaState.error, setModalView, updateSuccess]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
}
export interface SystemVersionInfo {
local: { appVersion: string; systemVersion: string };
@ -17,37 +47,15 @@ export interface SystemVersionInfo {
appUpdateAvailable: boolean;
}
export default function UpdateDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
// We need to keep track of the update state in the dialog even if the dialog is minimized
const { setModalView } = useUpdateStore();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
setModalView("updating");
}, [send, setModalView]);
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} onConfirmUpdate={onConfirmUpdate} />
</Modal>
);
}
export function Dialog({
setOpen,
onClose,
onConfirmUpdate,
}: {
setOpen: (open: boolean) => void;
onClose: () => void;
onConfirmUpdate: () => void;
}) {
const { navigateTo } = useDeviceUiNavigation();
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore();
@ -73,27 +81,24 @@ export function Dialog({
}, [setModalView]);
return (
<GridCard cardClassName="mx-auto relative max-w-md text-left pointer-events-auto">
<div className="p-10">
<div className="pointer-events-auto relative mx-auto text-left">
<div>
{modalView === "error" && (
<UpdateErrorState
errorMessage={otaState.error}
onClose={() => setOpen(false)}
onClose={onClose}
onRetryUpdate={() => setModalView("loading")}
/>
)}
{modalView === "loading" && (
<LoadingState
onFinished={onFinishedLoading}
onCancelCheck={() => setOpen(false)}
/>
<LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} />
)}
{modalView === "updateAvailable" && (
<UpdateAvailableState
onConfirmUpdate={onConfirmUpdate}
onClose={() => setOpen(false)}
onClose={onClose}
versionInfo={versionInfo!}
/>
)}
@ -101,24 +106,20 @@ export function Dialog({
{modalView === "updating" && (
<UpdatingDeviceState
otaState={otaState}
onMinimizeUpgradeDialog={() => {
setOpen(false);
}}
onMinimizeUpgradeDialog={() => navigateTo("/")}
/>
)}
{modalView === "upToDate" && (
<SystemUpToDateState
checkUpdate={() => setModalView("loading")}
onClose={() => setOpen(false)}
onClose={onClose}
/>
)}
{modalView === "updateCompleted" && (
<UpdateCompletedState onClose={() => setOpen(false)} />
)}
{modalView === "updateCompleted" && <UpdateCompletedState onClose={onClose} />}
</div>
</GridCard>
</div>
);
}
@ -133,6 +134,9 @@ function LoadingState({
const abortControllerRef = useRef<AbortController | null>(null);
const [send] = useJsonRpc();
const setAppVersion = useDeviceStore(state => state.setAppVersion);
const setSystemVersion = useDeviceStore(state => state.setSystemVersion);
const getVersionInfo = useCallback(() => {
return new Promise<SystemVersionInfo>((resolve, reject) => {
send("getUpdateStatus", {}, async resp => {
@ -141,11 +145,13 @@ function LoadingState({
reject(new Error("Failed to check for updates"));
} else {
const result = resp.result as SystemVersionInfo;
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
resolve(result);
}
});
});
}, [send]);
}, [send, setAppVersion, setSystemVersion]);
const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -156,14 +162,12 @@ function LoadingState({
const animationTimer = setTimeout(() => {
setProgressWidth("100%");
}, 500);
}, 0);
getVersionInfo()
.then(versionInfo => {
if (progressBarRef.current) {
progressBarRef.current?.classList.add("!duration-1000");
}
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 1000));
// Add a small delay to ensure it's not just flickering
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 600));
})
.then(versionInfo => {
if (!signal.aborted) {
@ -183,12 +187,8 @@ function LoadingState({
}, [getVersionInfo, onFinished]);
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="max-w-sm space-y-4">
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
Checking for updates...
@ -201,18 +201,11 @@ function LoadingState({
<div
ref={progressBarRef}
style={{ width: progressWidth }}
className="h-2.5 bg-blue-700 transition-all duration-[4s] ease-in-out"
className="h-2.5 bg-blue-700 transition-all duration-1000 ease-in-out"
></div>
</div>
<div className="mt-4">
<Button
size="SM"
theme="light"
text="Cancel"
onClick={() => {
onCancelCheck();
}}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancelCheck} />
</div>
</div>
</div>
@ -294,11 +287,7 @@ function UpdatingDeviceState({
};
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="w-full max-w-sm space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
@ -308,10 +297,10 @@ function UpdatingDeviceState({
Please don{"'"}t turn off your device. This process may take a few minutes.
</p>
</div>
<Card className="p-4 space-y-4">
<Card className="space-y-4 p-4">
{areAllUpdatesComplete() ? (
<div className="flex flex-col items-center my-2 space-y-2 text-center">
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
<div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span className="font-medium text-black dark:text-white">
Rebooting to complete the update...
@ -321,8 +310,8 @@ function UpdatingDeviceState({
) : (
<>
{!(otaState.systemUpdatePending || otaState.appUpdatePending) && (
<div className="flex flex-col items-center my-2 space-y-2 text-center">
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
<div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
</div>
)}
@ -333,9 +322,9 @@ function UpdatingDeviceState({
Linux System Update
</p>
{calculateOverallProgress("system") < 100 ? (
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" />
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
) : (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
<CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
)}
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
@ -365,9 +354,9 @@ function UpdatingDeviceState({
App Update
</p>
{calculateOverallProgress("app") < 100 ? (
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" />
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
) : (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
<CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
)}
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
@ -390,7 +379,7 @@ function UpdatingDeviceState({
</>
)}
</Card>
<div className="flex justify-start mt-4 text-white gap-x-2">
<div className="mt-4 flex justify-start gap-x-2 text-white">
<Button
size="XS"
theme="light"
@ -411,11 +400,7 @@ function SystemUpToDateState({
onClose: () => void;
}) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
System is up to date
@ -424,23 +409,9 @@ function SystemUpToDateState({
Your system is running the latest version. No updates are currently available.
</p>
<div className="flex mt-4 gap-x-2">
<Button
size="SM"
theme="light"
text="Check Again"
onClick={() => {
checkUpdate();
}}
/>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
onClose();
}}
/>
<div className="mt-4 flex gap-x-2">
<Button size="SM" theme="light" text="Check Again" onClick={checkUpdate} />
<Button size="SM" theme="blank" text="Back" onClick={onClose} />
</div>
</div>
</div>
@ -457,11 +428,7 @@ function UpdateAvailableState({
onClose: () => void;
}) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
Update available
@ -495,11 +462,7 @@ function UpdateAvailableState({
function UpdateCompletedState({ onClose }: { onClose: () => void }) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold dark:text-white">
Update Completed Successfully
@ -509,7 +472,7 @@ function UpdateCompletedState({ onClose }: { onClose: () => void }) {
features and improvements!
</p>
<div className="flex items-center justify-start">
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
<Button size="SM" theme="primary" text="Back" onClick={onClose} />
</div>
</div>
</div>
@ -526,11 +489,7 @@ function UpdateErrorState({
onRetryUpdate: () => void;
}) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold dark:text-white">Update Error</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
@ -542,8 +501,8 @@ function UpdateErrorState({
</p>
)}
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
<Button size="SM" theme="primary" text="Retry" onClick={onRetryUpdate} />
<Button size="SM" theme="light" text="Back" onClick={onClose} />
<Button size="SM" theme="blank" text="Retry" onClick={onRetryUpdate} />
</div>
</div>
</div>

View File

@ -0,0 +1,142 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "@routes/devices.$id.settings";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { useEffect } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../notifications";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbConfigSetting } from "../components/UsbConfigSetting";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { FeatureFlag } from "../components/FeatureFlag";
export default function SettingsHardwareRoute() {
const [send] = useJsonRpc();
const settings = useSettingsStore();
const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings);
const handleBacklightSettingsChange = (settings: BacklightSettings) => {
// If the user has set the display to dim after it turns off, set the dim_after
// value to never.
if (settings.dim_after > settings.off_after && settings.off_after != 0) {
settings.dim_after = 0;
}
setBacklightSettings(settings);
handleBacklightSettingsSave();
};
const handleBacklightSettingsSave = () => {
send("setBacklightSettings", { params: settings.backlightSettings }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Backlight settings updated successfully");
});
};
useEffect(() => {
send("getBacklightSettings", {}, resp => {
if ("error" in resp) {
return notifications.error(
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
);
}
const result = resp.result as BacklightSettings;
setBacklightSettings(result);
});
}, [send, setBacklightSettings]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Hardware"
description="Configure display settings and hardware options for your JetKVM device"
/>
<div className="space-y-4">
<SettingsItem
title="Display Brightness"
description="Set the brightness of the display"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.max_brightness.toString()}
options={[
{ value: "0", label: "Off" },
{ value: "10", label: "Low" },
{ value: "35", label: "Medium" },
{ value: "64", label: "High" },
]}
onChange={e => {
settings.backlightSettings.max_brightness = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</SettingsItem>
{settings.backlightSettings.max_brightness != 0 && (
<>
<SettingsItem
title="Dim Display After"
description="Set how long to wait before dimming the display"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.dim_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "60", label: "1 Minute" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.dim_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</SettingsItem>
<SettingsItem
title="Turn off Display After"
description="Period of inactivity before display automatically turns off"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.off_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.off_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</SettingsItem>
</>
)}
<p className="text-xs text-slate-600 dark:text-slate-400">
The display will wake up when the connection state changes, or when touched.
</p>
</div>
<FeatureFlag minAppVersion="0.3.8">
<UsbConfigSetting />
</FeatureFlag>
<FeatureFlag minAppVersion="0.3.8">
<UsbDeviceSetting />
</FeatureFlag>
</div>
);
}

View File

@ -0,0 +1,181 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { Checkbox } from "@/components/Checkbox";
import { GridCard } from "@/components/Card";
import PointingFinger from "@/assets/pointing-finger.svg";
import { CheckCircleIcon } from "@heroicons/react/16/solid";
import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { useCallback, useEffect, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { cx } from "../cva.config";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
type ScrollSensitivity = "low" | "default" | "high";
export default function SettingsKeyboardMouseRoute() {
const hideCursor = useSettingsStore(state => state.isCursorHidden);
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
const scrollSensitivity = useDeviceSettingsStore(state => state.scrollSensitivity);
const setScrollSensitivity = useDeviceSettingsStore(
state => state.setScrollSensitivity,
);
const [jiggler, setJiggler] = useState(false);
const [send] = useJsonRpc();
useEffect(() => {
send("getJigglerState", {}, resp => {
if ("error" in resp) return;
setJiggler(resp.result as boolean);
});
send("getScrollSensitivity", {}, resp => {
if ("error" in resp) return;
setScrollSensitivity(resp.result as ScrollSensitivity);
});
}, [send, setScrollSensitivity]);
const handleJigglerChange = (enabled: boolean) => {
send("setJigglerState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setJiggler(enabled);
});
};
const onScrollSensitivityChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const sensitivity = e.target.value as ScrollSensitivity;
send("setScrollSensitivity", { sensitivity }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set scroll sensitivity: ${resp.error.data || "Unknown error"}`,
);
}
notifications.success("Scroll sensitivity set successfully");
setScrollSensitivity(sensitivity);
});
},
[send, setScrollSensitivity],
);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Mouse"
description="Configure cursor behavior and interaction settings for your device"
/>
<div className="space-y-4">
<SettingsItem
title="Hide Cursor"
description="Hide the cursor when sending mouse movements"
>
<Checkbox
checked={hideCursor}
onChange={e => setHideCursor(e.target.checked)}
/>
</SettingsItem>
<SettingsItem
title="Scroll Sensitivity"
description="Adjust the scroll sensitivity"
>
<SelectMenuBasic
size="SM"
label=""
fullWidth
value={scrollSensitivity}
onChange={onScrollSensitivityChange}
options={
[
{ label: "Low", value: "low" },
{ label: "Default", value: "default" },
{ label: "High", value: "high" },
] as { label: string; value: ScrollSensitivity }[]
}
/>
</SettingsItem>
<SettingsItem
title="Jiggler"
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
>
<Checkbox
checked={jiggler}
onChange={e => handleJigglerChange(e.target.checked)}
/>
</SettingsItem>
<div className="space-y-4">
<SettingsItem title="Modes" description="Choose the mouse input mode" />
<div className="flex flex-col items-center gap-4 md:flex-row">
<button
className="group block w-full grow"
onClick={() => console.log("Absolute mouse mode clicked")}
>
<GridCard>
<div className="group flex items-center gap-x-4 px-4 py-3">
<img
className="w-6 shrink-0 dark:invert"
src={PointingFinger}
alt="Finger touching a screen"
/>
<div className="flex grow items-center justify-between">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Absolute
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most convenient
</p>
</div>
<CheckCircleIcon
className={cx(
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
)}
/>
</div>
</div>
</GridCard>
</button>
<button
className="group block w-full grow cursor-not-allowed opacity-50"
disabled
>
<GridCard>
<div className="group flex items-center gap-x-4 px-4 py-3">
<img
className="w-6 shrink-0 dark:invert"
src={PointingFinger}
alt="Finger touching a screen"
/>
<div className="flex grow items-center justify-between">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Relative
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most Compatible
</p>
</div>
<CheckCircleIcon
className={cx(
"hidden",
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
)}
/>
</div>
</div>
</GridCard>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,254 @@
import { NavLink, Outlet, useLocation } from "react-router-dom";
import Card from "@/components/Card";
import {
LuSettings,
LuKeyboard,
LuVideo,
LuCpu,
LuShieldCheck,
LuWrench,
LuArrowLeft,
LuPalette,
} from "react-icons/lu";
import { LinkButton } from "../components/Button";
import React, { useEffect, useRef, useState } from "react";
import { cx } from "../cva.config";
import { useUiStore } from "../hooks/stores";
import useKeyboard from "../hooks/useKeyboard";
import { useResizeObserver } from "../hooks/useResizeObserver";
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
export default function SettingsRoute() {
const location = useLocation();
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { sendKeyboardEvent } = useKeyboard();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const { width } = useResizeObserver({ ref: scrollContainerRef });
// Handle scroll position to show/hide gradients
const handleScroll = () => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
// Show left gradient only if scrolled to the right
setShowLeftGradient(scrollLeft > 0);
// Show right gradient only if there's more content to scroll to the right
setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); // -1 for rounding errors
}
};
useEffect(() => {
// Check initial scroll position
handleScroll();
// Add scroll event listener to the container
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
scrollContainer.addEventListener("scroll", handleScroll);
}
return () => {
// Clean up event listener
if (scrollContainer) {
scrollContainer.removeEventListener("scroll", handleScroll);
}
};
}, [width]);
useEffect(() => {
// disable focus trap
setTimeout(() => {
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
sendKeyboardEvent([], []);
setDisableVideoFocusTrap(true);
// For some reason, the focus trap is not disabled immediately
// so we need to blur the active element
(document.activeElement as HTMLElement)?.blur();
console.log("Just disabled focus trap");
}, 300);
return () => {
setDisableVideoFocusTrap(false);
};
}, [setDisableVideoFocusTrap, sendKeyboardEvent]);
return (
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
<div className="h-full">
<div className="w-full gap-x-8 gap-y-4 space-y-4 md:grid md:grid-cols-8 md:space-y-0">
<div className="w-full select-none space-y-4 md:col-span-2">
<Card className="flex w-full gap-x-4 overflow-hidden p-2 md:flex-col dark:bg-slate-800">
<div className="md:hidden">
<LinkButton
to=".."
size="SM"
theme="blank"
text="Back to KVM"
LeadingIcon={LuArrowLeft}
textAlign="left"
/>
</div>
<div className="hidden md:block">
<LinkButton
to=".."
size="SM"
theme="blank"
text="Back to KVM"
LeadingIcon={LuArrowLeft}
textAlign="left"
fullWidth
/>
</div>
</Card>
<Card className="relative overflow-hidden">
{/* Gradient overlay for left side - only visible on mobile when scrolled */}
<div
className={cx(
"pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-white to-transparent transition-opacity duration-300 ease-in-out md:hidden dark:from-slate-900",
{
"opacity-0": !showLeftGradient,
"opacity-100": showLeftGradient,
},
)}
></div>
{/* Gradient overlay for right side - only visible on mobile when there's more content */}
<div
className={cx(
"pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-white to-transparent transition duration-300 ease-in-out md:hidden dark:from-slate-900",
{
"opacity-0": !showRightGradient,
"opacity-100": showRightGradient,
},
)}
></div>
<div
ref={scrollContainerRef}
className="hide-scrollbar relative flex w-full gap-x-4 overflow-x-auto whitespace-nowrap p-2 md:flex-col md:overflow-visible md:whitespace-normal dark:bg-slate-800"
>
<div className="shrink-0">
<NavLink
to="general"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuSettings className="h-4 w-4 shrink-0" />
<h1>General</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="mouse"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuKeyboard className="h-4 w-4 shrink-0" />
<h1>Mouse</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="video"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuVideo className="h-4 w-4 shrink-0" />
<h1>Video</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="hardware"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuCpu className="h-4 w-4 shrink-0" />
<h1>Hardware</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="access"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuShieldCheck className="h-4 w-4 shrink-0" />
<h1>Access</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="appearance"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuPalette className="h-4 w-4 shrink-0" />
<h1>Appearance</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="advanced"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuWrench className="h-4 w-4 shrink-0" />
<h1>Advanced</h1>
</div>
</NavLink>
</div>
</div>
</Card>
</div>
<div className="w-full md:col-span-5">
{/* <AutoHeight> */}
<Card className="dark:bg-slate-800">
<div
className="space-y-4 px-8 py-6"
style={{ animationDuration: "0.7s" }}
key={location.pathname} // This is a workaround to force the animation to run when the route changes
>
<Outlet />
</div>
</Card>
{/* </AutoHeight> */}
</div>
</div>
</div>
</div>
);
}
export function SettingsItem({
title,
description,
children,
className,
}: {
title: string;
description: string | React.ReactNode;
children?: React.ReactNode;
className?: string;
name?: string;
}) {
return (
<label
className={cx(
"flex select-none items-center justify-between gap-x-8 rounded",
className,
)}
>
<div className="space-y-0.5">
<h3 className="text-base font-semibold text-black dark:text-white">{title}</h3>
<p className="text-sm text-slate-700 dark:text-slate-300">{description}</p>
</div>
{children ? <div>{children}</div> : null}
</label>
);
}

View File

@ -0,0 +1,185 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { Button } from "@/components/Button";
import { TextAreaWithLabel } from "@/components/TextArea";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useState, useEffect } from "react";
import notifications from "../notifications";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
const edids = [
{
value: defaultEdid,
label: "JetKVM Default",
},
{
value:
"00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
label: "Acer B246WL, 1920x1200",
},
{
value:
"00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC",
label: "ASUS PA248QV, 1920x1200",
},
{
value:
"00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F",
label: "DELL D2721H, 1920x1080",
},
];
const streamQualityOptions = [
{ value: "1", label: "High" },
{ value: "0.5", label: "Medium" },
{ value: "0.1", label: "Low" },
];
export default function SettingsVideoRoute() {
const [send] = useJsonRpc();
const [streamQuality, setStreamQuality] = useState("1");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null);
useEffect(() => {
send("getStreamQualityFactor", {}, resp => {
if ("error" in resp) return;
setStreamQuality(String(resp.result));
});
send("getEDID", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
return;
}
const receivedEdid = resp.result as string;
const matchingEdid = edids.find(
x => x.value.toLowerCase() === receivedEdid.toLowerCase(),
);
if (matchingEdid) {
// EDID is stored in uppercase in the UI
setEdid(matchingEdid.value.toUpperCase());
// Reset custom EDID value
setCustomEdidValue(null);
} else {
setEdid("custom");
setCustomEdidValue(receivedEdid);
}
});
}, [send]);
const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success(`Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`);
setStreamQuality(factor);
});
};
const handleEDIDChange = (newEdid: string) => {
send("setEDID", { edid: newEdid }, resp => {
if ("error" in resp) {
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
return;
}
notifications.success(
`EDID set successfully to ${edids.find(x => x.value === newEdid)?.label}`,
);
// Update the EDID value in the UI
setEdid(newEdid);
});
};
return (
<div className="space-y-3">
<div className="space-y-4">
<SettingsPageHeader
title="Video"
description="Configure display settings and EDID for optimal compatibility"
/>
<div className="space-y-4">
<div className="space-y-4">
<SettingsItem
title="Stream Quality"
description="Adjust the quality of the video stream"
>
<SelectMenuBasic
size="SM"
label=""
value={streamQuality}
options={streamQualityOptions}
onChange={e => handleStreamQualityChange(e.target.value)}
/>
</SettingsItem>
<SettingsItem
title="EDID"
description="Adjust the EDID settings for the display"
>
<SelectMenuBasic
size="SM"
label=""
fullWidth
value={customEdidValue ? "custom" : edid || "asd"}
onChange={e => {
if (e.target.value === "custom") {
setEdid("custom");
setCustomEdidValue("");
} else {
setCustomEdidValue(null);
handleEDIDChange(e.target.value as string);
}
}}
options={[...edids, { value: "custom", label: "Custom" }]}
/>
</SettingsItem>
{customEdidValue !== null && (
<>
<SettingsItem
title="Custom EDID"
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
/>
<TextAreaWithLabel
label="EDID File"
placeholder="00F..."
rows={3}
value={customEdidValue}
onChange={e => setCustomEdidValue(e.target.value)}
/>
<div className="flex justify-start gap-x-2">
<Button
size="SM"
theme="primary"
text="Set Custom EDID"
onClick={() => handleEDIDChange(customEdidValue)}
/>
<Button
size="SM"
theme="light"
text="Restore to default"
onClick={() => {
setCustomEdidValue(null);
handleEDIDChange(defaultEdid);
}}
/>
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}

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