Compare commits

...

146 Commits

Author SHA1 Message Date
William Johnstone f753a7ac08
Merge remote-tracking branch 'upstream/dev' into keyboard-layouts 2025-04-12 17:11:04 +01:00
William Johnstone e53445067e
Update macros to use new keyboard implementation, add keyboard settings to new settings page, update PasteModal to use new keyboard implemention, dropped spanish mappings. 2025-04-12 17:01:43 +01:00
William Johnstone 95c14102cd
Merge remote-tracking branch 'upstream/dev' into keyboard-layouts 2025-04-12 16:57:58 +01:00
Siyuan Miao 951e673e0c chore(ntp): add logging for time sync errors 2025-04-11 18:55:31 +02:00
Siyuan Miao edca8a4cb5 fix(log): segmentation violation when err is nil 2025-04-11 18:49:08 +02:00
Aveline 87ee954e70
chore(log): move each component to its own logger (#353) 2025-04-11 18:38:34 +02:00
Siyuan Miao 94e83249ef chore(cloud): use request id from the cloud 2025-04-11 16:03:46 +02:00
William Johnstone 940612ab6a
Updated keyboard character codes, added half working spanish mappings 2025-04-11 13:26:49 +01:00
Siyuan Miao f98eaddf15 chore(log): ntp logger 2025-04-11 13:12:14 +02:00
Siyuan Miao 8888d13824 chore(log): add nbdLogger 2025-04-11 13:08:52 +02:00
Siyuan Miao 334b3bee60 chore: fix linting issue 2025-04-11 13:05:03 +02:00
Siyuan Miao 0ba7902f82 chore: update logging 2025-04-11 12:55:36 +02:00
Siyuan Miao 924b55059f chore(log): add wolLogger 2025-04-11 08:14:44 +02:00
Siyuan Miao 6489421605 fix(ota): verifyFile missing arguments 2025-04-11 08:05:35 +02:00
Siyuan Miao e08ff425c3 chore(log): add webRtcLogger 2025-04-11 08:05:04 +02:00
Siyuan Miao d5f8e51a14 chore(log): add terminalLogger 2025-04-11 07:58:11 +02:00
Siyuan Miao 612c50bfe2 chore(log): add serialLogger 2025-04-11 07:56:18 +02:00
Siyuan Miao 48a917fd76 chore(log): add otaLogger 2025-04-11 07:49:03 +02:00
Siyuan Miao 5f7dded973 chore(log): add watchdogLogger 2025-04-11 07:42:47 +02:00
Siyuan Miao 04aa35249a chore(log): add jsonRpcLogger 2025-04-11 07:41:21 +02:00
Siyuan Miao 82c018a2f6 feat(tls): #330 2025-04-11 00:43:58 +02:00
Siyuan Miao 4c37f7e079 refactor: use structured logging 2025-04-11 00:43:46 +02:00
Andrew Davis 8f6e64fd9c Add keyboard macros (#305)
* add jsonrpc keyboard macro get/set

* add ui keyboard macros settings and macro bar

* use notifications component and handle jsonrpc errors

* cleanup settings menu

* return error rather than truncate steps in validation

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(ui): add className prop to Checkbox component to allow custom styling

* use existing components and CTA

* extract display key mappings

* create generic combobox component

* remove macro description

* cleanup styles and macro list

* create sortable list component

* split up macro routes

* remove sortable list and simplify

* cleanup macrobar

* use and add info to fieldlabel

* add useCallback optimizations

* add confirm dialog component

* cleanup delete buttons

* revert info on field label

* cleanup combobox focus

* cleanup icons

* set default label for delay

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-11 00:43:46 +02:00
Adam Shiervani 76efa56083 chore(dev_deploy): update logging for websocket in deployment script (#348) 2025-04-11 00:43:46 +02:00
Aveline dc1ce03697 chore(websocket): logging and metrics improvement (#347)
* chore(websocket): only show warning if websocket is closed abnormally

* chore(websocket): add counter for ping requests received
2025-04-11 00:43:46 +02:00
Aveline 66a3352e5d feat(websocket): handle ping messages sent from react and add logging (#346) 2025-04-11 00:43:46 +02:00
Adam Shiervani 9c758b6d57 fix(ui): adjust layout and z-index for improved UI consistency in KvmIdRoute (#345) 2025-04-11 00:43:46 +02:00
Adam Shiervani 647250c32b fix(ui): update WebRTCVideo component to properly animate on peer connection state (#343) 2025-04-11 00:43:46 +02:00
Ben Kochie 3f20c23ea1 fix: Shell linting (#328)
Cleanup various shell linting issues
* Use `/usr/bin/env` consistently for better platform compatibility.
* SC2317 (info): Command appears to be unreachable.
* SC2002 (style): Useless cat.

Signed-off-by: SuperQ <superq@gmail.com>
2025-04-11 00:43:45 +02:00
Adam Shiervani b94de38510 fix(ui): increase z-index for Modal component to improve layering (#341) 2025-04-11 00:43:45 +02:00
Adam Shiervani 1505ca1bc1 fix(dev_device): update JETKVM_PROXY_URL to use WebSocket protocol (#342) 2025-04-11 00:43:45 +02:00
Adam Shiervani 960ef230ba Don't block new PC if connection is stable. No need to (#340) 2025-04-11 00:43:45 +02:00
Adam Shiervani 98af805089 refactor: remove unnecessary whitespace in setupRouter function 2025-04-11 00:43:45 +02:00
Adam Shiervani 84b35d5deb re-add old signaling for when upgrading 2025-04-11 00:43:45 +02:00
Siyuan Miao 652e845d83 fix(ota): certificate signed by unknown authority 2025-04-09 20:25:26 +02:00
Adam Shiervani 1a30977085
Feat/Trickle ice (#336)
* feat(cloud): Use Websocket signaling in cloud mode

* refactor: Enhance WebRTC signaling and connection handling

* refactor: Improve WebRTC connection management and logging in KvmIdRoute

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

* refactor: Standardize metric naming and improve websocket logging

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

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

* refactor: Rename signaling handler function for clarity

* refactor: Remove old http local http endpoint

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

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

* fix conflicts

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

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

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

---------

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

* fix: Update comments for WebRTC connection handling in KvmIdRoute

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

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

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

* feat: add frontend linting job to GitHub Actions workflow

* Move UI linting to separate file

* More linting fixes

* Remove pull_request trigger from UI linting workflow

* Update UI linting workflow

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

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

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

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

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

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

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

* fix(WebRTC): extend connection timeout from 1 second to 60 seconds for improved error handling
2025-03-24 23:31:23 +01:00
Aveline 0a7847c5ab
fix: create empty resource directory to avoid static type check failure (#286) 2025-03-24 23:29:46 +01:00
Siyuan Miao 1b8954e9f3 chore: fix linting issues of web_tls.go 2025-03-24 23:20:08 +01:00
Siyuan Miao ab03aded74 chore: create empty resource directory to avoid static type check fail 2025-03-24 23:16:17 +01:00
Adam Shiervani 204e6c7faf feat(UsbDeviceSetting): integrate remote virtual media state management and improve USB config handlingt 2025-03-24 12:32:12 +01:00
Adam Shiervani caf3922ecd
refactor(WebRTCVideo): improve mouse event handling and video playback logic (#282) 2025-03-24 12:07:31 +01:00
Aveline ec5226ebdb
Merge branch 'main' into dev 2025-03-19 18:30:30 +01:00
Adam Shiervani f198df816c
fix(Dialog): restore navigation after mount process completion (#274) 2025-03-19 18:18:51 +01:00
Adam Shiervani f30eb0355e
fix(Dialog): ensure navigation occurs after mount process completion (#273) 2025-03-19 18:12:49 +01:00
Aveline 439ef01687
fix(usb_mass_storage): should use path instead of configPath (#272) 2025-03-19 17:51:43 +01:00
Siyuan Miao f3c49b853d fix(usb_mass_storage): should use path instead of configPath 2025-03-19 17:43:19 +01:00
Adam Shiervani 8e2ed6059d
Refactor: remove USB configuration components and update settings structure (#271) 2025-03-19 15:57:53 +01:00
Aveline d52e7d04d1
feat: relative mouse (#246) 2025-03-19 11:47:15 +01:00
Aveline e426515ce9
fix: build info was missing (#269) 2025-03-18 18:03:05 +01:00
Siyuan Miao d291053e06 fix: build info was missing 2025-03-18 18:00:21 +01:00
Aveline c4348c7eb4
feat: simple TLS support (#247) 2025-03-18 14:07:45 +01:00
Aveline 369bd3fb18
Merge branch 'dev' into feat/tls 2025-03-18 14:05:33 +01:00
Aveline 38d6f57786
chore: Enable golangci-lint (#7) 2025-03-12 14:55:56 +01:00
Adam Shiervani e66190df0b
feat: Add feature flag for scroll sensitivity settings (#245)
- Integrate feature flag for scroll sensitivity configuration
- Conditionally render scroll sensitivity settings based on app version
- Update useEffect to only fetch scroll sensitivity when feature flag is enabled
2025-03-12 14:55:44 +01:00
Antony Messerli a55774b0de
Update netboot.xyz logo to latest (#253)
Updates svg for the newest logo and replaces the older one
2025-03-12 14:55:14 +01:00
Aveline f72cf0cbff
fix: Logging cleanup (#250) 2025-03-12 11:03:46 +01:00
SuperQ c818d498a9
Logging cleanup
* Cleanup additional `fmt.Println()` that should call logger.
* Use `%v` for logging errors.

Signed-off-by: SuperQ <superq@gmail.com>
2025-03-11 18:47:49 +01:00
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
Siyuan Miao 285de31ade feat(tls): add simple tls support 2025-03-10 13:49:20 +01:00
Siyuan Miao 8b59a3e387 chore(prometheus): move prometheus to a new file 2025-03-10 13:49:20 +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
William Johnstone fb3f5f44fc
Almost complete implementation of mapped virtual keyboard. Still to implement proper modifer key holding. 2025-02-27 01:33:22 +00: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
William Johnstone 40b1c70be0
Added German (T1) mappings, UK mappings, updated UK apple mappings. Added functionality to disable keyboard mapping. 2025-02-25 00:44:17 +00: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
Siyuan Miao 4351cc8dd7 chore: bump version to 0.3.7 2025-02-19 10:38:09 +01:00
Siyuan Miao 16efeee31d fix(config): should return defaultConfig when config file doesnt exist 2025-02-19 10:29:06 +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 99b3017344
Release 0.3.6 2025-02-18 13:51:02 +01:00
Aveline 2ce327ed15
Merge branch 'main' into release/0.3.6 2025-02-18 13:44:12 +01:00
Siyuan Miao 7bca9cb827 chore: bump version to 0.3.6 2025-02-18 13:42:58 +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
Siyuan Miao 1d6b7ad83a chore: bump version to 0.3.5 2025-02-11 15:57:21 +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
166 changed files with 13949 additions and 4703 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"
]
}

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

@ -0,0 +1,39 @@
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
if: "github.event.review.state == 'approved' || github.event.event_type != 'pull_request_review'"
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

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

@ -0,0 +1,37 @@
---
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: Create empty resource directory
run: |
mkdir -p static && touch static/.gitkeep
- name: Lint
uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1
with:
args: --verbose
version: v1.62.0

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

@ -0,0 +1,122 @@
name: smoketest
on:
repository_dispatch:
types: [smoketest]
jobs:
ghbot_payload:
name: Ghbot payload
runs-on: ubuntu-latest
steps:
- name: "GH_CHECK_RUN_ID=${{ github.event.client_payload.check_run_id }}"
run: |
echo "== START GHBOT_PAYLOAD =="
cat <<'GHPAYLOAD_EOF' | base64
${{ toJson(github.event.client_payload) }}
GHPAYLOAD_EOF
echo "== END GHBOT_PAYLOAD =="
deploy_and_test:
runs-on: buildjet-4vcpu-ubuntu-2204
name: Smoke test
concurrency:
group: smoketest-jk
steps:
- name: Download artifact
run: |
wget -O /tmp/jk.zip "${{ github.event.client_payload.artifact_download_url }}"
unzip /tmp/jk.zip
- name: Configure WireGuard and check connectivity
run: |
WG_KEY_FILE=$(mktemp)
echo -n "$CI_WG_PRIVATE" > $WG_KEY_FILE && \
sudo apt-get update && sudo apt-get install -y wireguard-tools && \
sudo ip link add dev wg-ci type wireguard && \
sudo ip addr add $CI_WG_IPS dev wg-ci && \
sudo wg set wg-ci listen-port 51820 \
private-key $WG_KEY_FILE \
peer $CI_WG_PUBLIC \
allowed-ips $CI_WG_ALLOWED_IPS \
endpoint $CI_WG_ENDPOINT \
persistent-keepalive 15 && \
sudo ip link set up dev wg-ci && \
sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci
ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1)
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }}
CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }}
CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }}
CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }}
CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }}
CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }}
- name: Configure SSH
run: |
# Write SSH private key to a file
SSH_PRIVATE_KEY=$(mktemp)
echo "$CI_SSH_PRIVATE" > $SSH_PRIVATE_KEY
chmod 0600 $SSH_PRIVATE_KEY
# Configure SSH
mkdir -p ~/.ssh
cat <<EOF >> ~/.ssh/config
Host jkci
HostName $CI_HOST
User $CI_USER
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile $SSH_PRIVATE_KEY
EOF
env:
CI_USER: ${{ vars.JETKVM_CI_USER }}
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
- name: Deploy application
run: |
set -e
# Copy the binary to the remote host
echo "+ Copying the application to the remote host"
cat jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
# Deploy and run the application on the remote host
echo "+ Deploying the application on the remote host"
ssh jkci ash <<EOF
# Extract the binary
gzip -d /userdata/jetkvm/jetkvm_app.update.gz
# Flush filesystem buffers to ensure all data is written to disk
sync
# Clear the filesystem caches to force a read from disk
echo 1 > /proc/sys/vm/drop_caches
# Reboot the application
reboot -d 5 -f &
EOF
sleep 10
echo "Deployment complete, waiting for JetKVM to come back online "
function check_online() {
for i in {1..60}; do
if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then
echo "JetKVM is back online"
return 0
fi
echo -n "."
sleep 1
done
echo "JetKVM did not come back online within 60 seconds"
return 1
}
check_online
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Run smoke tests
run: |
echo "+ Checking the status of the device"
curl -v http://$CI_HOST/device/status && echo
echo "+ Waiting for 10 seconds to allow all services to start"
sleep 10
echo "+ Collecting logs"
ssh jkci "cat /userdata/jetkvm/last.log" > last.log
cat last.log
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Upload logs
uses: actions/upload-artifact@v4
with:
name: device-logs
path: last.log

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

@ -0,0 +1,34 @@
---
name: ui-lint
on:
push:
paths:
- "ui/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/ui-lint.yml"
permissions:
contents: read
jobs:
ui-lint:
name: UI Lint
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: v21.1.0
cache: "npm"
cache-dependency-path: "ui/package-lock.json"
- name: Install dependencies
run: |
cd ui
npm ci
- name: Lint UI
run: |
cd ui
npm run lint

22
.golangci.yml Normal file
View File

@ -0,0 +1,22 @@
---
linters:
enable:
- forbidigo
- goimports
- misspell
# - revive
- whitespace
issues:
exclude-rules:
- path: _test.go
linters:
- errcheck
linters-settings:
forbidigo:
forbid:
- p: ^fmt\.Print.*$
msg: Do not commit print statements. Use logger package.
- p: ^log\.(Fatal|Panic|Print)(f|ln)?.*$
msg: Do not commit log statements. Use logger package.

View File

@ -1,17 +1,31 @@
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)
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.3.9-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.8
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm
GO_LDFLAGS := \
-s -w \
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
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_PKG_NAME).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 +33,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_PKG_NAME).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

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

View File

@ -3,13 +3,13 @@ package kvm
import (
"context"
"errors"
"log"
"net"
"os"
"time"
"github.com/pojntfx/go-nbd/pkg/client"
"github.com/pojntfx/go-nbd/pkg/server"
"github.com/rs/zerolog"
)
type remoteImageBackend struct {
@ -17,8 +17,8 @@ type remoteImageBackend struct {
func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
virtualMediaStateMutex.RLock()
logger.Debugf("currentVirtualMediaState is %v", currentVirtualMediaState)
logger.Debugf("read size: %d, off: %d", len(p), off)
logger.Debug().Interface("currentVirtualMediaState", currentVirtualMediaState).Msg("currentVirtualMediaState")
logger.Debug().Int64("read size", int64(len(p))).Int64("off", off).Msg("read size and off")
if currentVirtualMediaState == nil {
return 0, errors.New("image not mounted")
}
@ -73,6 +73,8 @@ type NBDDevice struct {
serverConn net.Conn
clientConn net.Conn
dev *os.File
l *zerolog.Logger
}
func NewNBDDevice() *NBDDevice {
@ -91,10 +93,19 @@ func (d *NBDDevice) Start() error {
return err
}
if d.l == nil {
scopedLogger := nbdLogger.With().
Str("socket_path", nbdSocketPath).
Str("device_path", nbdDevicePath).
Logger()
d.l = &scopedLogger
}
// 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)
d.l.Error().Err(err).Msg("failed to remove existing socket file")
os.Exit(1)
}
}
@ -134,7 +145,8 @@ func (d *NBDDevice) runServerConn() {
MaximumBlockSize: uint32(16 * 1024),
SupportsMultiConn: false,
})
log.Println("nbd server exited:", err)
d.l.Info().Err(err).Msg("nbd server exited")
}
func (d *NBDDevice) runClientConn() {
@ -142,14 +154,14 @@ func (d *NBDDevice) runClientConn() {
ExportName: "jetkvm",
BlockSize: uint32(4 * 1024),
})
log.Println("nbd client exited:", err)
d.l.Info().Err(err).Msg("nbd client exited")
}
func (d *NBDDevice) Close() {
if d.dev != nil {
err := client.Disconnect(d.dev)
if err != nil {
log.Println("error disconnecting nbd client:", err)
d.l.Warn().Err(err).Msg("error disconnecting nbd client")
}
_ = d.dev.Close()
}

365
cloud.go
View File

@ -4,16 +4,23 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/coder/websocket/wsjson"
"sync"
"time"
"github.com/coder/websocket/wsjson"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
type CloudRegisterRequest struct {
@ -23,6 +30,142 @@ 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
// WebsocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
WebsocketPingInterval = 15 * time.Second
)
var (
metricCloudConnectionStatus = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_status",
Help: "The status of the cloud connection",
},
)
metricCloudConnectionEstablishedTimestamp = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_established_timestamp",
Help: "The timestamp when the cloud connection was established",
},
)
metricConnectionLastPingTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_timestamp",
Help: "The timestamp when the last ping response was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingReceivedTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_received_timestamp",
Help: "The timestamp when the last ping request was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_duration",
Help: "The duration of the last ping response",
},
[]string{"type", "source"},
)
metricConnectionPingDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_ping_duration",
Help: "The duration of the ping response",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
[]string{"type", "source"},
)
metricConnectionTotalPingSentCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_sent",
Help: "The total number of pings sent to the connection",
},
[]string{"type", "source"},
)
metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_received",
Help: "The total number of pings received from the connection",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_session_total_requests",
Help: "The total number of session requests received",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_session_request_duration",
Help: "The duration of session requests",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
[]string{"type", "source"},
)
metricConnectionLastSessionRequestTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_timestamp",
Help: "The timestamp of the last session request",
},
[]string{"type", "source"},
)
metricConnectionLastSessionRequestDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_duration",
Help: "The duration of the last session request",
},
[]string{"type", "source"},
)
metricCloudConnectionFailureCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_cloud_connection_failure_count",
Help: "The number of times the cloud connection has failed",
},
)
)
var (
cloudDisconnectChan chan error
cloudDisconnectLock = &sync.Mutex{}
)
func wsResetMetrics(established bool, sourceType string, source string) {
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingReceivedTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(-1)
if sourceType != "cloud" {
return
}
if established {
metricCloudConnectionEstablishedTimestamp.SetToCurrentTime()
metricCloudConnectionStatus.Set(1)
} else {
metricCloudConnectionEstablishedTimestamp.Set(-1)
metricCloudConnectionStatus.Set(-1)
}
}
func handleCloudRegister(c *gin.Context) {
var req CloudRegisterRequest
@ -43,22 +186,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
}
@ -69,7 +221,6 @@ func handleCloudRegister(c *gin.Context) {
}
config.CloudToken = tokenResp.SecretToken
config.CloudURL = req.CloudAPI
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
if err != nil {
@ -99,76 +250,114 @@ func handleCloudRegister(c *gin.Context) {
c.JSON(200, gin.H{"message": "Cloud registration successful"})
}
func disconnectCloud(reason error) {
cloudDisconnectLock.Lock()
defer cloudDisconnectLock.Unlock()
if cloudDisconnectChan == nil {
cloudLogger.Trace().Msg("cloud disconnect channel is not set, no need to disconnect")
return
}
// just in case the channel is closed, we don't want to panic
defer func() {
if r := recover(); r != nil {
cloudLogger.Warn().Interface("reason", r).Msg("cloud disconnect channel is closed, no need to disconnect")
}
}()
cloudDisconnectChan <- reason
}
func runWebsocketClient() error {
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
return fmt.Errorf("cloud token is not set")
}
wsURL, err := url.Parse(config.CloudURL)
if err != nil {
return fmt.Errorf("failed to parse config.CloudURL: %w", err)
}
if wsURL.Scheme == "http" {
wsURL.Scheme = "ws"
} else {
wsURL.Scheme = "wss"
}
header := http.Header{}
header.Set("X-Device-ID", GetDeviceID())
header.Set("X-App-Version", builtAppVersion)
header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Minute)
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
l := websocketLogger.With().
Str("source", wsURL.Host).
Str("sourceType", "cloud").
Logger()
scopedLogger := &l
defer cancelDial()
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
c, resp, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header,
OnPingReceived: func(ctx context.Context, payload []byte) bool {
scopedLogger.Info().Bytes("payload", payload).Int("length", len(payload)).Msg("ping frame received")
metricConnectionTotalPingReceivedCount.WithLabelValues("cloud", wsURL.Host).Inc()
metricConnectionLastPingReceivedTimestamp.WithLabelValues("cloud", wsURL.Host).SetToCurrentTime()
return true
},
})
// get the request id from the response header
connectionId := resp.Header.Get("X-Request-ID")
if connectionId == "" {
connectionId = resp.Header.Get("Cf-Ray")
}
if connectionId == "" {
connectionId = uuid.New().String()
scopedLogger.Warn().
Str("connectionId", connectionId).
Msg("no connection id received from the server, generating a new one")
}
lWithConnectionId := scopedLogger.With().
Str("connectionID", connectionId).
Logger()
scopedLogger = &lWithConnectionId
// if the context is canceled, we don't want to return an error
if err != nil {
if errors.Is(err, context.Canceled) {
cloudLogger.Info().Msg("websocket connection canceled")
return nil
}
return err
}
defer c.CloseNow()
logger.Infof("WS connected to %v", wsURL.String())
runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun()
go func() {
for {
time.Sleep(15 * time.Second)
err := c.Ping(runCtx)
if err != nil {
logger.Warnf("websocket ping error: %v", err)
cancelRun()
return
}
}
}()
for {
typ, msg, err := c.Read(runCtx)
if err != nil {
return err
}
if typ != websocket.MessageText {
// ignore non-text messages
continue
}
var req WebRTCSessionRequest
err = json.Unmarshal(msg, &req)
if err != nil {
logger.Warnf("unable to parse ws message: %v", string(msg))
continue
}
defer c.CloseNow() //nolint:errcheck
cloudLogger.Info().
Str("url", wsURL.String()).
Str("connectionID", connectionId).
Msg("websocket connected")
err = handleSessionRequest(runCtx, c, req)
if err != nil {
logger.Infof("error starting new session: %v", err)
continue
}
}
// set the metrics when we successfully connect to the cloud.
wsResetMetrics(true, "cloud", wsURL.Host)
// we don't have a source for the cloud connection
return handleWebRTCSignalWsMessages(c, true, wsURL.Host, connectionId, scopedLogger)
}
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
oidcCtx, cancelOIDC := context.WithTimeout(ctx, time.Minute)
func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
defer cancelOIDC()
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
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.Warn().Err(err).Msg("failed to initialize OIDC provider")
return err
}
@ -184,10 +373,48 @@ 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()
return nil
}
func handleSessionRequest(
ctx context.Context,
c *websocket.Conn,
req WebRTCSessionRequest,
isCloudConnection bool,
source string,
scopedLogger *zerolog.Logger,
) error {
var sourceType string
if isCloudConnection {
sourceType = "cloud"
} else {
sourceType = "local"
}
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v)
metricConnectionSessionRequestDuration.WithLabelValues(sourceType, source).Observe(v)
}))
defer timer.ObserveDuration()
// If the message is from the cloud, we need to authenticate the session.
if isCloudConnection {
if err := authenticateSession(ctx, c, req); err != nil {
return err
}
}
session, err := newSession(SessionConfig{
ws: c,
IsCloud: isCloudConnection,
LocalIP: req.IP,
ICEServers: req.ICEServers,
Logger: scopedLogger,
})
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
return err
@ -206,16 +433,41 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
_ = peerConn.Close()
}()
}
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"sd": sd})
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
return nil
}
func RunWebsocketClient() {
for {
// If the cloud token is not set, we don't need to run the websocket client.
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
continue
}
// If the network is not up, well, we can't connect to the cloud.
if !networkState.Up {
cloudLogger.Warn().Msg("waiting for network to be up, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
// If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail.
if isTimeSyncNeeded() && !timeSyncSuccess {
cloudLogger.Warn().Msg("system time is not synced, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
err := runWebsocketClient()
if err != nil {
fmt.Println("Websocket client error:", err)
cloudLogger.Warn().Err(err).Msg("websocket client error")
metricCloudConnectionStatus.Set(0)
metricCloudConnectionFailureCount.Inc()
time.Sleep(5 * time.Second)
}
}
@ -224,12 +476,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 +498,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,12 +511,15 @@ 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)
}
cloudLogger.Info().Msg("device deregistered, disconnecting from cloud")
disconnectCloud(fmt.Errorf("device deregistered"))
return nil
}

View File

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

166
config.go
View File

@ -4,6 +4,9 @@ import (
"encoding/json"
"fmt"
"os"
"sync"
"github.com/jetkvm/kvm/internal/usbgadget"
)
type WakeOnLanDevice struct {
@ -11,54 +14,171 @@ type WakeOnLanDevice struct {
MacAddress string `json:"macAddress"`
}
// Constants for keyboard macro limits
const (
MaxMacrosPerDevice = 25
MaxStepsPerMacro = 10
MaxKeysPerStep = 10
MinStepDelay = 50
MaxStepDelay = 2000
)
type KeyboardMacroStep struct {
Keys []string `json:"keys"`
Modifiers []string `json:"modifiers"`
Delay int `json:"delay"`
}
func (s *KeyboardMacroStep) Validate() error {
if len(s.Keys) > MaxKeysPerStep {
return fmt.Errorf("too many keys in step (max %d)", MaxKeysPerStep)
}
if s.Delay < MinStepDelay {
s.Delay = MinStepDelay
} else if s.Delay > MaxStepDelay {
s.Delay = MaxStepDelay
}
return nil
}
type KeyboardMacro struct {
ID string `json:"id"`
Name string `json:"name"`
Steps []KeyboardMacroStep `json:"steps"`
SortOrder int `json:"sortOrder,omitempty"`
}
func (m *KeyboardMacro) Validate() error {
if m.Name == "" {
return fmt.Errorf("macro name cannot be empty")
}
if len(m.Steps) == 0 {
return fmt.Errorf("macro must have at least one step")
}
if len(m.Steps) > MaxStepsPerMacro {
return fmt.Errorf("too many steps in macro (max %d)", MaxStepsPerMacro)
}
for i := range m.Steps {
if err := m.Steps[i].Validate(); err != nil {
return fmt.Errorf("invalid step %d: %w", i+1, err)
}
}
return nil
}
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"`
KeyboardLayout string `json:"keyboard_layout"`
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"`
KeyboardLayout string `json:"keyboard_layout"`
KeyboardMappingEnabled bool `json:"keyboard_mapping_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"`
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
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"` // options: "self-signed", "user-defined", ""
UsbConfig *usbgadget.Config `json:"usb_config"`
UsbDevices *usbgadget.Devices `json:"usb_devices"`
DefaultLogLevel string `json:"default_log_level"`
}
const configPath = "/userdata/kvm_config.json"
var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
KeyboardLayout: "us",
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
KeyboardLayout: "en-US",
KeyboardMappingEnabled: false,
ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{},
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,
},
DefaultLogLevel: "INFO",
}
var config *Config
var (
config *Config
configLock = &sync.Mutex{}
)
func LoadConfig() {
configLock.Lock()
defer configLock.Unlock()
if config != nil {
logger.Info().Msg("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
logger.Debug().Msg("default config file doesn't exist, using default")
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
logger.Warn().Err(err).Msg("config file JSON parsing failed")
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
rootLogger.UpdateLogLevel()
}
func SaveConfig() error {
configLock.Lock()
defer configLock.Unlock()
logger.Trace().Str("path", configPath).Msg("Saving config")
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
@ -73,3 +193,9 @@ func SaveConfig() error {
return nil
}
func ensureConfigLoaded() {
if config == nil {
LoadConfig()
}
}

View File

@ -1,3 +1,5 @@
#!/usr/bin/env bash
#
# Exit immediately if a command exits with a non-zero status
set -e
@ -10,17 +12,18 @@ 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:"
echo " $0 -r 192.168.0.17"
echo " $0 -r 192.168.0.17 -u admin"
exit 0
}
# Default values
REMOTE_USER="root"
REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
@ -33,6 +36,10 @@ while [[ $# -gt 0 ]]; do
REMOTE_USER="$2"
shift 2
;;
--skip-ui-build)
SKIP_UI_BUILD=true
shift
;;
--help)
show_help
exit 0
@ -52,17 +59,22 @@ 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"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < jetkvm_app
# Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e
# Set the library path to include the directory where librockit.so is located
@ -74,14 +86,13 @@ killall jetkvm_app_debug || true
killall jetkvm_native || true
# Navigate to the directory where the binary will be stored
cd "$REMOTE_PATH"
cd "${REMOTE_PATH}"
# Make the new binary executable
chmod +x jetkvm_app_debug
# Run the application in the background
./jetkvm_app_debug
PION_LOG_TRACE=jetkvm,cloud,websocket ./jetkvm_app_debug
EOF
echo "Deployment complete."

View File

@ -1,17 +1,31 @@
package kvm
import (
"errors"
"fmt"
"log"
"os"
"strconv"
"sync"
"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)
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
return
}
currentScreen = screen
@ -27,7 +41,7 @@ func updateLabelIfChanged(objName string, newText string) {
}
func switchToScreenIfDifferent(screenName string) {
fmt.Println("switching screen from", currentScreen, screenName)
displayLogger.Info().Str("from", currentScreen).Str("to", screenName).Msg("switching screen")
if currentScreen != screenName {
switchToScreen(screenName)
}
@ -57,15 +71,22 @@ func updateDisplay() {
}
}
var displayInited = false
var (
displayInited = false
displayUpdateLock = sync.Mutex{}
)
func requestDisplayUpdate() {
displayUpdateLock.Lock()
defer displayUpdateLock.Unlock()
if !displayInited {
fmt.Println("display not inited, skipping updates")
displayLogger.Info().Msg("display not inited, skipping updates")
return
}
go func() {
fmt.Println("display updating........................")
wakeDisplay(false)
displayLogger.Info().Msg("display updating")
//TODO: only run once regardless how many pending updates
updateDisplay()
}()
@ -83,14 +104,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
}
displayLogger.Info().Int("brightness", brightness).Msg("set 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 {
displayLogger.Warn().Err(err).Msg("failed to dim display")
}
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 {
displayLogger.Warn().Err(err).Msg("failed to turn off display")
}
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 {
displayLogger.Warn().Err(err).Msg("failed to wake display")
}
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 {
displayLogger.Warn().Err(err).Msg("failed to open touchscreen device")
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 {
displayLogger.Warn().Err(err).Msg("failed to read from touchscreen device")
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 {
displayLogger.Info().Msg("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 {
displayLogger.Info().Msg("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")
displayLogger.Info().Msg("setting initial display contents")
time.Sleep(500 * time.Millisecond)
updateStaticContents()
displayInited = true
fmt.Println("display inited")
displayLogger.Info().Msg("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.Warn().Err(err).Msg("failed to mount fuse")
}
fuseServer.Wait()
}

61
go.mod
View File

@ -1,52 +1,59 @@
module kvm
module github.com/jetkvm/kvm
go 1.21.0
toolchain go1.21.1
go 1.23.0
require (
github.com/Masterminds/semver/v3 v3.3.0
github.com/beevik/ntp v1.3.1
github.com/coder/websocket v1.8.12
github.com/coder/websocket v1.8.13
github.com/coreos/go-oidc/v3 v3.11.0
github.com/creack/pty v1.1.23
github.com/gin-gonic/gin v1.9.1
github.com/gin-contrib/logger v1.2.5
github.com/gin-gonic/gin v1.10.0
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/rs/zerolog v1.34.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.36.0
golang.org/x/net v0.38.0
)
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/creack/goselect v0.1.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // 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/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pilebones/go-udev v0.9.0 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/dtls/v3 v3.0.3 // 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/arch v0.15.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

138
go.sum
View File

@ -2,32 +2,40 @@ 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/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/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.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
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=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
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=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/logger v1.2.5 h1:qVQI4omayQecuN4zX9ZZnsOq7w9J/ZLds3J/FMn8ypM=
github.com/gin-contrib/logger v1.2.5/go.mod h1:/bj+vNMuA2xOEQ1aRHoJ1m9+uyaaXIAxQTvM2llsc6I=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -36,12 +44,13 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
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/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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,24 +58,33 @@ 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/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
@ -76,10 +94,10 @@ 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/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/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.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q=
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
@ -114,14 +132,24 @@ 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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
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 +160,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,34 +173,33 @@ 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=
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=
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.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
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.0.0-20220811171246-fbc7d0a398ab/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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
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=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

12
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
@ -42,7 +42,7 @@ func GetDeviceID() string {
deviceIDOnce.Do(func() {
serial, err := extractSerialNumber()
if err != nil {
logger.Warn("unknown serial number, the program likely not running on RV1106")
logger.Warn().Msg("unknown serial number, the program likely not running on RV1106")
deviceID = "unknown_device_id"
} else {
deviceID = serial
@ -54,7 +54,7 @@ func GetDeviceID() string {
func runWatchdog() {
file, err := os.OpenFile("/dev/watchdog", os.O_WRONLY, 0)
if err != nil {
logger.Warnf("unable to open /dev/watchdog: %v, skipping watchdog reset", err)
watchdogLogger.Warn().Err(err).Msg("unable to open /dev/watchdog, skipping watchdog reset")
return
}
defer file.Close()
@ -65,13 +65,13 @@ func runWatchdog() {
case <-ticker.C:
_, err = file.Write([]byte{0})
if err != nil {
logger.Errorf("error writing to /dev/watchdog, system may reboot: %v", err)
watchdogLogger.Warn().Err(err).Msg("error writing to /dev/watchdog, system may reboot")
}
case <-appCtx.Done():
//disarm watchdog with magic value
_, err := file.Write([]byte("V"))
if err != nil {
logger.Errorf("failed to disarm watchdog, system may reboot: %v", err)
watchdogLogger.Warn().Err(err).Msg("failed to disarm watchdog, system may reboot")
}
return
}

View File

@ -0,0 +1,336 @@
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().Msg("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
}
// GetPath returns the path to the item.
func (u *UsbGadget) GetPath(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.path), 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().Msg("no udc found, skipping USB stack init")
return nil
}
u.udc = udcs[0]
_, err := os.Stat(u.kvmGadgetPath)
if err == nil {
u.log.Info().Msg("usb gadget already exists")
}
if err := mountConfigFS(); err != nil {
u.log.Error().Err(err).Msg("failed to mount configfs, usb stack might not function properly")
}
if err := os.MkdirAll(u.configC1Path, 0755); err != nil {
u.log.Error().Err(err).Msg("failed to create config path")
}
if err := u.writeGadgetConfig(); err != nil {
u.log.Error().Err(err).Msg("failed to start gadget")
}
return nil
}
func (u *UsbGadget) UpdateGadgetConfig() error {
u.configLock.Lock()
defer u.configLock.Unlock()
u.loadGadgetConfig()
if err := u.writeGadgetConfig(); err != nil {
u.log.Error().Err(err).Msg("failed to update gadget")
}
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.Trace().Msg("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.Trace().Str("key", key).Msg("disabling gadget config")
err = u.disableGadgetItemConfig(item)
if err != nil {
return err
}
continue
}
u.log.Trace().Str("key", key).Msg("writing gadget config")
err = u.writeGadgetItemConfig(item)
if err != nil {
return err
}
}
if err = u.writeUDC(); err != nil {
u.log.Error().Err(err).Msg("failed to write UDC")
return err
}
if err = u.rebindUsb(true); err != nil {
u.log.Info().Err(err).Msg("failed to rebind usb")
}
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.Trace().Str("path", configPath).Msg("symlink does not exist")
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.Trace().Str("source", configPath).Str("target", gadgetItemPath).Msg("creating symlink")
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.Error().Err(err).Msg("failed to write to hidg0")
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.Error().Err(err).Msg("failed to write to hidg1")
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.Error().Err(err).Msg("failed to write to hidg2")
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.Info().Str("udc", u.udc).Msg("rebinding USB gadget to 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.Trace().Str("udc", u.udc).Str("path", path).Msg("writing UDC")
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.Trace().Err(err).Msg("failed to read usb state")
}
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/rs/zerolog"
)
// 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 *zerolog.Logger
}
const configFSPath = "/sys/kernel/config"
const gadgetPath = "/sys/kernel/config/usb_gadget"
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
// NewUsbGadget creates a new UsbGadget.
func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *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 {
logger.Error().Err(err).Msg("failed to init USB gadget")
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.Trace().Str("path", filePath).Msg("skipping writing to as it already has the correct content")
return nil
}
if len(oldContent) == len(content)+1 &&
bytes.Equal(oldContent[:len(content)], content) &&
oldContent[len(content)] == 10 {
u.log.Trace().Str("path", filePath).Msg("skipping writing to as it already has the correct content")
return nil
}
u.log.Trace().Str("path", filePath).Bytes("old", oldContent).Bytes("new", content).Msg("writing to as it has different content")
}
}
return os.WriteFile(filePath, content, permMode)
}

View File

@ -0,0 +1,9 @@
package websecure
import (
"os"
"github.com/rs/zerolog"
)
var defaultLogger = zerolog.New(os.Stdout).With().Str("component", "websecure").Logger()

View File

@ -0,0 +1,191 @@
package websecure
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"net"
"strings"
"time"
"github.com/rs/zerolog"
"golang.org/x/net/idna"
)
const selfSignerCAMagicName = "__ca__"
type SelfSigner struct {
store *CertStore
log *zerolog.Logger
caInfo pkix.Name
DefaultDomain string
DefaultOrg string
DefaultOU string
}
func NewSelfSigner(
store *CertStore,
log *zerolog.Logger,
defaultDomain,
defaultOrg,
defaultOU,
caName string,
) *SelfSigner {
return &SelfSigner{
store: store,
log: log,
DefaultDomain: defaultDomain,
DefaultOrg: defaultOrg,
DefaultOU: defaultOU,
caInfo: pkix.Name{
CommonName: caName,
Organization: []string{defaultOrg},
OrganizationalUnit: []string{defaultOU},
},
}
}
func (s *SelfSigner) getCA() *tls.Certificate {
return s.createSelfSignedCert(selfSignerCAMagicName)
}
func (s *SelfSigner) createSelfSignedCert(hostname string) *tls.Certificate {
if tlsCert := s.store.certificates[hostname]; tlsCert != nil {
return tlsCert
}
// check if hostname is the CA magic name
var ca *tls.Certificate
if hostname != selfSignerCAMagicName {
ca = s.getCA()
if ca == nil {
s.log.Error().Msg("Failed to get CA certificate")
return nil
}
}
s.log.Info().Str("hostname", hostname).Msg("Creating self-signed certificate")
// lock the store while creating the certificate (do not move upwards)
s.store.certLock.Lock()
defer s.store.certLock.Unlock()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
s.log.Error().Err(err).Msg("Failed to generate private key")
return nil
}
notBefore := time.Now()
notAfter := notBefore.AddDate(1, 0, 0)
serialNumber, err := generateSerialNumber()
if err != nil {
s.log.Error().Err(err).Msg("Failed to generate serial number")
return nil
}
dnsName := hostname
ip := net.ParseIP(hostname)
if ip != nil {
dnsName = s.DefaultDomain
}
// set up CSR
isCA := hostname == selfSignerCAMagicName
subject := pkix.Name{
CommonName: hostname,
Organization: []string{s.DefaultOrg},
OrganizationalUnit: []string{s.DefaultOU},
}
keyUsage := x509.KeyUsageDigitalSignature
extKeyUsage := []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
// check if hostname is the CA magic name, and if so, set the subject to the CA info
if isCA {
subject = s.caInfo
keyUsage |= x509.KeyUsageCertSign
extKeyUsage = append(extKeyUsage, x509.ExtKeyUsageClientAuth)
notAfter = notBefore.AddDate(10, 0, 0)
}
cert := x509.Certificate{
SerialNumber: serialNumber,
Subject: subject,
NotBefore: notBefore,
NotAfter: notAfter,
IsCA: isCA,
KeyUsage: keyUsage,
ExtKeyUsage: extKeyUsage,
BasicConstraintsValid: true,
}
// set up DNS names and IP addresses
if !isCA {
cert.DNSNames = []string{dnsName}
if ip != nil {
cert.IPAddresses = []net.IP{ip}
}
}
// set up parent certificate
parent := &cert
parentPriv := priv
if ca != nil {
parent, err = x509.ParseCertificate(ca.Certificate[0])
if err != nil {
s.log.Error().Err(err).Msg("Failed to parse parent certificate")
return nil
}
parentPriv = ca.PrivateKey.(*ecdsa.PrivateKey)
}
certBytes, err := x509.CreateCertificate(rand.Reader, &cert, parent, &priv.PublicKey, parentPriv)
if err != nil {
s.log.Error().Err(err).Msg("Failed to create certificate")
return nil
}
tlsCert := &tls.Certificate{
Certificate: [][]byte{certBytes},
PrivateKey: priv,
}
if ca != nil {
tlsCert.Certificate = append(tlsCert.Certificate, ca.Certificate...)
}
s.store.certificates[hostname] = tlsCert
s.store.saveCertificate(hostname)
return tlsCert
}
// GetCertificate returns the certificate for the given hostname
// returns nil if the certificate is not found
func (s *SelfSigner) GetCertificate(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
var hostname string
if info.ServerName != "" && info.ServerName != selfSignerCAMagicName {
hostname = info.ServerName
} else {
hostname = strings.Split(info.Conn.LocalAddr().String(), ":")[0]
}
s.log.Info().Str("hostname", hostname).Strs("supported_protos", info.SupportedProtos).Msg("TLS handshake")
// convert hostname to punycode
h, err := idna.Lookup.ToASCII(hostname)
if err != nil {
s.log.Warn().Str("hostname", hostname).Err(err).Str("remote_addr", info.Conn.RemoteAddr().String()).Msg("Hostname is not valid")
hostname = s.DefaultDomain
} else {
hostname = h
}
cert := s.createSelfSignedCert(hostname)
return cert, nil
}

175
internal/websecure/store.go Normal file
View File

@ -0,0 +1,175 @@
package websecure
import (
"crypto/tls"
"fmt"
"os"
"path"
"strings"
"sync"
"github.com/rs/zerolog"
)
type CertStore struct {
certificates map[string]*tls.Certificate
certLock *sync.Mutex
storePath string
log *zerolog.Logger
}
func NewCertStore(storePath string, log *zerolog.Logger) *CertStore {
if log == nil {
log = &defaultLogger
}
return &CertStore{
certificates: make(map[string]*tls.Certificate),
certLock: &sync.Mutex{},
storePath: storePath,
log: log,
}
}
func (s *CertStore) ensureStorePath() error {
// check if directory exists
stat, err := os.Stat(s.storePath)
if err == nil {
if stat.IsDir() {
return nil
}
return fmt.Errorf("TLS store path exists but is not a directory: %s", s.storePath)
}
if os.IsNotExist(err) {
s.log.Trace().Str("path", s.storePath).Msg("TLS store directory does not exist, creating directory")
err = os.MkdirAll(s.storePath, 0755)
if err != nil {
return fmt.Errorf("Failed to create TLS store path: %w", err)
}
return nil
}
return fmt.Errorf("Failed to check TLS store path: %w", err)
}
func (s *CertStore) LoadCertificates() {
err := s.ensureStorePath()
if err != nil {
s.log.Error().Err(err).Msg("Failed to ensure store path")
return
}
files, err := os.ReadDir(s.storePath)
if err != nil {
s.log.Error().Err(err).Msg("Failed to read TLS directory")
return
}
for _, file := range files {
if file.IsDir() {
continue
}
if strings.HasSuffix(file.Name(), ".crt") {
s.loadCertificate(strings.TrimSuffix(file.Name(), ".crt"))
}
}
}
func (s *CertStore) loadCertificate(hostname string) {
s.certLock.Lock()
defer s.certLock.Unlock()
keyFile := path.Join(s.storePath, hostname+".key")
crtFile := path.Join(s.storePath, hostname+".crt")
cert, err := tls.LoadX509KeyPair(crtFile, keyFile)
if err != nil {
s.log.Error().Err(err).Str("hostname", hostname).Msg("Failed to load certificate")
return
}
s.certificates[hostname] = &cert
s.log.Info().Str("hostname", hostname).Msg("Loaded certificate")
}
// GetCertificate returns the certificate for the given hostname
// returns nil if the certificate is not found
func (s *CertStore) GetCertificate(hostname string) *tls.Certificate {
s.certLock.Lock()
defer s.certLock.Unlock()
return s.certificates[hostname]
}
// ValidateAndSaveCertificate validates the certificate and saves it to the store
// returns are:
// - error: if the certificate is invalid or if there's any error during saving the certificate
// - error: if there's any warning or error during saving the certificate
func (s *CertStore) ValidateAndSaveCertificate(hostname string, cert string, key string, ignoreWarning bool) (error, error) {
tlsCert, err := tls.X509KeyPair([]byte(cert), []byte(key))
if err != nil {
return fmt.Errorf("Failed to parse certificate: %w", err), nil
}
// this can be skipped as current implementation supports one custom certificate only
if tlsCert.Leaf != nil {
// add recover to avoid panic
defer func() {
if r := recover(); r != nil {
s.log.Error().Interface("recovered", r).Msg("Failed to verify hostname")
}
}()
if err = tlsCert.Leaf.VerifyHostname(hostname); err != nil {
if !ignoreWarning {
return nil, fmt.Errorf("Certificate does not match hostname: %w", err)
}
s.log.Warn().Err(err).Msg("Certificate does not match hostname")
}
}
s.certLock.Lock()
s.certificates[hostname] = &tlsCert
s.certLock.Unlock()
s.saveCertificate(hostname)
return nil, nil
}
func (s *CertStore) saveCertificate(hostname string) {
// check if certificate already exists
tlsCert := s.certificates[hostname]
if tlsCert == nil {
s.log.Error().Str("hostname", hostname).Msg("Certificate for hostname does not exist, skipping saving certificate")
return
}
err := s.ensureStorePath()
if err != nil {
s.log.Error().Err(err).Msg("Failed to ensure store path")
return
}
keyFile := path.Join(s.storePath, hostname+".key")
crtFile := path.Join(s.storePath, hostname+".crt")
if err := keyToFile(tlsCert, keyFile); err != nil {
s.log.Error().Err(err).Msg("Failed to save key file")
return
}
if err := certToFile(tlsCert, crtFile); err != nil {
s.log.Error().Err(err).Msg("Failed to save certificate")
return
}
s.log.Info().Str("hostname", hostname).Msg("Saved certificate")
}

View File

@ -0,0 +1,80 @@
package websecure
import (
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"math/big"
"os"
)
var serialNumberLimit = new(big.Int).Lsh(big.NewInt(1), 4096)
func withSecretFile(filename string, f func(*os.File) error) error {
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer file.Close()
return f(file)
}
func keyToFile(cert *tls.Certificate, filename string) error {
var keyBlock pem.Block
switch k := cert.PrivateKey.(type) {
case *rsa.PrivateKey:
keyBlock = pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(k),
}
case *ecdsa.PrivateKey:
b, e := x509.MarshalECPrivateKey(k)
if e != nil {
return fmt.Errorf("Failed to marshal EC private key: %v", e)
}
keyBlock = pem.Block{
Type: "EC PRIVATE KEY",
Bytes: b,
}
default:
return fmt.Errorf("Unknown private key type: %T", k)
}
err := withSecretFile(filename, func(file *os.File) error {
return pem.Encode(file, &keyBlock)
})
if err != nil {
return fmt.Errorf("Failed to save private key: %w", err)
}
return nil
}
func certToFile(cert *tls.Certificate, filename string) error {
return withSecretFile(filename, func(file *os.File) error {
for _, c := range cert.Certificate {
block := pem.Block{
Type: "CERTIFICATE",
Bytes: c,
}
err := pem.Encode(file, &block)
if err != nil {
return fmt.Errorf("Failed to save certificate: %w", err)
}
}
return nil
})
}
func generateSerialNumber() (*big.Int, error) {
return rand.Int(rand.Reader, serialNumberLimit)
}

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()
}
@ -30,11 +28,11 @@ func runJiggler() {
//TODO: change to rel mouse
err := rpcAbsMouseReport(1, 1, 0)
if err != nil {
logger.Warnf("Failed to jiggle mouse: %v", err)
logger.Warn().Err(err).Msg("Failed to jiggle mouse")
}
err = rpcAbsMouseReport(0, 0, 0)
if err != nil {
logger.Warnf("Failed to reset mouse position: %v", err)
logger.Warn().Err(err).Msg("Failed to reset mouse position")
}
}
}

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)
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
return
}
err = session.RPCChannel.SendText(string(responseBytes))
if err != nil {
log.Println("Error sending JSONRPC response:", err)
jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response")
return
}
}
@ -55,16 +65,24 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
}
requestBytes, err := json.Marshal(request)
if err != nil {
log.Println("Error marshalling JSONRPC event:", err)
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
return
}
if session == nil || session.RPCChannel == nil {
log.Println("RPC channel not available")
jsonRpcLogger.Info().Msg("RPC channel not available")
return
}
err = session.RPCChannel.SendText(string(requestBytes))
requestString := string(requestBytes)
scopedLogger := jsonRpcLogger.With().
Str("data", requestString).
Logger()
scopedLogger.Info().Msg("sending JSONRPC event")
err = session.RPCChannel.SendText(requestString)
if err != nil {
log.Println("Error sending JSONRPC event:", err)
scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
return
}
}
@ -73,6 +91,11 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
var request JSONRPCRequest
err := json.Unmarshal(message.Data, &request)
if err != nil {
jsonRpcLogger.Warn().
Str("data", string(message.Data)).
Err(err).
Msg("Error unmarshalling JSONRPC request")
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]interface{}{
@ -85,7 +108,13 @@ 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)
scopedLogger := jsonRpcLogger.With().
Str("method", request.Method).
Interface("params", request.Params).
Interface("id", request.ID).Logger()
scopedLogger.Trace().Msg("Received RPC request")
handler, ok := rpcHandlers[request.Method]
if !ok {
errorResponse := JSONRPCResponse{
@ -100,8 +129,10 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
scopedLogger.Trace().Msg("Calling RPC handler")
result, err := callRPCHandler(handler, request.Params)
if err != nil {
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]interface{}{
@ -115,6 +146,8 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned")
response := JSONRPCResponse{
JSONRPC: "2.0",
Result: result,
@ -143,6 +176,42 @@ func rpcSetKeyboardLayout(KeyboardLayout string) (string, error) {
return KeyboardLayout, nil
}
func rpcGetKeyboardMappingState() (bool, error) {
return config.KeyboardMappingEnabled, nil
}
func rpcSetKeyboardMappingState(enabled bool) (bool, error) {
config.KeyboardMappingEnabled = enabled
if err := SaveConfig(); err != nil {
return config.KeyboardMappingEnabled, fmt.Errorf("failed to save config: %w", err)
}
return enabled, nil
}
func rpcReboot(force bool) error {
logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
args := []string{}
if force {
args = append(args, "-f")
}
cmd := exec.Command("reboot", args...)
err := cmd.Start()
if err != nil {
logger.Error().Err(err).Msg("failed to reboot")
return fmt.Errorf("failed to reboot: %w", err)
}
// If the reboot command is successful, exit the program after 5 seconds
go func() {
time.Sleep(5 * time.Second)
os.Exit(0)
}()
return nil
}
var streamFactor = 1.0
func rpcGetStreamQualityFactor() (float64, error) {
@ -150,7 +219,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
}
func rpcSetStreamQualityFactor(factor float64) error {
log.Printf("Setting stream quality factor to: %f", factor)
logger.Info().Float64("factor", factor).Msg("Setting stream quality factor")
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
if err != nil {
return err
@ -186,15 +255,19 @@ func rpcGetEDID() (string, error) {
func rpcSetEDID(edid string) error {
if edid == "" {
log.Println("Restoring EDID to default")
logger.Info().Msg("Restoring EDID to default")
edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
} else {
log.Printf("Setting EDID to: %s", edid)
logger.Info().Str("edid", edid).Msg("Setting 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
}
@ -225,12 +298,58 @@ func rpcTryUpdate() error {
go func() {
err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Warnf("failed to try update: %v", err)
logger.Warn().Err(err).Msg("failed to try update")
}
}()
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.Info().Int("max_brightness", config.DisplayMaxBrightness).Int("dim_after", config.DisplayDimAfterSec).Int("off_after", config.DisplayOffAfterSec).Msg("rpc: display: settings applied")
// 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"
@ -270,7 +389,7 @@ func rpcSetDevModeState(enabled bool) error {
return fmt.Errorf("failed to create devmode file: %w", err)
}
} else {
logger.Debug("dev mode already enabled")
logger.Debug().Msg("dev mode already enabled")
return nil
}
} else {
@ -279,7 +398,7 @@ func rpcSetDevModeState(enabled bool) error {
return fmt.Errorf("failed to remove devmode file: %w", err)
}
} else if os.IsNotExist(err) {
logger.Debug("dev mode already disabled")
logger.Debug().Msg("dev mode already disabled")
return nil
} else {
return fmt.Errorf("error checking dev mode file: %w", err)
@ -289,7 +408,7 @@ func rpcSetDevModeState(enabled bool) error {
cmd := exec.Command("dropbear.sh")
output, err := cmd.CombinedOutput()
if err != nil {
logger.Warnf("Failed to start/stop SSH: %v, %v", err, output)
logger.Warn().Err(err).Bytes("output", output).Msg("Failed to start/stop SSH")
return fmt.Errorf("failed to start/stop SSH, you may need to reboot for changes to take effect")
}
@ -327,6 +446,23 @@ func rpcSetSSHKeyState(sshKey string) error {
return nil
}
func rpcGetTLSState() TLSState {
return getTLSState()
}
func rpcSetTLSState(state TLSState) error {
err := setTLSState(state)
if err != nil {
return fmt.Errorf("failed to set TLS state: %w", err)
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
handlerValue := reflect.ValueOf(handler.Func)
handlerType := handlerValue.Type()
@ -391,7 +527,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)
@ -430,23 +566,23 @@ type RPCHandler struct {
}
func rpcSetMassStorageMode(mode string) (string, error) {
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
var cdrom bool
if mode == "cdrom" {
cdrom = true
} else if mode != "file" {
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode)
logger.Info().Str("mode", mode).Msg("Invalid mode provided")
return "", fmt.Errorf("invalid mode: %s", mode)
}
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
logger.Info().Str("mode", mode).Msg("Setting mass storage 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.Info().Str("mode", mode).Msg("Mass storage mode set")
// Get the updated mode after setting
return rpcGetMassStorageMode()
@ -469,29 +605,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
}
@ -503,69 +641,415 @@ 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().Msg("Configuration reset to default")
return nil
}
// TODO: replace this crap with code generator
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState},
"unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"kbLayout"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"tryUpdate": {Func: rpcTryUpdate},
"getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode},
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
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.Info().Bool("enabled", enabled).Msg("Setting DC power state")
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.Debug().Str("action", action).Msg("Executing ATX power action")
switch action {
case "power-short":
logger.Debug().Msg("Simulating short power button press")
return pressATXPowerButton(200 * time.Millisecond)
case "power-long":
logger.Debug().Msg("Simulating long power button press")
return pressATXPowerButton(5 * time.Second)
case "reset":
logger.Debug().Msg("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 {
currentCloudURL := config.CloudURL
config.CloudURL = apiUrl
config.CloudAppURL = appUrl
if currentCloudURL != apiUrl {
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
var currentScrollSensitivity string = "default"
func rpcGetScrollSensitivity() (string, error) {
return currentScrollSensitivity, nil
}
func rpcSetScrollSensitivity(sensitivity string) error {
currentScrollSensitivity = sensitivity
return nil
}
func getKeyboardMacros() (interface{}, error) {
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
copy(macros, config.KeyboardMacros)
return macros, nil
}
type KeyboardMacrosParams struct {
Macros []interface{} `json:"macros"`
}
func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
if params.Macros == nil {
return nil, fmt.Errorf("missing or invalid macros parameter")
}
newMacros := make([]KeyboardMacro, 0, len(params.Macros))
for i, item := range params.Macros {
macroMap, ok := item.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid macro at index %d", i)
}
id, _ := macroMap["id"].(string)
if id == "" {
id = fmt.Sprintf("macro-%d", time.Now().UnixNano())
}
name, _ := macroMap["name"].(string)
sortOrder := i + 1
if sortOrderFloat, ok := macroMap["sortOrder"].(float64); ok {
sortOrder = int(sortOrderFloat)
}
steps := []KeyboardMacroStep{}
if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
for _, stepItem := range stepsArray {
stepMap, ok := stepItem.(map[string]interface{})
if !ok {
continue
}
step := KeyboardMacroStep{}
if keysArray, ok := stepMap["keys"].([]interface{}); ok {
for _, k := range keysArray {
if keyStr, ok := k.(string); ok {
step.Keys = append(step.Keys, keyStr)
}
}
}
if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
for _, m := range modsArray {
if modStr, ok := m.(string); ok {
step.Modifiers = append(step.Modifiers, modStr)
}
}
}
if delay, ok := stepMap["delay"].(float64); ok {
step.Delay = int(delay)
}
steps = append(steps, step)
}
}
macro := KeyboardMacro{
ID: id,
Name: name,
Steps: steps,
SortOrder: sortOrder,
}
if err := macro.Validate(); err != nil {
return nil, fmt.Errorf("invalid macro at index %d: %w", i, err)
}
newMacros = append(newMacros, macro)
}
config.KeyboardMacros = newMacros
if err := SaveConfig(); err != nil {
return nil, err
}
return nil, nil
}
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}},
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState},
"unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"kbLayout"}},
"setKeyboardMappingState": {Func: rpcSetKeyboardMappingState, Params: []string{"enabled"}},
"getKeyboardMappingState": {Func: rpcGetKeyboardMappingState},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"tryUpdate": {Func: rpcTryUpdate},
"getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"getTLSState": {Func: rpcGetTLSState},
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode},
"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},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"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"}},
"getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
}

293
log.go
View File

@ -1,8 +1,291 @@
package kvm
import "github.com/pion/logging"
import (
"fmt"
"io"
"os"
"strings"
"sync"
"time"
// 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")
"github.com/pion/logging"
"github.com/rs/zerolog"
)
type Logger struct {
l *zerolog.Logger
scopeLoggers map[string]*zerolog.Logger
scopeLevels map[string]zerolog.Level
scopeLevelMutex sync.Mutex
defaultLogLevelFromEnv zerolog.Level
defaultLogLevelFromConfig zerolog.Level
defaultLogLevel zerolog.Level
}
const (
defaultLogLevel = zerolog.ErrorLevel
)
type logOutput struct {
mu *sync.Mutex
}
func (w *logOutput) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
// TODO: write to file or syslog
return len(p), nil
}
var (
consoleLogOutput io.Writer = zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: time.RFC3339,
PartsOrder: []string{"time", "level", "scope", "component", "message"},
FieldsExclude: []string{"scope", "component"},
FormatPartValueByName: func(value interface{}, name string) string {
val := fmt.Sprintf("%s", value)
if name == "component" {
if value == nil {
return "-"
}
}
return val
},
}
fileLogOutput io.Writer = &logOutput{mu: &sync.Mutex{}}
defaultLogOutput = zerolog.MultiLevelWriter(consoleLogOutput, fileLogOutput)
zerologLevels = map[string]zerolog.Level{
"DISABLE": zerolog.Disabled,
"NOLEVEL": zerolog.NoLevel,
"PANIC": zerolog.PanicLevel,
"FATAL": zerolog.FatalLevel,
"ERROR": zerolog.ErrorLevel,
"WARN": zerolog.WarnLevel,
"INFO": zerolog.InfoLevel,
"DEBUG": zerolog.DebugLevel,
"TRACE": zerolog.TraceLevel,
}
rootZerologLogger = zerolog.New(defaultLogOutput).With().
Str("scope", "jetkvm").
Timestamp().
Stack().
Logger()
rootLogger = NewLogger(rootZerologLogger)
)
func NewLogger(zerologLogger zerolog.Logger) *Logger {
return &Logger{
l: &zerologLogger,
scopeLoggers: make(map[string]*zerolog.Logger),
scopeLevels: make(map[string]zerolog.Level),
scopeLevelMutex: sync.Mutex{},
defaultLogLevelFromEnv: -2,
defaultLogLevelFromConfig: -2,
defaultLogLevel: defaultLogLevel,
}
}
func (l *Logger) updateLogLevel() {
l.scopeLevelMutex.Lock()
defer l.scopeLevelMutex.Unlock()
l.scopeLevels = make(map[string]zerolog.Level)
finalDefaultLogLevel := l.defaultLogLevel
for name, level := range zerologLevels {
env := os.Getenv(fmt.Sprintf("JETKVM_LOG_%s", name))
if env == "" {
env = os.Getenv(fmt.Sprintf("PION_LOG_%s", name))
}
if env == "" {
env = os.Getenv(fmt.Sprintf("PIONS_LOG_%s", name))
}
if env == "" {
continue
}
if strings.ToLower(env) == "all" {
l.defaultLogLevelFromEnv = level
if finalDefaultLogLevel > level {
finalDefaultLogLevel = level
}
continue
}
scopes := strings.Split(strings.ToLower(env), ",")
for _, scope := range scopes {
l.scopeLevels[scope] = level
}
}
l.defaultLogLevel = finalDefaultLogLevel
}
func (l *Logger) getScopeLoggerLevel(scope string) zerolog.Level {
if l.scopeLevels == nil {
l.updateLogLevel()
}
var scopeLevel zerolog.Level
if l.defaultLogLevelFromConfig != -2 {
scopeLevel = l.defaultLogLevelFromConfig
}
if l.defaultLogLevelFromEnv != -2 {
scopeLevel = l.defaultLogLevelFromEnv
}
// if the scope is not in the map, use the default level from the root logger
if level, ok := l.scopeLevels[scope]; ok {
scopeLevel = level
}
return scopeLevel
}
func (l *Logger) newScopeLogger(scope string) zerolog.Logger {
scopeLevel := l.getScopeLoggerLevel(scope)
logger := l.l.Level(scopeLevel).With().Str("component", scope).Logger()
return logger
}
func (l *Logger) getLogger(scope string) *zerolog.Logger {
logger, ok := l.scopeLoggers[scope]
if !ok || logger == nil {
scopeLogger := l.newScopeLogger(scope)
l.scopeLoggers[scope] = &scopeLogger
}
return l.scopeLoggers[scope]
}
func (l *Logger) UpdateLogLevel() {
needUpdate := false
if config != nil && config.DefaultLogLevel != "" {
if logLevel, ok := zerologLevels[config.DefaultLogLevel]; ok {
l.defaultLogLevelFromConfig = logLevel
} else {
l.l.Warn().Str("logLevel", config.DefaultLogLevel).Msg("invalid defaultLogLevel from config, using ERROR")
}
if l.defaultLogLevelFromConfig != l.defaultLogLevel {
needUpdate = true
}
}
l.updateLogLevel()
if needUpdate {
for scope, logger := range l.scopeLoggers {
currentLevel := logger.GetLevel()
targetLevel := l.getScopeLoggerLevel(scope)
if currentLevel != targetLevel {
*logger = l.newScopeLogger(scope)
}
}
}
}
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
if l == nil {
l = rootLogger.getLogger("jetkvm")
}
l.Error().Err(err).Msgf(format, args...)
if err == nil {
return fmt.Errorf(format, args...)
}
err_msg := err.Error() + ": %v"
err_args := append(args, err)
return fmt.Errorf(err_msg, err_args...)
}
var (
logger = rootLogger.getLogger("jetkvm")
cloudLogger = rootLogger.getLogger("cloud")
websocketLogger = rootLogger.getLogger("websocket")
webrtcLogger = rootLogger.getLogger("webrtc")
nativeLogger = rootLogger.getLogger("native")
nbdLogger = rootLogger.getLogger("nbd")
ntpLogger = rootLogger.getLogger("ntp")
jsonRpcLogger = rootLogger.getLogger("jsonrpc")
watchdogLogger = rootLogger.getLogger("watchdog")
websecureLogger = rootLogger.getLogger("websecure")
otaLogger = rootLogger.getLogger("ota")
serialLogger = rootLogger.getLogger("serial")
terminalLogger = rootLogger.getLogger("terminal")
displayLogger = rootLogger.getLogger("display")
wolLogger = rootLogger.getLogger("wol")
usbLogger = rootLogger.getLogger("usb")
// external components
ginLogger = rootLogger.getLogger("gin")
)
type pionLogger struct {
logger *zerolog.Logger
}
// Print all messages except trace.
func (c pionLogger) Trace(msg string) {
c.logger.Trace().Msg(msg)
}
func (c pionLogger) Tracef(format string, args ...interface{}) {
c.logger.Trace().Msgf(format, args...)
}
func (c pionLogger) Debug(msg string) {
c.logger.Debug().Msg(msg)
}
func (c pionLogger) Debugf(format string, args ...interface{}) {
c.logger.Debug().Msgf(format, args...)
}
func (c pionLogger) Info(msg string) {
c.logger.Info().Msg(msg)
}
func (c pionLogger) Infof(format string, args ...interface{}) {
c.logger.Info().Msgf(format, args...)
}
func (c pionLogger) Warn(msg string) {
c.logger.Warn().Msg(msg)
}
func (c pionLogger) Warnf(format string, args ...interface{}) {
c.logger.Warn().Msgf(format, args...)
}
func (c pionLogger) Error(msg string) {
c.logger.Error().Msg(msg)
}
func (c pionLogger) Errorf(format string, args ...interface{}) {
c.logger.Error().Msgf(format, args...)
}
// customLoggerFactory satisfies the interface logging.LoggerFactory
// This allows us to create different loggers per subsystem. So we can
// add custom behavior.
type pionLoggerFactory struct{}
func (c pionLoggerFactory) NewLogger(subsystem string) logging.LeveledLogger {
logger := rootLogger.getLogger(subsystem).With().
Str("scope", "pion").
Str("component", subsystem).
Logger()
return pionLogger{logger: &logger}
}
var defaultLoggerFactory = &pionLoggerFactory{}

43
main.go
View File

@ -2,7 +2,6 @@ package kvm
import (
"context"
"log"
"net/http"
"os"
"os/signal"
@ -15,70 +14,88 @@ import (
var appCtx context.Context
func Main() {
LoadConfig()
logger.Debug().Msg("config loaded")
var cancel context.CancelFunc
appCtx, cancel = context.WithCancel(context.Background())
defer cancel()
logger.Info("Starting JetKvm")
logger.Info().Msg("starting JetKvm")
go runWatchdog()
go confirmCurrentSystem()
http.DefaultClient.Timeout = 1 * time.Minute
LoadConfig()
logger.Debug("config loaded")
err := rootcerts.UpdateDefaultTransport()
if err != nil {
logger.Errorf("failed to load CA certs: %v", err)
logger.Warn().Err(err).Msg("failed to load CA certs")
}
initNetwork()
go TimeSyncLoop()
StartNativeCtrlSocketServer()
StartNativeVideoSocketServer()
initPrometheus()
go func() {
err = ExtractAndRunNativeBin()
if err != nil {
logger.Errorf("failed to extract and run native bin: %v", err)
logger.Warn().Err(err).Msg("failed to extract and run native bin")
//TODO: prepare an error message screen buffer to show on kvm screen
}
}()
initUsbGadget()
go func() {
time.Sleep(15 * time.Minute)
for {
logger.Debugf("UPDATING - Auto update enabled: %v", config.AutoUpdateEnabled)
if config.AutoUpdateEnabled == false {
logger.Debug().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("UPDATING")
if !config.AutoUpdateEnabled {
return
}
if currentSession != nil {
logger.Debugf("skipping update since a session is active")
logger.Debug().Msg("skipping update since a session is active")
time.Sleep(1 * time.Minute)
continue
}
includePreRelease := config.IncludePreRelease
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Errorf("failed to auto update: %v", err)
logger.Warn().Err(err).Msg("failed to auto update")
}
time.Sleep(1 * time.Hour)
}
}()
//go RunFuseServer()
go RunWebServer()
go RunWebSecureServer()
// Web secure server is started only if TLS mode is enabled
if config.TLSMode != "" {
startWebSecureServer()
}
// As websocket client already checks if the cloud token is set, we can start it here.
go RunWebsocketClient()
initSerialPort()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
log.Println("JetKVM Shutting Down")
logger.Info().Msg("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)

142
native.go
View File

@ -3,16 +3,19 @@ package kvm
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"kvm/resource"
"log"
"net"
"os"
"os/exec"
"sync"
"syscall"
"time"
"github.com/jetkvm/kvm/resource"
"github.com/rs/zerolog"
"github.com/pion/webrtc/v4/pkg/media"
)
@ -33,6 +36,19 @@ type CtrlResponse struct {
Data json.RawMessage `json:"data,omitempty"`
}
type nativeOutput struct {
mu *sync.Mutex
logger *zerolog.Event
}
func (w *nativeOutput) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
w.logger.Msg(string(p))
return len(p), nil
}
type EventHandler func(event CtrlResponse)
var seq int32 = 1
@ -60,25 +76,33 @@ 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))
scopedLogger := nativeLogger.With().
Str("action", ctrlAction.Action).
Interface("params", ctrlAction.Params).Logger()
scopedLogger.Debug().Msg("sending ctrl action")
err = WriteCtrlMessage(jsonData)
if err != nil {
delete(ongoingRequests, ctrlAction.Seq)
return nil, fmt.Errorf("error writing ctrl message: %w", err)
return nil, ErrorfL(&scopedLogger, "error writing ctrl message", err)
}
select {
case response := <-responseChan:
delete(ongoingRequests, seq)
if response.Error != "" {
return nil, fmt.Errorf("error native response: %s", response.Error)
return nil, ErrorfL(
&scopedLogger,
"error native response: %s",
errors.New(response.Error),
)
}
return response, nil
case <-time.After(5 * time.Second):
close(responseChan)
delete(ongoingRequests, seq)
return nil, fmt.Errorf("timeout waiting for response")
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
}
}
@ -90,8 +114,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{})
@ -100,29 +124,35 @@ func waitCtrlClientConnected() {
}
func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener {
scopedLogger := nativeLogger.With().
Str("socket_path", socketPath).
Logger()
// 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)
scopedLogger.Warn().Err(err).Msg("failed to remove existing socket file")
os.Exit(1)
}
}
listener, err := net.Listen("unixpacket", socketPath)
if err != nil {
log.Fatalf("Failed to start server on %s: %v", socketPath, err)
scopedLogger.Warn().Err(err).Msg("failed to start server")
os.Exit(1)
}
log.Printf("Server listening on %s", socketPath)
scopedLogger.Info().Msg("server listening")
go func() {
conn, err := listener.Accept()
listener.Close()
if err != nil {
logger.Errorf("failed to accept sock: %v", err)
scopedLogger.Warn().Err(err).Msg("failed to accept socket")
}
if isCtrl {
close(ctrlClientConnected)
logger.Debug("first native ctrl socket client connected")
scopedLogger.Debug().Msg("first native ctrl socket client connected")
}
handleClient(conn)
}()
@ -132,40 +162,50 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
func StartNativeCtrlSocketServer() {
nativeCtrlSocketListener = StartNativeSocketServer("/var/run/jetkvm_ctrl.sock", handleCtrlClient, true)
logger.Debug("native app ctrl sock started")
nativeLogger.Debug().Msg("native app ctrl sock started")
}
func StartNativeVideoSocketServer() {
nativeVideoSocketListener = StartNativeSocketServer("/var/run/jetkvm_video.sock", handleVideoClient, false)
logger.Debug("native app video sock started")
nativeLogger.Debug().Msg("native app video sock started")
}
func handleCtrlClient(conn net.Conn) {
defer conn.Close()
logger.Debug("native socket client connected")
scopedLogger := nativeLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "ctrl").
Logger()
scopedLogger.Info().Msg("native ctrl socket client connected")
if ctrlSocketConn != nil {
logger.Debugf("closing existing native socket connection")
scopedLogger.Debug().Msg("closing existing native socket connection")
ctrlSocketConn.Close()
}
ctrlSocketConn = conn
// Restore HDMI EDID if applicable
go restoreHdmiEdid()
readBuf := make([]byte, 4096)
for {
n, err := conn.Read(readBuf)
if err != nil {
logger.Errorf("error reading from ctrl sock: %v", err)
scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock")
break
}
readMsg := string(readBuf[:n])
logger.Tracef("ctrl sock msg: %v", readMsg)
ctrlResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
if err != nil {
logger.Warnf("error parsing ctrl sock msg: %v", err)
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing ctrl sock msg")
continue
}
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
if ctrlResp.Seq != 0 {
responseChan, ok := ongoingRequests[ctrlResp.Seq]
if ok {
@ -178,30 +218,34 @@ func handleCtrlClient(conn net.Conn) {
}
}
logger.Debug("ctrl sock disconnected")
scopedLogger.Debug().Msg("ctrl sock disconnected")
}
func handleVideoClient(conn net.Conn) {
defer conn.Close()
log.Printf("Native video socket client connected: %v", conn.RemoteAddr())
scopedLogger := nativeLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "video").
Logger()
scopedLogger.Info().Msg("native video socket client connected")
inboundPacket := make([]byte, maxFrameSize)
lastFrame := time.Now()
for {
n, err := conn.Read(inboundPacket)
if err != nil {
log.Println("error during read: %s", err)
scopedLogger.Warn().Err(err).Msg("error during read")
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)
scopedLogger.Warn().Err(err).Msg("error writing sample")
}
}
}
@ -220,9 +264,25 @@ func ExtractAndRunNativeBin() error {
// Run the binary in the background
cmd := exec.Command(binaryPath)
nativeOutputLock := sync.Mutex{}
nativeStdout := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stdout"),
}
nativeStderr := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stderr"),
}
// Redirect stdout and stderr to the current process
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdout = nativeStdout
cmd.Stderr = nativeStderr
// 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 {
@ -232,28 +292,28 @@ func ExtractAndRunNativeBin() error {
//TODO: add auto restart
go func() {
<-appCtx.Done()
logger.Infof("killing process PID: %d", cmd.Process.Pid)
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process")
err := cmd.Process.Kill()
if err != nil {
logger.Errorf("failed to kill process: %v", err)
nativeLogger.Warn().Err(err).Msg("failed to kill process")
return
}
}()
fmt.Printf("Binary started with PID: %d\n", cmd.Process.Pid)
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("jetkvm_native binary started")
return nil
}
func shouldOverwrite(destPath string, srcHash []byte) bool {
if srcHash == nil {
logger.Debug("error reading embedded jetkvm_native.sha256, doing overwriting")
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, doing overwriting")
return true
}
dstHash, err := os.ReadFile(destPath + ".sha256")
if err != nil {
logger.Debug("error reading existing jetkvm_native.sha256, doing overwriting")
nativeLogger.Debug().Msg("error reading existing jetkvm_native.sha256, doing overwriting")
return true
}
@ -269,13 +329,13 @@ func ensureBinaryUpdated(destPath string) error {
srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
if err != nil {
logger.Debug("error reading embedded jetkvm_native.sha256, proceeding with update")
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
srcHash = nil
}
_, err = os.Stat(destPath)
if shouldOverwrite(destPath, srcHash) || err != nil {
logger.Info("writing jetkvm_native")
nativeLogger.Info().Msg("writing jetkvm_native")
_ = os.Remove(destPath)
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755)
if err != nil {
@ -292,8 +352,20 @@ func ensureBinaryUpdated(destPath string) error {
return err
}
}
logger.Info("jetkvm_native updated")
nativeLogger.Info().Msg("jetkvm_native updated")
}
return nil
}
// Restore the HDMI EDID value from the config.
// Called after successful connection to jetkvm_native.
func restoreHdmiEdid() {
if config.EdidString != "" {
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString})
if err != nil {
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
}
}
}

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,101 @@ 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.Warn().Err(err).Msg("network: setDhcpClientState: failed to change udhcpc state")
}
}
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.Warn().Err(err).Str("interface", NetIfName).Msg("failed to get interface")
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.Warn().Err(err).Str("interface", NetIfName).Msg("failed to get addresses")
}
// 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.Info().Str("address", addr.IP.String()).Msg("network: state transitioned to down, removing IPv4 address")
err := netlink.AddrDel(iface, &addr)
if err != nil {
logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("network: failed to delete address")
}
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().
Interface("newState", newState).
Interface("oldState", networkState).
Msg("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().Msg("stopping mDNS server")
err := mDNSConn.Close()
if err != nil {
logger.Warn().Err(err).Msg("failed to stop mDNS server")
}
}
// Start a new server
hostname := "jetkvm.local"
scopedLogger := logger.With().Str("hostname", hostname).Logger()
scopedLogger.Info().Msg("starting mDNS server")
addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4)
if err != nil {
return err
@ -83,22 +153,60 @@ func startMDNS() error {
return err
}
_, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{
LocalNames: []string{"jetkvm.local"}, //TODO: make it configurable
mDNSConn, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{
LocalNames: []string{hostname}, //TODO: make it configurable
LoggerFactory: defaultLoggerFactory,
})
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to start mDNS server")
mDNSConn = nil
return err
}
//defer server.Close()
return nil
}
func init() {
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.Info().Str("server", server).Msg("invalid NTP server IP, ignoring")
}
servers = append(servers, server)
}
return servers, nil
}
func initNetwork() {
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.Warn().Err(err).Msg("failed to subscribe to link updates")
return
}
@ -111,8 +219,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.Info().Interface("update", update).Msg("link update")
checkNetworkState()
}
case <-ticker.C:
@ -122,9 +230,8 @@ func init() {
}
}
}()
fmt.Println("Starting mDNS server")
err := startMDNS()
if err != nil {
fmt.Println("failed to run mDNS: %v", err)
logger.Warn().Err(err).Msg("failed to run mDNS")
}
}

127
ntp.go
View File

@ -1,30 +1,97 @@
package kvm
import (
"errors"
"fmt"
"log"
"net/http"
"os/exec"
"strconv"
"time"
"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 (
builtTimestamp string
timeSyncRetryInterval = 0 * time.Second
timeSyncSuccess = false
defaultNTPServers = []string{
"time.cloudflare.com",
"time.apple.com",
}
)
func isTimeSyncNeeded() bool {
if builtTimestamp == "" {
ntpLogger.Warn().Msg("Built timestamp is not set, time sync is needed")
return true
}
ts, err := strconv.Atoi(builtTimestamp)
if err != nil {
ntpLogger.Warn().Str("error", err.Error()).Msg("Failed to parse built timestamp")
return true
}
// builtTimestamp is UNIX timestamp in seconds
builtTime := time.Unix(int64(ts), 0)
now := time.Now()
ntpLogger.Debug().Str("built_time", builtTime.Format(time.RFC3339)).Str("now", now.Format(time.RFC3339)).Msg("Built time and now")
if now.Sub(builtTime) < 0 {
ntpLogger.Warn().Msg("System time is behind the built time, time sync is needed")
return true
}
return false
}
func TimeSyncLoop() {
for {
fmt.Println("Syncing system time")
if !networkState.checked {
time.Sleep(timeSyncWaitNetChkInt)
continue
}
if !networkState.Up {
ntpLogger.Info().Msg("Waiting for network to come up")
time.Sleep(timeSyncWaitNetUpInt)
continue
}
// check if time sync is needed, but do nothing for now
isTimeSyncNeeded()
ntpLogger.Info().Msg("Syncing system time")
start := time.Now()
err := SyncSystemTime()
if err != nil {
log.Printf("Failed to sync system time: %v", err)
ntpLogger.Error().Str("error", err.Error()).Msg("Failed to sync system time")
// 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
timeSyncSuccess = true
ntpLogger.Info().Str("now", time.Now().Format(time.RFC3339)).
Str("time_taken", time.Since(start).String()).
Msg("Time sync successful")
time.Sleep(timeSyncInterval) // after the first sync is done
}
}
@ -41,27 +108,59 @@ func SyncSystemTime() (err error) {
}
func queryNetworkTime() (*time.Time, error) {
ntpServers := []string{
"time.cloudflare.com",
"time.apple.com",
ntpServers, err := getNTPServersFromDHCPInfo()
if err != nil {
ntpLogger.Info().Err(err).Msg("failed to get NTP servers from DHCP info")
}
if ntpServers == nil {
ntpServers = defaultNTPServers
ntpLogger.Info().
Interface("ntp_servers", ntpServers).
Msg("Using default NTP servers")
} else {
ntpLogger.Info().
Interface("ntp_servers", ntpServers).
Msg("Using NTP servers from DHCP")
}
for _, server := range ntpServers {
now, err := queryNtpServer(server, 2*time.Second)
now, err := queryNtpServer(server, timeSyncTimeout)
if err == nil {
ntpLogger.Info().
Str("ntp_server", server).
Str("time", now.Format(time.RFC3339)).
Msg("NTP server returned time")
return now, nil
} else {
ntpLogger.Error().
Str("ntp_server", server).
Str("error", err.Error()).
Msg("failed to query NTP server")
}
}
httpUrls := []string{
"http://apple.com",
"http://cloudflare.com",
}
for _, url := range httpUrls {
now, err := queryHttpTime(url, 2*time.Second)
now, err := queryHttpTime(url, timeSyncTimeout)
if err == nil {
ntpLogger.Info().
Str("http_url", url).
Str("time", now.Format(time.RFC3339)).
Msg("HTTP server returned time")
return now, nil
} else {
ntpLogger.Error().
Str("http_url", url).
Str("error", err.Error()).
Msg("failed to query HTTP server")
}
}
return nil, errors.New("failed to query network time")
return nil, ErrorfL(ntpLogger, "failed to query network time, all NTP servers and HTTP servers failed", nil)
}
func queryNtpServer(server string, timeout time.Duration) (now *time.Time, err error) {

82
ota.go
View File

@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
@ -17,6 +16,7 @@ import (
"time"
"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog"
)
type UpdateMetadata struct {
@ -77,7 +77,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.Info().Str("url", updateUrl.String()).Msg("Checking for updates")
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
if err != nil {
@ -127,7 +127,13 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return fmt.Errorf("error creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
// TODO: set a separate timeout for the download but keep the TLS handshake short
// use Transport here will cause CA certificate validation failure so we temporarily removed it
client := http.Client{
Timeout: 10 * time.Minute,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error downloading file: %w", err)
}
@ -186,7 +192,11 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return nil
}
func verifyFile(path string, expectedHash string, verifyProgress *float32) error {
func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
if scopedLogger == nil {
scopedLogger = otaLogger
}
unverifiedPath := path + ".unverified"
fileToHash, err := os.Open(unverifiedPath)
if err != nil {
@ -230,7 +240,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32) error
}
hashSum := hash.Sum(nil)
fmt.Printf("SHA256 hash of %s: %x\n", path, hashSum)
scopedLogger.Info().Str("path", path).Str("hash", hex.EncodeToString(hashSum)).Msg("SHA256 hash of")
if hex.EncodeToString(hashSum) != expectedHash {
return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash)
@ -272,7 +282,7 @@ var otaState = OTAState{}
func triggerOTAStateUpdate() {
go func() {
if currentSession == nil {
log.Println("No active RPC session, skipping update state update")
logger.Info().Msg("No active RPC session, skipping update state update")
return
}
writeJSONRPCEvent("otaState", otaState, currentSession)
@ -280,7 +290,12 @@ func triggerOTAStateUpdate() {
}
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
log.Println("Trying to update...")
scopedLogger := otaLogger.With().
Str("deviceId", deviceId).
Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)).
Logger()
scopedLogger.Info().Msg("Trying to update...")
if otaState.Updating {
return fmt.Errorf("update already in progress")
}
@ -298,6 +313,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
updateStatus, err := GetUpdateStatus(ctx, deviceId, includePreRelease)
if err != nil {
otaState.Error = fmt.Sprintf("Error checking for updates: %v", err)
scopedLogger.Error().Err(err).Msg("Error checking for updates")
return fmt.Errorf("error checking for updates: %w", err)
}
@ -315,11 +331,15 @@ 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)
scopedLogger.Info().
Str("local", local.AppVersion).
Str("remote", remote.AppVersion).
Msg("App update available")
err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress)
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading app update")
triggerOTAStateUpdate()
return err
}
@ -328,9 +348,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.AppDownloadProgress = 1
triggerOTAStateUpdate()
err = verifyFile("/userdata/jetkvm/jetkvm_app.update", remote.AppHash, &otaState.AppVerificationProgress)
err = verifyFile(
"/userdata/jetkvm/jetkvm_app.update",
remote.AppHash,
&otaState.AppVerificationProgress,
&scopedLogger,
)
if err != nil {
otaState.Error = fmt.Sprintf("Error verifying app update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying app update hash")
triggerOTAStateUpdate()
return err
}
@ -341,17 +367,22 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.AppUpdateProgress = 1
triggerOTAStateUpdate()
fmt.Println("App update downloaded")
scopedLogger.Info().Msg("App update downloaded")
rebootNeeded = true
} else {
fmt.Println("App is up to date")
scopedLogger.Info().Msg("App is up to date")
}
if systemUpdateAvailable {
fmt.Printf("System update available: %s -> %s\n", local.SystemVersion, remote.SystemVersion)
scopedLogger.Info().
Str("local", local.SystemVersion).
Str("remote", remote.SystemVersion).
Msg("System update available")
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)
scopedLogger.Error().Err(err).Msg("Error downloading system update")
triggerOTAStateUpdate()
return err
}
@ -360,18 +391,25 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.SystemDownloadProgress = 1
triggerOTAStateUpdate()
err = verifyFile("/userdata/jetkvm/update_system.tar", remote.SystemHash, &otaState.SystemVerificationProgress)
err = verifyFile(
"/userdata/jetkvm/update_system.tar",
remote.SystemHash,
&otaState.SystemVerificationProgress,
&scopedLogger,
)
if err != nil {
otaState.Error = fmt.Sprintf("Error verifying system update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying system update hash")
triggerOTAStateUpdate()
return err
}
fmt.Println("System update downloaded")
scopedLogger.Info().Msg("System update downloaded")
verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished
otaState.SystemVerificationProgress = 1
triggerOTAStateUpdate()
scopedLogger.Info().Msg("Starting rk_ota command")
cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/jetkvm/update_system.tar", "--save_dir=/userdata/jetkvm/ota_save", "--partition=all")
var b bytes.Buffer
cmd.Stdout = &b
@ -379,6 +417,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
err = cmd.Start()
if err != nil {
otaState.Error = fmt.Sprintf("Error starting rk_ota command: %v", err)
scopedLogger.Error().Err(err).Msg("Error starting rk_ota command")
return fmt.Errorf("error starting rk_ota command: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
@ -410,25 +449,30 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
output := b.String()
if err != nil {
otaState.Error = fmt.Sprintf("Error executing rk_ota command: %v\nOutput: %s", err, output)
scopedLogger.Error().
Err(err).
Str("output", output).
Int("exitCode", cmd.ProcessState.ExitCode()).
Msg("Error executing rk_ota command")
return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output)
}
fmt.Printf("rk_ota success, output: %s\n", output)
scopedLogger.Info().Str("output", output).Msg("rk_ota success")
otaState.SystemUpdateProgress = 1
otaState.SystemUpdatedAt = &verifyFinished
triggerOTAStateUpdate()
rebootNeeded = true
} else {
fmt.Println("System is up to date")
scopedLogger.Info().Msg("System is up to date")
}
if rebootNeeded {
fmt.Println("System Rebooting in 10s...")
scopedLogger.Info().Msg("System Rebooting in 10s")
time.Sleep(10 * time.Second)
cmd := exec.Command("reboot")
err := cmd.Start()
if err != nil {
otaState.Error = fmt.Sprintf("Failed to start reboot: %v", err)
scopedLogger.Error().Err(err).Msg("Failed to start reboot")
return fmt.Errorf("failed to start reboot: %w", err)
} else {
os.Exit(0)
@ -498,6 +542,6 @@ func IsUpdatePending() bool {
func confirmCurrentSystem() {
output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput()
if err != nil {
logger.Warnf("failed to set current partition in A/B setup: %s", string(output))
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
}
}

13
prometheus.go Normal file
View File

@ -0,0 +1,13 @@
package kvm
import (
"github.com/prometheus/client_golang/prometheus"
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
"github.com/prometheus/common/version"
)
func initPrometheus() {
// A Prometheus metrics endpoint.
version.Version = builtAppVersion
prometheus.MustRegister(versioncollector.NewCollector("jetkvm"))
}

View File

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

View File

@ -44,12 +44,12 @@ func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) (
return nil, errors.New("not active session")
}
logger.Debugf("reading from webrtc %v", string(jsonBytes))
logger.Debug().Str("request", string(jsonBytes)).Msg("reading from webrtc")
err = currentSession.DiskChannel.SendText(string(jsonBytes))
if err != nil {
return nil, err
}
buf := make([]byte, 0)
var buf []byte
for {
select {
case data := <-diskReadChan:

286
serial.go Normal file
View File

@ -0,0 +1,286 @@
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() {
scopedLogger := serialLogger.With().Str("service", "atx_control").Logger()
reader := bufio.NewReader(port)
for {
line, err := reader.ReadString('\n')
if err != nil {
scopedLogger.Warn().Err(err).Msg("Error reading from serial port")
return
}
// Each line should be 4 binary digits + newline
if len(line) != 5 {
scopedLogger.Warn().Int("length", len(line)).Msg("Invalid line length")
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 {
scopedLogger.Debug().
Bool("hdd", newLedHDDState).
Bool("pwr", newLedPWRState).
Bool("rst", newBtnRSTState).
Bool("pwr", newBtnPWRState).
Msg("Status changed")
// 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() {
scopedLogger := serialLogger.With().Str("service", "dc_control").Logger()
reader := bufio.NewReader(port)
for {
line, err := reader.ReadString('\n')
if err != nil {
scopedLogger.Warn().Err(err).Msg("Error reading from serial port")
return
}
// Split the line by semicolon
parts := strings.Split(strings.TrimSpace(line), ";")
if len(parts) != 4 {
scopedLogger.Warn().Str("line", line).Msg("Invalid line")
continue
}
// Parse new states
powerState, err := strconv.Atoi(parts[0])
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid power state")
continue
}
dcState.IsOn = powerState == 1
milliVolts, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid voltage")
continue
}
volts := milliVolts / 1000 // Convert mV to V
milliAmps, err := strconv.ParseFloat(parts[2], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid current")
continue
}
amps := milliAmps / 1000 // Convert mA to A
milliWatts, err := strconv.ParseFloat(parts[3], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid power")
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 {
serialLogger.Error().
Err(err).
Str("path", serialPortPath).
Interface("mode", defaultMode).
Msg("Error opening serial port")
}
return nil
}
func handleSerialChannel(d *webrtc.DataChannel) {
scopedLogger := serialLogger.With().
Uint16("data_channel_id", *d.ID()).Logger()
d.OnOpen(func() {
go func() {
buf := make([]byte, 1024)
for {
n, err := port.Read(buf)
if err != nil {
if err != io.EOF {
scopedLogger.Warn().Err(err).Msg("Failed to read from serial port")
}
break
}
err = d.Send(buf[:n])
if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to send serial output")
break
}
}
}()
})
d.OnMessage(func(msg webrtc.DataChannelMessage) {
if port == nil {
return
}
_, err := port.Write(msg.Data)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to write to serial")
}
})
d.OnError(func(err error) {
scopedLogger.Warn().Err(err).Msg("Serial channel error")
})
d.OnClose(func() {
scopedLogger.Info().Msg("Serial channel closed")
})
}

View File

@ -16,6 +16,9 @@ type TerminalSize struct {
}
func handleTerminalChannel(d *webrtc.DataChannel) {
scopedLogger := terminalLogger.With().
Uint16("data_channel_id", *d.ID()).Logger()
var ptmx *os.File
var cmd *exec.Cmd
d.OnOpen(func() {
@ -23,7 +26,7 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
var err error
ptmx, err = pty.Start(cmd)
if err != nil {
logger.Errorf("Failed to start pty: %v", err)
scopedLogger.Warn().Err(err).Msg("Failed to start pty")
d.Close()
return
}
@ -34,13 +37,13 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
n, err := ptmx.Read(buf)
if err != nil {
if err != io.EOF {
logger.Errorf("Failed to read from pty: %v", err)
scopedLogger.Warn().Err(err).Msg("Failed to read from pty")
}
break
}
err = d.Send(buf[:n])
if err != nil {
logger.Errorf("Failed to send pty output: %v", err)
scopedLogger.Warn().Err(err).Msg("Failed to send pty output")
break
}
}
@ -55,17 +58,19 @@ 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)
scopedLogger.Warn().Err(err).Msg("Failed to parse terminal size")
}
_, err := ptmx.Write(msg.Data)
if err != nil {
logger.Errorf("Failed to write to pty: %v", err)
scopedLogger.Warn().Err(err).Msg("Failed to write to pty")
}
})
@ -74,7 +79,12 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
ptmx.Close()
}
if cmd != nil && cmd.Process != nil {
cmd.Process.Kill()
_ = cmd.Process.Kill()
}
scopedLogger.Info().Msg("Terminal channel closed")
})
d.OnError(func(err error) {
scopedLogger.Warn().Err(err).Msg("Terminal channel error")
})
}

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

View File

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

View File

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

19
ui/dev_device.sh Executable file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env 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="ws://$ip_address" npx vite dev --mode=device

2390
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,24 +7,30 @@
"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 './src/**/*.{ts,tsx}'",
"lint:fix": "eslint './src/**/*.{ts,tsx}' --fix",
"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.1",
"@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",
"eslint-import-resolver-alias": "^1.1.2",
"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",
"react": "^18.2.0",
@ -34,33 +40,38 @@
"react-icons": "^5.4.0",
"react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.7.112",
"recharts": "^2.12.6",
"tailwind-merge": "^2.2.2",
"react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.9",
"recharts": "^2.15.0",
"tailwind-merge": "^2.5.5",
"usehooks-ts": "^3.1.0",
"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.9",
"@tailwindcss/typography": "^0.5.15",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.3.0",
"@types/semver": "^7.5.8",
"@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.19",
"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",
"prettier-plugin-tailwindcss": "^0.5.13",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"autoprefixer": "^10.4.20",
"eslint": "^8.20.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2"
"vite-tsconfig-paths": "^5.1.4"
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -1,34 +1,38 @@
import { MdOutlineContentPasteGo } from "react-icons/md";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { FaKeyboard } from "react-icons/fa6";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import { Button } from "@components/Button";
import {
useHidStore,
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 { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import MountPopopover from "@/components/popovers/MountPopover";
import ExtensionPopover from "@/components/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 +57,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 +70,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 +96,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 +138,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 +152,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Wake on Lan"
text="Wake on LAN"
onClick={() => {
setDisableFocusTrap(true);
}}
@ -186,7 +190,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 +210,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 +263,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

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

View File

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

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

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

View File

@ -1,7 +1,8 @@
import type { Ref } from "react";
import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import { cva, cx } from "@/cva.config";
const sizes = {
@ -36,11 +37,11 @@ type CheckBoxProps = {
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
{ size = "MD", ...props },
{ size = "MD", className, ...props },
ref,
) {
const classes = checkboxVariants({ size });
return <input ref={ref} {...props} type="checkbox" className={classes} />;
return <input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />;
});
Checkbox.displayName = "Checkbox";
@ -52,7 +53,7 @@ type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
function CheckboxWithLabel(
{ label, id, description, as, fullWidth, readOnly, ...props },
{ label, id, description, fullWidth, readOnly, ...props },
ref: Ref<HTMLInputElement>,
) {
return (

View File

@ -0,0 +1,119 @@
import { useRef } from "react";
import clsx from "clsx";
import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
import { cva } from "@/cva.config";
import Card from "./Card";
export interface ComboboxOption {
value: string;
label: string;
}
const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs",
SM: "h-[32px] pl-3 pr-8 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
} as const;
const comboboxVariants = cva({
variants: { size: sizes },
});
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
displayValue: (option: ComboboxOption) => string;
onInputChange: (option: string) => void;
options: () => ComboboxOption[];
placeholder?: string;
emptyMessage?: string;
size?: keyof typeof sizes;
disabledMessage?: string;
}
export function Combobox({
onInputChange,
displayValue,
options,
disabled = false,
placeholder = "Search...",
emptyMessage = "No results found",
size = "MD",
onChange,
disabledMessage = "Input disabled",
...otherProps
}: ComboboxProps) {
const inputRef = useRef<HTMLInputElement>(null);
const classes = comboboxVariants({ size });
return (
<HeadlessCombobox
onChange={onChange}
{...otherProps}
>
{() => (
<>
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
<ComboboxInput
ref={inputRef}
className={clsx(
classes,
// General styling
"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",
// Dark mode
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
// Focus
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
// Disabled
disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
)}
placeholder={disabled ? disabledMessage : placeholder}
displayValue={displayValue}
onChange={(event) => onInputChange(event.target.value)}
disabled={disabled}
/>
</Card>
{options().length > 0 && (
<ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
{options().map((option) => (
<ComboboxOption
key={option.value}
value={option}
className={clsx(
// General styling
"cursor-default select-none py-2 px-4",
// Hover and active states
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
// Dark mode
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
)}
>
{option.label}
</ComboboxOption>
))}
</ComboboxOptions>
)}
{options().length === 0 && inputRef.current?.value && (
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700">
<div className="text-slate-500 dark:text-slate-400">
{emptyMessage}
</div>
</div>
)}
</>
)}
</HeadlessCombobox>
);
}

View File

@ -0,0 +1,106 @@
import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
import { cx } from "@/cva.config";
import { Button } from "@/components/Button";
import Modal from "@/components/Modal";
type Variant = "danger" | "success" | "warning" | "info";
interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
title: string;
description: string;
variant?: Variant;
confirmText?: string;
cancelText?: string | null;
onConfirm: () => void;
isConfirming?: boolean;
}
const variantConfig = {
danger: {
icon: ExclamationTriangleIcon,
iconClass: "text-red-600",
iconBgClass: "bg-red-100",
buttonTheme: "danger",
},
success: {
icon: CheckCircleIcon,
iconClass: "text-green-600",
iconBgClass: "bg-green-100",
buttonTheme: "primary",
},
warning: {
icon: ExclamationTriangleIcon,
iconClass: "text-yellow-600",
iconBgClass: "bg-yellow-100",
buttonTheme: "lightDanger",
},
info: {
icon: InformationCircleIcon,
iconClass: "text-blue-600",
iconBgClass: "bg-blue-100",
buttonTheme: "primary",
},
} as Record<Variant, {
icon: React.ElementType;
iconClass: string;
iconBgClass: string;
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
}>;
export function ConfirmDialog({
open,
onClose,
title,
description,
variant = "info",
confirmText = "Confirm",
cancelText = "Cancel",
onConfirm,
isConfirming = false,
}: ConfirmDialogProps) {
const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
return (
<Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
<div className="relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800 pointer-events-auto">
<div className="space-y-4">
<div className="sm:flex sm:items-start">
<div className={cx("mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10", iconBgClass)}>
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h2 className="text-lg font-bold leading-tight text-black dark:text-white">
{title}
</h2>
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
{description}
</div>
</div>
</div>
<div className="flex justify-end gap-x-2">
{cancelText && (
<Button
size="SM"
theme="blank"
text={cancelText}
onClick={onClose}
/>
)}
<Button
size="SM"
theme={buttonTheme}
text={isConfirming ? `${confirmText}...` : confirmText}
onClick={onConfirm}
disabled={isConfirming}
/>
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,28 @@
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

@ -1,13 +1,14 @@
import React from "react";
import { cx } from "@/cva.config";
type Props = {
interface Props {
label: string | React.ReactNode;
id?: string;
as?: "label" | "span";
description?: string | React.ReactNode | null;
disabled?: boolean;
};
}
export default function FieldLabel({
label,
id,
@ -26,7 +27,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 +35,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>
)}
@ -48,4 +49,4 @@ export default function FieldLabel({
} else {
return <></>;
}
}
}

View File

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

View File

@ -1,18 +1,22 @@
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 { LuMonitorSmartphone } from "react-icons/lu";
import Container from "@/components/Container";
import Card from "@/components/Card";
import { LuMonitorSmartphone } from "react-icons/lu";
import { cx } from "@/cva.config";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "../api";
import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button";
interface NavbarProps {
@ -32,28 +36,26 @@ export default function DashboardNavbar({
picture,
kvmName,
}: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
const onLogout = useCallback(async () => {
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 +76,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}
@ -87,7 +89,7 @@ export default function DashboardNavbar({
<div className="hidden w-[159px] md:block">
<USBStateStatus
state={usbState}
peerConnectionState={peerConnectionState}
peerConnectionState={peerConnectionState}
/>
</div>
</div>
@ -104,66 +106,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

@ -1,3 +1,5 @@
import { useEffect, useState } from "react";
import { cx } from "@/cva.config";
import {
useHidStore,
@ -7,7 +9,6 @@ import {
useVideoStore,
useKeyboardMappingsStore,
} from "@/hooks/stores";
import { useEffect, useState } from "react";
export default function InfoBar() {
const [keys, setKeys] = useState(useKeyboardMappingsStore.keys);
@ -25,6 +26,7 @@ export default function InfoBar() {
const activeModifiers = useHidStore(state => state.activeModifiers);
const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY);
const mouseMove = useMouseStore(state => state.mouseMove);
const videoClientSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
@ -73,7 +75,7 @@ export default function InfoBar() {
</div>
) : null}
{settings.debugMode ? (
{(settings.debugMode && settings.mouseMode == "absolute") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs">
@ -82,6 +84,17 @@ export default function InfoBar() {
</div>
) : null}
{(settings.debugMode && settings.mouseMode == "relative") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Last Move:</span>
<span className="text-xs">
{mouseMove ?
`${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` :
"N/A"}
</span>
</div>
) : null}
{settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span>

View File

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

View File

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

@ -0,0 +1,48 @@
import { useEffect } from "react";
import { LuCommand } from "react-icons/lu";
import { Button } from "@components/Button";
import Container from "@components/Container";
import { useMacrosStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
export default function MacroBar() {
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
const { executeMacro } = useKeyboard();
const [send] = useJsonRpc();
useEffect(() => {
setSendFn(send);
if (!initialized) {
loadMacros();
}
}, [initialized, loadMacros, setSendFn, send]);
if (macros.length === 0) {
return null;
}
return (
<Container className="bg-white dark:bg-slate-900 border-b border-b-slate-800/20 dark:border-b-slate-300/20">
<div className="flex items-center gap-x-2 py-1.5">
<div className="absolute -ml-5">
<LuCommand className="h-4 w-4 text-slate-500" />
</div>
<div className="flex flex-wrap gap-2">
{macros.map(macro => (
<Button
key={macro.id}
aria-label={macro.name}
size="XS"
theme="light"
text={macro.name}
onClick={() => executeMacro(macro.steps)}
/>
))}
</div>
</div>
</Container>
);
}

View File

@ -0,0 +1,271 @@
import { useState } from "react";
import { LuPlus } from "react-icons/lu";
import { KeySequence } from "@/hooks/stores";
import { Button } from "@/components/Button";
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import Fieldset from "@/components/Fieldset";
import { MacroStepCard } from "@/components/MacroStepCard";
import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel";
interface ValidationErrors {
name?: string;
steps?: Record<number, {
keys?: string;
modifiers?: string;
delay?: string;
}>;
}
interface MacroFormProps {
initialData: Partial<KeySequence>;
onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
onCancel: () => void;
isSubmitting?: boolean;
submitText?: string;
}
export function MacroForm({
initialData,
onSubmit,
onCancel,
isSubmitting = false,
submitText = "Save Macro",
}: MacroFormProps) {
const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
const [errors, setErrors] = useState<ValidationErrors>({});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const showTemporaryError = (message: string) => {
setErrorMessage(message);
setTimeout(() => setErrorMessage(null), 3000);
};
const validateForm = (): boolean => {
const newErrors: ValidationErrors = {};
// Name validation
if (!macro.name?.trim()) {
newErrors.name = "Name is required";
} else if (macro.name.trim().length > 50) {
newErrors.name = "Name must be less than 50 characters";
}
if (!macro.steps?.length) {
newErrors.steps = { 0: { keys: "At least one step is required" } };
} else {
const hasKeyOrModifier = macro.steps.some(step =>
(step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
);
if (!hasKeyOrModifier) {
newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
showTemporaryError("Please fix the validation errors");
return;
}
try {
await onSubmit(macro);
} catch (error) {
if (error instanceof Error) {
showTemporaryError(error.message);
} else {
showTemporaryError("An error occurred while saving");
}
}
};
const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
const newSteps = [...(macro.steps || [])];
if (!newSteps[stepIndex]) return;
if (option.keys) {
newSteps[stepIndex].keys = option.keys;
} else if (option.value) {
if (!newSteps[stepIndex].keys) {
newSteps[stepIndex].keys = [];
}
const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
if (keysArray.length >= MAX_KEYS_PER_STEP) {
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
return;
}
newSteps[stepIndex].keys = [...keysArray, option.value];
}
setMacro({ ...macro, steps: newSteps });
if (errors.steps?.[stepIndex]?.keys) {
const newErrors = { ...errors };
delete newErrors.steps?.[stepIndex].keys;
if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
delete newErrors.steps?.[stepIndex];
}
if (Object.keys(newErrors.steps || {}).length === 0) {
delete newErrors.steps;
}
setErrors(newErrors);
}
};
const handleKeyQueryChange = (stepIndex: number, query: string) => {
setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
};
const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
const newSteps = [...(macro.steps || [])];
newSteps[stepIndex].modifiers = modifiers;
setMacro({ ...macro, steps: newSteps });
// Clear step errors when modifiers are added
if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
const newErrors = { ...errors };
delete newErrors.steps?.[stepIndex].keys;
if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
delete newErrors.steps?.[stepIndex];
}
if (Object.keys(newErrors.steps || {}).length === 0) {
delete newErrors.steps;
}
setErrors(newErrors);
}
};
const handleDelayChange = (stepIndex: number, delay: number) => {
const newSteps = [...(macro.steps || [])];
newSteps[stepIndex].delay = delay;
setMacro({ ...macro, steps: newSteps });
};
const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
const newSteps = [...(macro.steps || [])];
const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
[newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
setMacro({ ...macro, steps: newSteps });
};
const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
return (
<>
<div className="space-y-4">
<Fieldset>
<InputFieldWithLabel
type="text"
label="Macro Name"
placeholder="Macro Name"
value={macro.name}
error={errors.name}
onChange={e => {
setMacro(prev => ({ ...prev, name: e.target.value }));
if (errors.name) {
const newErrors = { ...errors };
delete newErrors.name;
setErrors(newErrors);
}
}}
/>
</Fieldset>
<div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<FieldLabel label="Steps" description={`Keys/modifiers executed in sequence with a delay between each step.`} />
</div>
<span className="text-slate-500 dark:text-slate-400">
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
</span>
</div>
{errors.steps && errors.steps[0]?.keys && (
<div className="mt-2">
<FieldError error={errors.steps[0].keys} />
</div>
)}
<Fieldset>
<div className="mt-2 space-y-4">
{(macro.steps || []).map((step, stepIndex) => (
<MacroStepCard
key={stepIndex}
step={step}
stepIndex={stepIndex}
onDelete={macro.steps && macro.steps.length > 1 ? () => {
const newSteps = [...(macro.steps || [])];
newSteps.splice(stepIndex, 1);
setMacro(prev => ({ ...prev, steps: newSteps }));
} : undefined}
onMoveUp={() => handleStepMove(stepIndex, 'up')}
onMoveDown={() => handleStepMove(stepIndex, 'down')}
onKeySelect={(option) => handleKeySelect(stepIndex, option)}
onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
keyQuery={keyQueries[stepIndex] || ''}
onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
/>
))}
</div>
</Fieldset>
<div className="mt-4">
<Button
size="MD"
theme="light"
fullWidth
LeadingIcon={LuPlus}
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
onClick={() => {
if (isMaxStepsReached) {
showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
return;
}
setMacro(prev => ({
...prev,
steps: [
...(prev.steps || []),
{ keys: [], modifiers: [], delay: DEFAULT_DELAY }
],
}));
setErrors({});
}}
disabled={isMaxStepsReached}
/>
</div>
{errorMessage && (
<div className="mt-4">
<FieldError error={errorMessage} />
</div>
)}
<div className="mt-6 flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={isSubmitting ? "Saving..." : submitText}
onClick={handleSubmit}
disabled={isSubmitting}
/>
<Button
size="SM"
theme="light"
text="Cancel"
onClick={onCancel}
/>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,236 @@
import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
import { Button } from "@/components/Button";
import { Combobox } from "@/components/Combobox";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import Card from "@/components/Card";
import { keyDisplayMap } from "@/keyboardMappings/KeyboardLayouts";
import { keysUS, modifiersUS } from '../keyboardMappings/layouts/us';
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel";
// Filter out modifier keys since they're handled in the modifiers section
const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
const keyOptions = Object.keys(keysUS)
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
.map(key => ({
value: key,
label: keyDisplayMap[key] || key,
}));
const modifierOptions = Object.keys(modifiersUS).map(modifier => ({
value: modifier,
label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
}));
const groupedModifiers: Record<string, typeof modifierOptions> = {
Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
};
const basePresetDelays = [
{ value: "50", label: "50ms" },
{ value: "100", label: "100ms" },
{ value: "200", label: "200ms" },
{ value: "300", label: "300ms" },
{ value: "500", label: "500ms" },
{ value: "750", label: "750ms" },
{ value: "1000", label: "1000ms" },
{ value: "1500", label: "1500ms" },
{ value: "2000", label: "2000ms" },
];
const PRESET_DELAYS = basePresetDelays.map(delay => {
if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
return { ...delay, label: "Default" };
}
return delay;
});
interface MacroStep {
keys: string[];
modifiers: string[];
delay: number;
}
interface MacroStepCardProps {
step: MacroStep;
stepIndex: number;
onDelete?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
onKeyQueryChange: (query: string) => void;
keyQuery: string;
onModifierChange: (modifiers: string[]) => void;
onDelayChange: (delay: number) => void;
isLastStep: boolean;
}
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
return Array.isArray(arr) ? arr : [];
};
export function MacroStepCard({
step,
stepIndex,
onDelete,
onMoveUp,
onMoveDown,
onKeySelect,
onKeyQueryChange,
keyQuery,
onModifierChange,
onDelayChange,
isLastStep
}: MacroStepCardProps) {
const getFilteredKeys = () => {
const selectedKeys = ensureArray(step.keys);
const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
if (keyQuery === '') {
return availableKeys;
} else {
return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
}
};
return (
<Card className="p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="flex h-6 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{stepIndex + 1}
</span>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-1">
<Button
size="XS"
theme="light"
onClick={onMoveUp}
disabled={stepIndex === 0}
LeadingIcon={LuArrowUp}
/>
<Button
size="XS"
theme="light"
onClick={onMoveDown}
disabled={isLastStep}
LeadingIcon={LuArrowDown}
/>
</div>
{onDelete && (
<Button
size="XS"
theme="light"
className="text-red-500 dark:text-red-400"
text="Delete"
LeadingIcon={LuTrash2}
onClick={onDelete}
/>
)}
</div>
</div>
<div className="space-y-4 mt-2">
<div className="w-full flex flex-col gap-2">
<FieldLabel label="Modifiers" />
<div className="inline-flex flex-wrap gap-3">
{Object.entries(groupedModifiers).map(([group, mods]) => (
<div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
<span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
{group}
</span>
<div className="flex flex-wrap gap-4 pt-1">
{mods.map(option => (
<Button
key={option.value}
size="XS"
theme={ensureArray(step.modifiers).includes(option.value) ? "primary" : "light"}
text={option.label.split(' ')[1] || option.label}
onClick={() => {
const modifiersArray = ensureArray(step.modifiers);
const isSelected = modifiersArray.includes(option.value);
const newModifiers = isSelected
? modifiersArray.filter(m => m !== option.value)
: [...modifiersArray, option.value];
onModifierChange(newModifiers);
}}
/>
))}
</div>
</div>
))}
</div>
</div>
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
</div>
{ensureArray(step.keys) && step.keys.length > 0 && (
<div className="flex flex-wrap gap-1 pb-2">
{step.keys.map((key, keyIndex) => (
<span
key={keyIndex}
className="inline-flex items-center py-0.5 rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
>
<span className="px-1">
{keyDisplayMap[key] || key}
</span>
<Button
size="XS"
className=""
theme="blank"
onClick={() => {
const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
onKeySelect({ value: null, keys: newKeys });
}}
LeadingIcon={LuX}
/>
</span>
))}
</div>
)}
<div className="relative w-full">
<Combobox
onChange={(value: { value: string; label: string }) => {
onKeySelect(value);
onKeyQueryChange('');
}}
displayValue={() => keyQuery}
onInputChange={onKeyQueryChange}
options={getFilteredKeys}
disabledMessage="Max keys reached"
size="SM"
immediate
disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
emptyMessage="No matching keys found"
/>
</div>
</div>
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel label="Step Duration" description="Time to wait before executing the next step." />
</div>
<div className="flex items-center gap-3">
<SelectMenuBasic
size="SM"
fullWidth
value={step.delay.toString()}
onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
options={PRESET_DELAYS}
/>
</div>
</div>
</div>
</Card>
);
}

View File

@ -1,8 +1,9 @@
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,
@ -14,25 +15,28 @@ export default function Modal({
onClose: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} className="relative z-10">
<Dialog open={open} onClose={onClose} className="relative z-20">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 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">
<div className="fixed inset-0 z-20 w-screen overflow-y-auto">
{/* TODO: This doesn't work well with other-sessions */}
<div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4">
<DialogPanel
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 +46,6 @@ export default function Modal({
</div>
</Dialog>
);
}
});
export default Modal;

View File

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

View File

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

View File

@ -1,9 +1,12 @@
import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "./Card";
import FieldLabel from "@/components/FieldLabel";
import { cva } from "@/cva.config";
import Card from "./Card";
type SelectMenuProps = Pick<
JSX.IntrinsicElements["select"],
"disabled" | "onChange" | "name" | "value"
@ -19,11 +22,12 @@ 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",
SM: "h-[32px] pl-3 pr-8 text-[13px]",
SM_Wide: "h-[32px] pl-3 pr-8 mr-5 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
};
@ -60,7 +64,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 +73,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 +89,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,10 +1,11 @@
import Container from "@/components/Container";
import { Link } from "react-router-dom";
import React from "react";
import Container from "@/components/Container";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
type Props = { logoHref?: string; actionElement?: React.ReactNode };
interface Props { logoHref?: string; actionElement?: React.ReactNode }
export default function SimpleNavbar({ logoHref, actionElement }: Props) {
return (

View File

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

View File

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

View File

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

View File

@ -1,32 +1,192 @@
import "react-simple-keyboard/build/css/index.css";
import { useUiStore, useRTCStore } from "@/hooks/stores";
import { XTerm } from "./Xterm";
import { Button } from "./Button";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { cx } from "../cva.config";
import { Transition } from "@headlessui/react";
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);
import { cx } from "@/cva.config";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
import { Button } from "./Button";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Terminal theme configuration
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 +194,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

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

View File

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

@ -1,26 +1,22 @@
import { cx } from "@/cva.config";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
import { UpdateState } from "@/hooks/stores";
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 +33,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,240 @@
import { useCallback , useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
import Checkbox from "./Checkbox";
import { Button } from "./Button";
import { SelectMenuBasic } from "./SelectMenuBasic";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import Fieldset from "./Fieldset";
export interface USBConfig {
vendor_id: string;
product_id: string;
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,
};
const usbPresets = [
{
label: "Keyboard, Mouse and Mass Storage",
value: "default",
config: {
keyboard: true,
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
},
},
{
label: "Keyboard Only",
value: "keyboard_only",
config: {
keyboard: true,
absolute_mouse: false,
relative_mouse: false,
mass_storage: false,
},
},
{
label: "Custom",
value: "custom",
},
];
export function UsbDeviceSetting() {
const [send] = useJsonRpc();
const [loading, setLoading] = useState(false);
const [usbDeviceConfig, setUsbDeviceConfig] =
useState<UsbDeviceConfig>(defaultUsbDeviceConfig);
const [selectedPreset, setSelectedPreset] = useState<string>("default");
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 {
const usbConfigState = resp.result as UsbDeviceConfig;
setUsbDeviceConfig(usbConfigState);
// Set the appropriate preset based on current config
const matchingPreset = usbPresets.find(
preset =>
preset.value !== "custom" &&
preset.config &&
Object.keys(preset.config).length === Object.keys(usbConfigState).length &&
Object.keys(preset.config).every(key => {
const configKey = key as keyof typeof preset.config;
return preset.config[configKey] === usbConfigState[configKey];
}),
);
setSelectedPreset(matchingPreset ? matchingPreset.value : "custom");
}
});
}, [send]);
const handleUsbConfigChange = useCallback(
(devices: UsbDeviceConfig) => {
setLoading(true);
send("setUsbDevices", { devices }, async resp => {
if ("error" in resp) {
notifications.error(
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`,
);
setLoading(false);
return;
}
// We need some time to ensure the USB devices are updated
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
syncUsbDeviceConfig();
notifications.success(`USB Devices updated`);
});
},
[send, syncUsbDeviceConfig],
);
const onUsbConfigItemChange = useCallback(
(key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => {
setUsbDeviceConfig(prev => ({
...prev,
[key]: e.target.checked,
}));
},
[],
);
const handlePresetChange = useCallback(
async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newPreset = e.target.value;
setSelectedPreset(newPreset);
if (newPreset !== "custom") {
const presetConfig = usbPresets.find(
preset => preset.value === newPreset,
)?.config;
if (presetConfig) {
handleUsbConfigChange(presetConfig);
}
}
},
[handleUsbConfigChange],
);
useEffect(() => {
syncUsbDeviceConfig();
}, [syncUsbDeviceConfig]);
return (
<Fieldset disabled={loading} className="space-y-4">
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsSectionHeader
title="USB Device"
description="USB devices to emulate on the target computer"
/>
<SettingsItem
loading={loading}
title="Classes"
description="USB device classes in the composite device"
>
<SelectMenuBasic
size="SM"
label=""
className="max-w-[292px]"
value={selectedPreset}
fullWidth
onChange={handlePresetChange}
options={usbPresets}
/>
</SettingsItem>
{selectedPreset === "custom" && (
<div className="ml-2 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 ">
<div className="space-y-4">
<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>
</div>
<div className="mt-6 flex gap-x-2">
<Button
size="SM"
loading={loading}
theme="primary"
text="Update USB Classes"
onClick={() => handleUsbConfigChange(usbDeviceConfig)}
/>
<Button
size="SM"
theme="light"
text="Restore to Default"
onClick={() => handleUsbConfigChange(defaultUsbDeviceConfig)}
/>
</div>
</div>
)}
</Fieldset>
);
}

View File

@ -0,0 +1,303 @@
import { useMemo , useCallback , useEffect, useState } from "react";
import { Button } from "@components/Button";
import { UsbConfigState } from "../hooks/stores";
import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
import { InputFieldWithLabel } from "./InputField";
import { SelectMenuBasic } from "./SelectMenuBasic";
import Fieldset from "./Fieldset";
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 UsbInfoSetting() {
const [send] = useJsonRpc();
const [loading, setLoading] = useState(false);
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) => {
setLoading(true);
send("setUsbConfig", { usbConfig }, async resp => {
if ("error" in resp) {
notifications.error(
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
);
setLoading(false);
return;
}
// We need some time to ensure the USB devices are updated
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
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 (
<Fieldset disabled={loading} className="space-y-4">
<SettingsItem
loading={loading}
title="Identifiers"
description="USB device identifiers exposed to the target computer"
>
<SelectMenuBasic
size="SM"
label=""
className="max-w-[192px]"
value={usbConfigProduct}
fullWidth
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" && (
<div className="ml-2 space-y-4 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 ">
<USBConfigDialog
loading={loading}
onSetUsbConfig={usbConfig => handleUsbConfigChange(usbConfig)}
onRestoreToDefault={() =>
handleUsbConfigChange(usbConfigData[usbConfigs[0].value])
}
/>
</div>
)}
</Fieldset>
);
}
function USBConfigDialog({
loading,
onSetUsbConfig,
onRestoreToDefault,
}: {
loading: boolean;
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="">
<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="mt-6 flex gap-x-2">
<Button
loading={loading}
size="SM"
theme="primary"
text="Update USB Identifiers"
onClick={() => onSetUsbConfig(usbConfigState)}
/>
<Button
size="SM"
theme="light"
text="Restore to Default"
onClick={onRestoreToDefault}
/>
</div>
</div>
);
}

View File

@ -1,10 +1,12 @@
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 { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion";
import { LuPlay } from "react-icons/lu";
import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner";
import { GridCard } from "@components/Card";
import Card, { GridCard } from "@components/Card";
interface OverlayContentProps {
children: React.ReactNode;
@ -12,7 +14,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>
@ -23,78 +25,183 @@ interface LoadingOverlayProps {
show: boolean;
}
export function LoadingOverlay({ show }: LoadingOverlayProps) {
export function LoadingVideoOverlay({ 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="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>
);
}
interface LoadingConnectionOverlayProps {
show: boolean;
text: string;
}
export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.4,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-center justify-center gap-y-1">
<div className="animate flex h-12 w-12 items-center justify-center">
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
{text}
</p>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}
interface ConnectionErrorOverlayProps {
show: boolean;
setupPeerConnection: () => Promise<void>;
}
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
export function ConnectionFailedOverlay({
show,
setupPeerConnection,
}: 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="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.4,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
</ul>
</div>
<div className="flex items-center gap-x-2">
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="primary"
text="Troubleshooting Guide"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
<Button
onClick={() => setupPeerConnection()}
LeadingIcon={ArrowPathIcon}
text="Try again"
size="SM"
theme="light"
/>
</div>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}
interface PeerConnectionDisconnectedOverlay {
show: boolean;
}
export function PeerConnectionDisconnectedOverlay({
show,
}: PeerConnectionDisconnectedOverlay) {
return (
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.4,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
</ul>
</div>
<div className="flex items-center gap-x-2">
<Card>
<div className="flex items-center gap-x-2 p-4">
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
<p className="text-sm text-slate-700 dark:text-slate-300">
Retrying connection...
</p>
</div>
</Card>
</div>
</div>
</div>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}
@ -109,85 +216,145 @@ 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 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">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={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
</>
);
}
interface NoAutoplayPermissionsOverlayProps {
show: boolean;
onPlayClick: () => void;
}
export function NoAutoplayPermissionsOverlay({
show,
onPlayClick,
}: NoAutoplayPermissionsOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="absolute inset-0 z-10 aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="space-y-4">
<h2 className="text-2xl font-extrabold text-black dark:text-white">
Autoplay permissions required
</h2>
<div className="space-y-2 text-center">
<div>
<Button
size="MD"
theme="primary"
LeadingIcon={LuPlay}
text="Manually start stream"
onClick={onPlayClick}
/>
</div>
<div className="text-xs text-slate-600 dark:text-slate-400">
Please adjust browser settings to enable autoplay
</div>
</div>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -1,12 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion";
import Card from "@components/Card";
// eslint-disable-next-line import/order
import { Button } from "@components/Button";
import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore, useKeyboardMappingsStore } from "@/hooks/stores";
import { Transition } from "@headlessui/react";
import { cx } from "@/cva.config";
import { keyDisplayMap } from "@/keyboardMappings/KeyboardLayouts";
import useKeyboard from "@/hooks/useKeyboard";
import DetachIconRaw from "@/assets/detach-icon.svg";
import AttachIconRaw from "@/assets/attach-icon.svg";
@ -20,21 +25,40 @@ const AttachIcon = ({ className }: { className?: string }) => {
};
function KeyboardWrapper() {
// TODO implement virtual keyboard mapping
const [keys, setKeys] = useState(useKeyboardMappingsStore.keys);
//const [chars, setChars] = useState(useKeyboardMappingsStore.chars);
const [modifiers, setModifiers] = useState(useKeyboardMappingsStore.modifiers);
useEffect(() => {
const unsubscribeKeyboardStore = useKeyboardMappingsStore.subscribe(() => {
setKeys(useKeyboardMappingsStore.keys);
//setChars(useKeyboardMappingsStore.chars);
setModifiers(useKeyboardMappingsStore.modifiers);
});
return unsubscribeKeyboardStore; // Cleanup on unmount
}, []);
const [layoutName, setLayoutName] = useState("default");
const [keys, setKeys] = useState(useKeyboardMappingsStore.keys);
const [chars, setChars] = useState(useKeyboardMappingsStore.chars);
const [modifiers, setModifiers] = useState(useKeyboardMappingsStore.modifiers);
useEffect(() => {
const unsubscribeKeyboardStore = useKeyboardMappingsStore.subscribe(() => {
setKeys(useKeyboardMappingsStore.keys);
setChars(useKeyboardMappingsStore.chars);
setModifiers(useKeyboardMappingsStore.modifiers);
setMappingsEnabled(useKeyboardMappingsStore.getMappingState());
});
return unsubscribeKeyboardStore; // Cleanup on unmount
}, []);
const [layoutName, setLayoutName] = useState("default");
const [mappingsEnabled, setMappingsEnabled] = useState(useKeyboardMappingsStore.getMappingState());
useEffect(() => {
if (mappingsEnabled) {
if (layoutName == "default" ) {
setLayoutName("mappedLower")
}
if (layoutName == "shift") {
setLayoutName("mappedUpper")
}
} else {
if (layoutName == "mappedLower") {
setLayoutName("default")
}
if (layoutName == "mappedUpper") {
setLayoutName("shift")
}
}
}, [mappingsEnabled, layoutName]);
const keyboardRef = useRef<HTMLDivElement>(null);
const showAttachedVirtualKeyboard = useUiStore(
@ -121,16 +145,25 @@ function KeyboardWrapper() {
};
}, [endDrag, onDrag, startDrag]);
// TODO implement meta key and meta key modifer
// TODO implement hold functionality for key combos. (add a hold button, add all keys to an array, when released send as one)
const onKeyDown = useCallback(
(key: string) => {
const cleanKey = key.replace(/[()]/g, "");
// Mappings
const { key: mappedKey, shift, altLeft, altRight } = chars[cleanKey] ?? {};
const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
const isKeyCaps = key === "CapsLock";
const cleanKey = key.replace(/[()]/g, "");
const keyHasShiftModifier = key.includes("(");
const keyHasShiftModifier = (key.includes("(") && key !== "(") || shift;
// Handle toggle of layout for shift or caps lock
const toggleLayout = () => {
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
if (mappingsEnabled) {
setLayoutName(prevLayout => (prevLayout === "mappedLower" ? "mappedUpper" : "mappedLower"));
} else {
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
}
};
if (key === "CtrlAltDelete") {
@ -152,10 +185,17 @@ function KeyboardWrapper() {
return;
}
if (isKeyShift || isKeyCaps) {
if (isKeyShift || (!(layoutName == "shift" || layoutName == "mappedUpper") && isCapsLockActive)) {
toggleLayout();
}
if (isCapsLockActive) {
if (layoutName == "shift" || layoutName == "mappedUpper") {
if (!isCapsLockActive) {
toggleLayout();
}
if (isKeyCaps && isCapsLockActive) {
toggleLayout();
setIsCapsLockActive(false);
sendKeyboardEvent([keys["CapsLock"]], []);
return;
@ -164,25 +204,30 @@ function KeyboardWrapper() {
// Handle caps lock state change
if (isKeyCaps) {
toggleLayout();
setIsCapsLockActive(!isCapsLockActive);
}
// Collect new active keys and modifiers
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
const newKeys = keys[mappedKey ?? cleanKey] ? [keys[mappedKey ?? cleanKey]] : [];
const newModifiers =
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
[
((shift || isKeyShift)? modifiers['ShiftLeft'] : 0),
(altLeft? modifiers['AltLeft'] : 0),
(altRight? modifiers['AltRight'] : 0),
].filter(Boolean);
// Update current keys and modifiers
sendKeyboardEvent(newKeys, newModifiers);
sendKeyboardEvent(newKeys, [...new Set(newModifiers)]);
// If shift was used as a modifier and caps lock is not active, revert to default layout
if (keyHasShiftModifier && !isCapsLockActive) {
setLayoutName("default");
setLayoutName(mappingsEnabled ? "mappedLower" : "default");
}
setTimeout(resetKeyboardState, 100);
},
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive, mappingsEnabled, chars, keys, modifiers, layoutName],
);
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
@ -195,278 +240,169 @@ 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={keyDisplayMap}
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",
],
mappedLower: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"` 1 2 3 4 5 6 7 8 9 0 - = Backspace",
"Tab q w e r t y u i o p [ ] \\",
"CapsLock a s d f g h j k l ; ' Enter",
"ShiftLeft z x c v b n m , . / ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight"
],
mappedUpper: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"~ ! @ # $ % ^ & * ( ) _ + Backspace",
"Tab Q W E R T Y U I O P { } |",
"CapsLock A S D F G H J K L : \" Enter",
"ShiftLeft Z X C V B N M < > ? 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>
);
}
export default KeyboardWrapper;
export default KeyboardWrapper;

View File

@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useDeviceSettingsStore,
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useUiStore,
useVideoStore,
useKeyboardMappingsStore,
} from "@/hooks/stores";
@ -12,11 +13,24 @@ import { useResizeObserver } from "@/hooks/useResizeObserver";
import { cx } from "@/cva.config";
import VirtualKeyboard from "@components/VirtualKeyboard";
import Actionbar from "@components/ActionBar";
import MacroBar from "@/components/MacroBar";
import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay";
import {
HDMIErrorOverlay,
LoadingVideoOverlay,
NoAutoplayPermissionsOverlay,
} from "./VideoOverlay";
// TODO Implement keyboard lock API to resolve #127
// https://developer.chrome.com/docs/capabilities/web-apis/keyboard-lock
// An appropriate error message will need to be displayed in order to alert users to browser compatibility issues.
// This requires TLS, waiting on TLS support.
// TODO Implement keyboard mapping setup in initial JetKVM setup
export default function WebRTCVideo() {
const [keys, setKeys] = useState(useKeyboardMappingsStore.keys);
const [chars, setChars] = useState(useKeyboardMappingsStore.chars);
@ -38,29 +52,31 @@ export default function WebRTCVideo() {
const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// Store hooks
const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition);
const setMouseMove = useMouseStore(state => state.setMouseMove);
const {
setClientSize: setVideoClientSize,
setSize: setVideoSize,
width: videoWidth,
height: videoHeight,
clientWidth: videoClientWidth,
clientHeight: videoClientHeight,
} = useVideoStore();
// RTC related states
const peerConnection = useRTCStore(state => state.peerConnection);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isLoading = !hdmiError && !isPlaying;
const isConnectionError = ["error", "failed", "disconnected"].includes(
peerConnectionState || "",
);
const isVideoLoading = !isPlaying;
// console.log("peerConnection?.connectionState", peerConnection?.connectionState);
// Keyboard related states
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
@ -93,61 +109,124 @@ export default function WebRTCVideo() {
const onVideoPlaying = useCallback(() => {
setIsPlaying(true);
videoElm.current && updateVideoSizeStore(videoElm.current);
if (videoElm.current) updateVideoSizeStore(videoElm.current);
}, [updateVideoSizeStore]);
// On mount, get the video size
useEffect(
function updateVideoSizeOnMount() {
videoElm.current && updateVideoSizeStore(videoElm.current);
if (videoElm.current) updateVideoSizeStore(videoElm.current);
},
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
);
// Mouse-related
const sendMouseMovement = useCallback(
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
send("absMouseReport", { x, y, buttons });
if (settings.mouseMode !== "relative") return;
// if we ignore the event, double-click will not work
// if (x === 0 && y === 0 && buttons === 0) return;
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
setMouseMove({ x, y, buttons });
},
[send, setMouseMove, settings.mouseMode],
);
const relMouseMoveHandler = useCallback(
(e: MouseEvent) => {
if (settings.mouseMode !== "relative") return;
// Send mouse movement
const { buttons } = e;
sendRelMouseMovement(e.movementX, e.movementY, buttons);
},
[sendRelMouseMovement, settings.mouseMode],
);
const sendAbsMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "absolute") return;
send("absMouseReport", { x, y, buttons });
// We set that for the debug info bar
setMousePosition(x, y);
},
[send, setMousePosition],
[send, setMousePosition, settings.mouseMode],
);
const mouseMoveHandler = useCallback(
const absMouseMoveHandler = useCallback(
(e: MouseEvent) => {
if (!videoClientWidth || !videoClientHeight) return;
const { buttons } = e;
if (settings.mouseMode !== "absolute") return;
// 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);
// Get the aspect ratios of the video element and the video stream
const videoElementAspectRatio = videoClientWidth / videoClientHeight;
const videoStreamAspectRatio = videoWidth / videoHeight;
// 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);
// Calculate the effective video display area
let effectiveWidth = videoClientWidth;
let effectiveHeight = videoClientHeight;
let offsetX = 0;
let offsetY = 0;
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
sendMouseMovement(x, y, buttons);
const { buttons } = e;
sendAbsMouseMovement(x, y, buttons);
},
[sendMouseMovement, videoClientHeight, videoClientWidth],
[
sendAbsMouseMovement,
videoClientHeight,
videoClientWidth,
videoWidth,
videoHeight,
settings.mouseMode,
],
);
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();
// TODO this should be user controllable
// 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);
@ -155,20 +234,27 @@ export default function WebRTCVideo() {
// Invert the scroll value to match expected behavior
const invertedScroll = -roundedScroll;
// TODO remove debug logs
console.log("wheelReport", { wheelY: invertedScroll });
send("wheelReport", { wheelY: invertedScroll });
// TODO this is making scrolling feel slow and sluggish, also throwing a violation in chrome
// 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(() => {
sendMouseMovement(0, 0, 0);
}, [sendMouseMovement]);
sendAbsMouseMovement(0, 0, 0);
}, [sendAbsMouseMovement]);
// TODO this needs reworked ot work with mappings
// Keyboard-related
@ -176,10 +262,7 @@ export default function WebRTCVideo() {
(e: KeyboardEvent, activeModifiers: number[], mappedKeyModifers: { shift: boolean; altLeft: boolean; altRight: boolean; }) => {
const { shiftKey, ctrlKey, altKey, metaKey } = e;
// TODO remove debug logging
console.log(shiftKey + " " +ctrlKey + " " +altKey + " " +metaKey + " " +mappedKeyModifers.shift + " "+mappedKeyModifers.altLeft + " "+mappedKeyModifers.altRight + " ")
const filteredModifiers = activeModifiers.filter(Boolean);3
const filteredModifiers = activeModifiers.filter(Boolean);
// Example: activeModifiers = [0x01, 0x02, 0x04, 0x08]
// Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft
return (
@ -210,8 +293,13 @@ export default function WebRTCVideo() {
modifier =>
altKey ||
mappedKeyModifers.altLeft ||
(modifier !== modifiers["AltLeft"]),
)
.filter(
modifier =>
altKey ||
mappedKeyModifers.altRight ||
(modifier !== modifiers["AltLeft"] && modifier !== modifiers["AltRight"]),
(modifier !== modifiers["AltRight"])
)
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
// Example: If metaKey is true, keep all modifiers
@ -230,10 +318,8 @@ export default function WebRTCVideo() {
async (e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
let code = e.code;
const localisedKey = e.key;
console.log(e);
console.log("Localised Key: " + localisedKey);
const code = e.code;
var localisedKey = settings.keyboardMappingEnabled ? e.key : code;
// if (document.activeElement?.id !== "videoFocusTrap") {hH
// console.log("KEYUP: Not focusing on the video", document.activeElement);
@ -254,15 +340,9 @@ export default function WebRTCVideo() {
const { key: mappedKey, shift, altLeft, altRight } = chars[localisedKey] ?? { key: code };
//if (!key) continue;
console.log("Mapped Key: " + mappedKey)
console.log("Current KB Layout:" + useKeyboardMappingsStore.getLayout());
console.log(chars[localisedKey]);
console.log("Shift: " + shift + ", altLeft: " + altLeft + ", altRight: " + altRight)
// Add the mapped key to keyState
activeKeyState.current.set(e.code, { mappedKey, modifiers: {shift, altLeft, altRight}});
console.log(activeKeyState)
// Add the key to the active keys
const newKeys = [...prev.activeKeys, keys[mappedKey]].filter(Boolean);
@ -282,12 +362,12 @@ export default function WebRTCVideo() {
// event, so we need to clear the keys after a short delay
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
// TODO add this to the activekey state
// TODO set this to remove from activekeystate as well
if (e.metaKey) {
setTimeout(() => {
const prev = useHidStore.getState();
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
activeKeyState.current.delete("MetaLeft");
activeKeyState.current.delete("MetaRight");
}, 10);
}
@ -302,20 +382,15 @@ export default function WebRTCVideo() {
chars,
keys,
modifiers,
settings,
],
);
const keyUpHandler = useCallback(
(e: KeyboardEvent) => {
e.preventDefault();
console.log(e)
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"));
@ -333,7 +408,6 @@ export default function WebRTCVideo() {
// Handle modifier release
if (isModifierKey) {
console.log("ITS A MODIFER")
// Update all affected keys when this modifier is released
activeKeyState.current.forEach((value, code) => {
const { mappedKey, modifiers: mappedModifiers} = value;
@ -369,14 +443,11 @@ export default function WebRTCVideo() {
.filter(Boolean);
};
});
console.log("prev.activemodifers: " + prev.activeModifiers)
console.log("prev.activemodifers.filtered: " + prev.activeModifiers.filter(k => k !== modifiers[e.code]))
const newModifiers = handleModifierKeys(
e,
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
{shift: false, altLeft: false, altRight: false}
);
console.log("New modifiers in keyup: " + newModifiers)
// Update the keyState
/*activeKeyState.current.delete(code);/*.set(code, {
@ -411,7 +482,6 @@ export default function WebRTCVideo() {
// Filter out the key that was just released
newKeys = newKeys.filter(k => k !== keys[mappedKey]).filter(Boolean);
console.log(activeKeyState)
// Filter out the associated modifier
//const newModifiers = prev.activeModifiers.filter(k => k !== modifier).filter(Boolean);
@ -445,7 +515,6 @@ export default function WebRTCVideo() {
);
*/
console.log(e.key);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
},
[
@ -460,7 +529,68 @@ export default function WebRTCVideo() {
],
);
// Effect hooks
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video
// there is no way to prevent this, so we need to simply force play the video when it's paused.
// Fix only works in chrome based browsers.
if (e.code === "Space") {
if (videoElm.current?.paused == true) {
console.log("Force playing video");
videoElm.current?.play();
}
}
}, []);
const addStreamToVideoElm = useCallback(
(mediaStream: MediaStream) => {
if (!videoElm.current) return;
const videoElmRefValue = videoElm.current;
// console.log("Adding stream to video element", videoElmRefValue);
videoElmRefValue.srcObject = mediaStream;
updateVideoSizeStore(videoElmRefValue);
},
[updateVideoSizeStore],
);
useEffect(
function updateVideoStreamOnNewTrack() {
if (!peerConnection) return;
const abortController = new AbortController();
const signal = abortController.signal;
peerConnection.addEventListener(
"track",
(e: RTCTrackEvent) => {
// console.log("Adding stream to video element");
addStreamToVideoElm(e.streams[0]);
},
{ signal },
);
return () => {
abortController.abort();
};
},
[addStreamToVideoElm, peerConnection],
);
useEffect(
function updateVideoStream() {
if (!mediaStream) return;
console.log("Updating video stream from mediaStream");
// We set the as early as possible
addStreamToVideoElm(mediaStream);
},
[
setVideoClientSize,
mediaStream,
updateVideoSizeStore,
peerConnection,
addStreamToVideoElm,
],
);
// Setup Keyboard Events
useEffect(
function setupKeyboardEvents() {
const abortController = new AbortController();
@ -483,109 +613,147 @@ export default function WebRTCVideo() {
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
);
// Setup Video Event Listeners
useEffect(
function setupVideoEventListeners() {
let videoElmRefValue = null;
if (!videoElm.current) return;
videoElmRefValue = videoElm.current;
const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal });
// To prevent the video from being paused when the user presses a space in fullscreen mode
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal });
videoElmRefValue.addEventListener(
"contextmenu",
(e: MouseEvent) => e.preventDefault(),
{ signal },
);
// We need to know when the video is playing to update state and video size
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
return () => {
if (videoElmRefValue) abortController.abort();
abortController.abort();
};
},
[mouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler],
);
useEffect(
function updateVideoStream() {
if (!mediaStream) return;
if (!videoElm.current) return;
if (peerConnection?.iceConnectionState !== "connected") return;
setTimeout(() => {
if (videoElm?.current) {
videoElm.current.srcObject = mediaStream;
}
}, 0);
updateVideoSizeStore(videoElm.current);
},
[
setVideoClientSize,
setVideoSize,
mediaStream,
updateVideoSizeStore,
peerConnection?.iceConnectionState,
absMouseMoveHandler,
resetMousePosition,
onVideoPlaying,
mouseWheelHandler,
videoKeyUpHandler,
],
);
// 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);
// Setup Absolute Mouse Events
useEffect(
function setAbsoluteMouseModeEventListeners() {
const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return;
// 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]);
if (settings.mouseMode !== "absolute") return;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
// Reset the mouse position when the window is blurred or the document is hidden
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal });
return () => {
abortController.abort();
};
},
[absMouseMoveHandler, mouseWheelHandler, resetMousePosition, settings.mouseMode],
);
// Setup Relative Mouse Events
const containerRef = useRef<HTMLDivElement>(null);
useEffect(
function setupRelativeMouseEventListeners() {
if (settings.mouseMode !== "relative") return;
const abortController = new AbortController();
const signal = abortController.signal;
// We bind to the larger container in relative mode because of delta between the acceleration of the local
// mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use.
// When we get Pointer Lock support, we can remove this.
const containerElm = containerRef.current;
if (!containerElm) return;
containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal });
containerElm.addEventListener("pointerdown", relMouseMoveHandler, { signal });
containerElm.addEventListener("pointerup", relMouseMoveHandler, { signal });
containerElm.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
containerElm.addEventListener("contextmenu", preventContextMenu, { signal });
return () => {
abortController.abort();
};
},
[settings.mouseMode, relMouseMoveHandler, mouseWheelHandler],
);
const hasNoAutoPlayPermissions = useMemo(() => {
if (peerConnection?.connectionState !== "connected") return false;
if (isPlaying) return false;
if (hdmiError) return false;
if (videoHeight === 0 || videoWidth === 0) return false;
return true;
}, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]);
return (
<div className="grid w-full h-full grid-rows-layout">
<div className="min-h-[39.5px]">
<fieldset disabled={peerConnectionState !== "connected"}>
<Actionbar
requestFullscreen={async () =>
videoElm.current?.requestFullscreen({
navigationUI: "show",
})
}
/>
</fieldset>
<div className="grid h-full w-full grid-rows-layout">
<div className="min-h-[39.5px] flex flex-col">
<div className="flex flex-col">
<fieldset disabled={peerConnection?.connectionState !== "connected"} className="contents">
<Actionbar
requestFullscreen={async () =>
videoElm.current?.requestFullscreen({
navigationUI: "show",
})
}
/>
<MacroBar />
</fieldset>
</div>
</div>
<div className="h-full overflow-hidden">
<div
ref={containerRef}
className={cx("h-full overflow-hidden", {
"cursor-none": settings.mouseMode === "relative" && settings.isCursorHidden,
})}
>
<div className="relative h-full">
<div
className={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}
@ -597,25 +765,37 @@ 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":
"cursor-none":
settings.mouseMode === "absolute" &&
settings.isCursorHidden,
"opacity-0":
isVideoLoading ||
hdmiError ||
peerConnectionState !== "connected",
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
isPlaying,
},
)}
/>
<div
style={{ animationDuration: "500ms" }}
className="absolute inset-0 flex items-center justify-center opacity-0 pointer-events-none animate-slideUpFade"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<LoadingOverlay show={isLoading} />
<ConnectionErrorOverlay show={isConnectionError} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
{peerConnection?.connectionState == "connected" && (
<div
style={{ animationDuration: "500ms" }}
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<LoadingVideoOverlay show={isVideoLoading} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
}}
/>
</div>
</div>
</div>
)}
</div>
</div>
<VirtualKeyboard />

View File

@ -1,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>
);
}

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