Compare commits

..

29 Commits

Author SHA1 Message Date
Siyuan Miao e24f3c95cd refactor: use less data to transfer keyboard and mouse events [WIP DO NOT MERGE] 2025-08-29 12:53:36 +02:00
Marc Brooks 94521ef6db
chore/Deprecate browser mount (#752)
* chore/Deprecate browser mount

No longer supported.

* Remove device-side go code

* Removed diskChannel and localFile

* Removed RemoteVirtualMediaState.WebRTC

Also removed dead go code (to make that lint happy!)
2025-08-28 23:46:55 +02:00
Marc Brooks 66cccfe9e1
Add application icon for Safari and saved-bookmarks (#749) 2025-08-28 12:01:04 +02:00
Adam Shiervani a42384fed6
enhancement: add new EDID for DELL iDRAC (#693) 2025-08-27 10:16:17 +02:00
Marc Brooks 3ec243255b
Add ability to track modifier state on the device (#725)
Remove LED sync source option and add keypress reporting while still working with devices that haven't been upgraded
We return the modifiers as the valid bitmask so that the VirtualKeyboard and InfoBar can represent the correct keys as down. This is important when we have strokes like Left-Control + Right-Control + Keypad-1 (used in switching KVMs and such).
Fix handling of modifier keys in client and also removed the extraneous resetKeyboardState.
Manage state to eliminate rerenders by judicious use of useMemo.
Centralized keyboard layout and localized display maps
Move keyboardOptions to useKeyboardLayouts
Added translations for display maps.
Add documentation on the legacy support.

Return the KeysDownState from keyboardReport
Clear out the hidErrorRollOver once sent to reset the keyboard to nothing down.
Handles the returned KeysDownState from keyboardReport
Now passes all logic through handleKeyPress.
If we get a state back from a keyboardReport, use it and also enable keypressReport because we now know it's an upgraded device.
Added exposition on isoCode management

Fix de-DE chars to reflect German E2 keyboard.
https://kbdlayout.info/kbdgre2/overview+virtualkeys

Ran go modernize
Morphs Interface{} to any
Ranges over SplitSeq and FieldSeq for iterating splits
Used min for end calculation remote_mount.Read
Used range 16 in wol.createMagicPacket
DID NOT apply the Omitempty cleanup.

Strong typed in the typescript realm.
Cleanup react state management to enable upgrading Zustand
2025-08-26 17:09:35 +02:00
Marc Brooks 05bf61152b
feature/Faster loading (#746)
* feature/Faster loading

This refactors all the hot-path components for an already-setup JetKVM so that we only
lazy-load the components off the main path. This greatly reduces the initial .JS size at
initial page load from a single file of

dist/assets/index-D4LZBdmN.js                          1,969.46 kB │ gzip: 570.08 kB

To these files, of which the hot-path only loads the 963.29 kB index for a savings of
just over a megabyte (180kb savings in gzip).

dist/assets/login-DA9KVVX1.js                                     0.64 kB │ gzip:   0.40 kB
dist/assets/signup-Bb_VCzY1.js                                    0.67 kB │ gzip:   0.40 kB
dist/assets/devices._id.settings.macros.add-DpBnq5E0.js           0.82 kB │ gzip:   0.55 kB
dist/assets/devices._id.settings.appearance-VHd5B2H2.js           0.91 kB │ gzip:   0.52 kB
dist/assets/devices._id.settings.general.reboot-DsRBP5Dd.js       1.01 kB │ gzip:   0.52 kB
dist/assets/UpdateInProgressStatusCard-DJCdJo-z.js                1.05 kB │ gzip:   0.54 kB
dist/assets/devices._id.other-session-BpXjEP6K.js                 1.09 kB │ gzip:   0.56 kB
dist/assets/devices.already-adopted-BC1xoKrN.js                   1.16 kB │ gzip:   0.57 kB
dist/assets/Checkbox-DGO277w5.js                                  1.24 kB │ gzip:   0.64 kB
dist/assets/devices._id.settings.keyboard-Cno0kaUr.js             1.59 kB │ gzip:   0.81 kB
dist/assets/devices._id.settings.general._index-CNW0Pj5B.js       1.71 kB │ gzip:   0.76 kB
dist/assets/devices._id.settings.macros.edit-BYQGw2CJ.js          1.92 kB │ gzip:   1.00 kB
dist/assets/ConfirmDialog-lzerZkf7.js                             2.77 kB │ gzip:   1.13 kB
dist/assets/AuthLayout-H4vGP3TU.js                                2.96 kB │ gzip:   1.41 kB
dist/assets/AutoHeight-B-TU1fRg.js                                4.07 kB │ gzip:   1.63 kB
dist/assets/devices._id.settings.video-O3qJWstQ.js                5.68 kB │ gzip:   2.17 kB
dist/assets/devices._id.settings.advanced-Drd_iPzw.js             5.98 kB │ gzip:   2.08 kB
dist/assets/devices._id.settings.macros-D3unB0uf.js               6.05 kB │ gzip:   2.13 kB
dist/assets/devices._id.settings.access.local-auth-BltQI66N.js    6.17 kB │ gzip:   1.54 kB
dist/assets/devices._id.settings.mouse-CAwDHqxl.js               10.02 kB │ gzip:   3.59 kB
dist/assets/devices._id.settings.general.update-jkzXML1U.js      10.22 kB │ gzip:   2.67 kB
dist/assets/devices._id.settings.hardware-B7v3lfwA.js            10.41 kB │ gzip:   3.03 kB
dist/assets/devices._id.settings.network-CJYfzFt2.js             25.23 kB │ gzip:   7.21 kB
dist/assets/devices._id.mount-4AT1reig.js                        43.92 kB │ gzip:  19.81 kB
dist/assets/MacroForm-BQpdQgFn.js                                49.75 kB │ gzip:  16.25 kB
dist/assets/connectionStats-NM-PZeH3.js                         400.14 kB │ gzip: 110.33 kB
dist/assets/Terminal-Dgo3sfr-.js                                425.05 kB │ gzip: 109.49 kB
dist/assets/index-w6H2Mz3f.js                                   963.29 kB │ gzip: 294.20 kB

* Remove feral async declarations on things that have no await
2025-08-26 16:55:08 +02:00
Marc Brooks d952480c2a
fix: useJsonRpc "any" issue
PR #743 didn't have all the files included in the commit.
Mea culpa, many apologies.
2025-08-24 11:40:43 +02:00
Alex Ballas 8e27cd6b60
chore: ensure that rpc messages get processed sequentially and avoid phantom and repeated key presses (#744) 2025-08-22 20:15:46 +02:00
Marc Brooks bb87fb5a1a
fix: compiler error (#743)
Using { send } gives the resp a type instead of any
2025-08-22 12:15:27 +02:00
Adam Shiervani 8527b1eff1
feat: improve custom jiggler settings and add timezone support (#742)
* feat: add timezone support to jiggler and fix custom settings persistence

- Add timezone field to JigglerConfig with comprehensive IANA timezone list
- Fix custom settings not loading current values
- Remove business hours preset, add as examples in custom settings
- Improve error handling for invalid cron expressions

* fix: format jiggler.go with gofmt

* fix: add embedded timezone data and validation

- Import time/tzdata to embed timezone database in binary
- Add timezone validation in runJigglerCronTab() to gracefully fallback to UTC
- Add timezone to debug logging in rpcSetJigglerConfig
- Fixes 'unknown time zone' errors when system lacks timezone data

* refactor: add timezone field comments from jiggler options

* chore: move tzdata to backend

* refactor: fix JigglerSetting linting

- Adjusted useEffect dependency to include send function for better data fetching
- Modified layout classes for improved responsiveness and consistency
- Cleaned up code formatting for better readability

---------

Co-authored-by: Siyuan Miao <i@xswan.net>
2025-08-19 16:50:42 +02:00
Serhii 9f573200b1
Update mount list for new Debian 13 release (#739)
* Update mount list for new Debian 13 release

* Keep Debian 12 Bookworm as old-stable release
2025-08-18 11:20:20 +02:00
Marc Brooks 608f69db13
Lint fix from last merge. (#733) 2025-08-13 11:13:42 +02:00
jackislanding f7b8efde7c
Added crontab scheduler for jiggler (#316) 2025-08-12 20:50:03 +02:00
Marc Brooks 33ac9fe0b6
chore(ui)/package upgrades (#724)
| Package                          | From     | To           |
| -------------------------------- | ----------- | ------------ |
| @headlessui/react        | 2.2.4     | 2.2.7        |
| framer-motion              | 12.23.3 | 12.23.12 |
| react                                | 19.1.0   | 19.1.1     |
| react-dom                       | 19.1.0   | 19.1.1    |
| react-simple-keyboard | 3.8.93   | 3.8.106  |
|@eslint/js                         | 9.30.1   | 9.32.0    |
| @types/react                  | 19.1.8   | 19.1.9    |
| @types/react-dom         | 19.1.8   | 19.1.9   |
|eslint                                 | 9.30.1   | 9.32.0    |
|eslint-config-prettier       | 10.1.5   | 10.1.8   |
| typescript                         |  5.8.3    | 5.9.2     |
2025-08-12 11:24:05 +02:00
Silke pilon 55fbd6c359 docs: add comprehensive DEVELOPMENT.md for JetKVM (#692)
* docs: add comprehensive DEVELOPMENT.md for JetKVM

Add a detailed development guide covering setup, project structure,
and workflows for both full device and frontend-only development.

Include prerequisites, build commands, deployment scripts, environment
variables, and testing instructions to streamline onboarding and
contributions.

This improves developer experience and standardizes development
practices across the project.

* docs: clean up DEVELOPMENT.md by removing outdated sections

Remove the Custom Build Tags and Release Process sections to simplify
the documentation and avoid confusion with deprecated build and release
instructions. Focus the document on current performance profiling steps.

* docs: rewrite DEVELOPMENT.md for clearer setup and usage

Revise the JetKVM development guide to improve clarity and usability.
Simplify the introduction and reorganize prerequisites and setup steps
to help new developers get started quickly. Add explicit instructions for
cloning, tool verification, deployment, and testing. Streamline common
tasks sections with clear commands for UI and backend development,
testing, and log viewing. Update project layout overview for easier
navigation. These changes reduce onboarding friction and enhance the
developer experience.

* docs: remove duplicate "Get Started" header in DEVELOPMENT.md

Clean up the DEVELOPMENT.md file by deleting the repeated
"Get Started" header

* docs: add recommended development environment section

Add guidance recommending Linux or macOS for development and suggest
using WSL on Windows to ensure compatibility with shell scripts and
build tools. This improves the developer experience and reduces setup
issues across different operating systems.

* docs: add links to prerequisites in DEVELOPMENT.md

Update DEVELOPMENT.md to URLs for Go, Node.js, Git, and SSH
access prerequisites. This improves clarity and helps developers quickly
find installation resources.
2025-07-16 00:04:41 +02:00
Aveline cff3ddad29
chore: add issue templates (#686)
* chore: add issue templates

* chore: add remote device info
2025-07-14 18:10:49 +02:00
Marc Brooks b4525b8760
chore/ Fix go lint error (#683) 2025-07-11 23:41:05 +02:00
Marc Brooks 5a3ce2d6ec
chore(ui) Upgrade packages (#682) 2025-07-11 23:41:01 +02:00
Ben Kochie f1953fddbc
chore: add metrics for configuration and WOL (#193)
* Configuration load success/timestamp.
* Wake-on-Lan packets/errors.

Signed-off-by: SuperQ <superq@gmail.com>
2025-07-11 18:14:32 +02:00
Marc Brooks 9ba97ebe67
chore(ui): Clean new keyboard option (#495)
Fixed the Tailwind CSS syntax for `in` (nested) selector
Added missing React dependency for `useEffect`
2025-07-11 17:56:03 +02:00
Marc Brooks 5fb8d866ba
refactor(ui): Refactor the keyboardLayouts (#497)
Add missing keyboard mappings for most layouts
Change  pasteModel.tsx to use the new structure and vastly clarified the way that keys are emitted.
Make each layout export just the KeyboardLayout object (which is a package of isoCode, name, and chars)
Made keyboardLayouts.ts export a function to select keyboard by `isoCode`, export the keyboards as label . value pairs (for a select list) and the list of keyboards.
Changed devices.$id.settings.keyboard.tsx use the exported keyboard option list.
2025-07-11 17:49:06 +02:00
rmschooley 3359f8fca4
Remove Out Endpoint Descriptors from Absolute Mouse and Relative Mouse (#542)
* Update hid_mouse_absolute.go

Added attribute to remove unnecessary out endpoint.

* Update hid_mouse_relative.go

Added attribute to remove unnecessary out endpoint.

* Update hid_keyboard.go

Added attribute to explicitly keep currently needed out endpoint and to make listed attributes consistent across the keyboard and mouse devices.

---------

Co-authored-by: Aveline <352441+ym@users.noreply.github.com>
2025-07-11 17:43:37 +02:00
Daniel Collins ef95643a86
Implement HTTP proxy option (#515). (#521)
This commit adds a "Proxy" field to the network settings screen, which
can be used to specify a HTTP proxy for any outgoing requests from the
device.
2025-07-11 17:43:22 +02:00
Daniel Collins 1fc603b553
Add -i/--install option to dev_deploy.sh (#527)
Running `dev_deploy.sh -i` will build the app in release mode and
install it to the device for longer term development/testing or just
running a custom variant of the app.
2025-07-11 17:09:49 +02:00
Bradley Wilson-Hunt aada3d95e0
feat(metrics): adding prometheus metrics for dc power extension (#556) 2025-07-11 17:04:41 +02:00
Aveline d704fcc6c7
feat: add command to show version (#604)
* feat: add -version flag for jetkvm_app

* move code to kvm package
2025-07-11 11:32:46 +02:00
Siyuan Miao ab3dda6dee chore(network): fix linting error errcheck 2025-07-11 11:30:02 +02:00
Marc Brooks 4a23f22a55
chore: upgrade ui packages (#571)
Move to current on all non-major upgrades
Fixes the tainted hardware WebGL video renderer if video settings are at default (1.0) values

## Runtime

|  Package | From  | To  |
|---|---|---|
| @headlessui/react | 2.2.3 | 2.2.4 |
| @vitejs/plugin-basic-ssl | 2.0.0 | 2.1.0 |
| cva | 1.0.0-beta.3 | 1.0.0-beta.4 |
| focus-trap-react | 11.0.3 | 11.0.4 |
| framer-motion | 12.11.5 | 12.23.0 |
| react-simple-keyboard | 3.8.72 | 3.8.89 |
| tailwind-merge | 3.3.0 | 3.3.1 |
| validator | 13.15.0 | 13.15.15 |

## Dev

|  Package | From  | To  |
|---|---|---|
| @eslint/compat | 1.2.9 | 1.3.1 |
| @eslint/js | 9.26.0 | 9.30.1 |
| @tailwindcss/postcss | 4.1.7 | 4.1.11 |
| @tailwindcss/vite | 4.1.8 | 4.1.10 |
| @types/react | 19.1.4 | 19.1.8  |
| @types/react-dom | 19.1.5 | 19.1.6 |
| @types/validator | 13.15.0 | 13.15.2 |
| @typescript-eslint/eslint-plugin | 8.32.1 | 8.34.0 |
| @typescript-eslint/parser | 8.32.1 | 8.35.1  |
| @vitejs/plugin-react-swc | 3.9.0 | 3.10.2 |
| eslint | 9.26.0 | 9.30.1 |
| globals | 16.1.0 | 16.3.0 |
| postcss  | 8.5.3 | 8.5.6 |
| prettier | 3.5.3 | 3.6.2 |
| prettier-plugin-tailwindcss | 0.6.11 | 0.6.13 |
| tailwindcss | 4.1.7 | 4.1.11 |
2025-07-11 08:06:17 +02:00
Marc Brooks 11a095c0f6
feat(ntp): enhances time sync with DHCP NTP and custom servers (#625)
* Ensure the mDNS mode is set every time network state changes

Eliminates (mostly) duplicate code

* Add custom NTP and HTTP time sync servers

Since the ordering may have been previously defaulted and saved as "ntp,http", but that was being ignored and fallback-defaults were being used, in Ordering, `ntp` means use the fallback NTP servers, and `http` means use the fallback HTTP URLs. Thus `ntp_user_provided` and `http_user_provided` are the user specified static lists.

* Add support for using DHCP-provided NTP server
2025-07-11 08:04:19 +02:00
125 changed files with 5543 additions and 2994 deletions

76
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@ -0,0 +1,76 @@
---
name: Bug report
description: 🐛 Let us know about an unexpected error, a crash, or an unexpected behavior.
type: 'Bug'
labels:
- 'type: bug'
body:
- type: checkboxes
attributes:
label: Disclaimer
description: |
For support questions, please use the [discussions][] or [Discord][] instead. Before
opening a bug report, ensure you have read the [documentation][],
[Troubleshooting][] and [Device FAQs][]. Only use bug reports for actual
bugs.
[documentation]: https://jetkvm.com/docs
[Troubleshooting]: https://jetkvm.com/docs/getting-started/troubleshooting
[Device FAQs]: https://jetkvm.com/docs/getting-started/faq
[discussions]: https://github.com/jetkvm/kvm/discussions
[Discord]: https://jetkvm.com/discord
options:
- label: I have read and understood the disclaimer.
required: true
- type: input
attributes:
label: Application version
description: |
Provide the application version (can be found in General settings)
validations:
required: true
- type: input
attributes:
label: System version
description: |
Provide the system version (can be found in General settings)
validations:
required: true
- type: dropdown
attributes:
label: Device model
description: Provide the device model
options:
- JetKVM
- JetKVM (POE)
validations:
required: false
- type: dropdown
attributes:
label: Extension model
description: Provide the extension model (if the bug is related to the extension)
options:
- ATX Power Control
- DC Power Control
- Serial Console
validations:
required: false
- type: input
attributes:
label: Remote device Hardware
description: If the bug is related to a remote device, please provide its hardware information e.g. Raspberry Pi 5
validations:
required: false
- type: input
attributes:
label: Remote device OS
description: If the bug is related to a remote device, please provide its OS information as detailed as possible e.g. Debian 12.
validations:
required: false
- type: textarea
attributes:
label: Bug description
description: |
Provide a description of the problem: steps to reproduce it, what you are expecting and what you got.
validations:
required: true

10
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,10 @@
blank_issues_enabled: true
contact_links:
- name: Hardware Issues
url: https://jetkvm.com/contact
about: If your hardware is not powering on or is not working, please contact us.
- name: Discord
url: https://jetkvm.com/discord
about: Engage with the JetKVM team and other community members.

46
.github/ISSUE_TEMPLATE/feature.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: Feature
type: 'Feature'
description: 🚀 Request a new feature.
labels:
- 'type: feature'
body:
- type: textarea
attributes:
label: A note for the community
value: |
> [!NOTE]
> Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request.
validations:
required: true
- type: checkboxes
attributes:
label: Disclaimer
description: |
Before requesting a feature, check it does not already exist in the [documentation](https://jetkvm.com/docs) or our [roadmap](https://jetkvm.com/roadmap).
You are quite welcome opening a feature request before spending time to implement it yourself.
options:
- label: I have read and understood the disclaimer.
required: true
- label: I plan to implement the feature myself.
- type: dropdown
attributes:
label: Subsystem
description: Provide the subsystem of the feature you request, you can choose multiple if you think it fits in multiple areas.
options:
- Hardware
- Device Compatibility
- Keyboard
- Mouse
- Power
- UI: Screen
- UI: Application
- UI: Cloud
validations:
required: false
- type: textarea
attributes:
label: Feature description
description: |
Provide a description of the feature you request.
validations:
required: true

View File

@ -23,6 +23,9 @@ linters:
- linters: - linters:
- errcheck - errcheck
path: _test.go path: _test.go
- linters:
- forbidigo
path: cmd/main.go
- linters: - linters:
- gochecknoinits - gochecknoinits
path: internal/logging/sse.go path: internal/logging/sse.go

355
DEVELOPMENT.md Normal file
View File

@ -0,0 +1,355 @@
<div align="center">
<img alt="JetKVM logo" src="https://jetkvm.com/logo-blue.png" height="28">
### Development Guide
[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/cloud-api/issues) | [Docs](https://jetkvm.com/docs)
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](https://twitter.com/jetkvm)
[![Go Report Card](https://goreportcard.com/badge/github.com/jetkvm/kvm)](https://goreportcard.com/report/github.com/jetkvm/kvm)
</div>
# JetKVM Development Guide
Welcome to JetKVM development! This guide will help you get started quickly, whether you're fixing bugs, adding features, or just exploring the codebase.
## Get Started
### Prerequisites
- **A JetKVM device** (for full development)
- **[Go 1.24.4+](https://go.dev/doc/install)** and **[Node.js 22.15.0](https://nodejs.org/en/download/)**
- **[Git](https://git-scm.com/downloads)** for version control
- **[SSH access](https://jetkvm.com/docs/advanced-usage/developing#developer-mode)** to your JetKVM device
### Development Environment
**Recommended:** Development is best done on **Linux** or **macOS**.
If you're using Windows, we strongly recommend using **WSL (Windows Subsystem for Linux)** for the best development experience:
- [Install WSL on Windows](https://docs.microsoft.com/en-us/windows/wsl/install)
- [WSL Setup Guide](https://docs.microsoft.com/en-us/windows/wsl/setup/environment)
This ensures compatibility with shell scripts and build tools used in the project.
### Project Setup
1. **Clone the repository:**
```bash
git clone https://github.com/jetkvm/kvm.git
cd kvm
```
2. **Check your tools:**
```bash
go version && node --version
```
3. **Find your JetKVM IP address** (check your router or device screen)
4. **Deploy and test:**
```bash
./dev_deploy.sh -r 192.168.1.100 # Replace with your device IP
```
5. **Open in browser:** `http://192.168.1.100`
That's it! You're now running your own development version of JetKVM.
---
## Common Tasks
### Modify the UI
```bash
cd ui
npm install
./dev_device.sh 192.168.1.100 # Replace with your device IP
```
Now edit files in `ui/src/` and see changes live in your browser!
### Modify the backend
```bash
# Edit Go files (config.go, web.go, etc.)
./dev_deploy.sh -r 192.168.1.100 --skip-ui-build
```
### Run tests
```bash
./dev_deploy.sh -r 192.168.1.100 --run-go-tests
```
### View logs
```bash
ssh root@192.168.1.100
tail -f /var/log/jetkvm.log
```
---
## Project Layout
```
/kvm/
├── main.go # App entry point
├── config.go # Settings & configuration
├── web.go # API endpoints
├── ui/ # React frontend
│ ├── src/routes/ # Pages (login, settings, etc.)
│ └── src/components/ # UI components
└── internal/ # Internal Go packages
```
**Key files for beginners:**
- `web.go` - Add new API endpoints here
- `config.go` - Add new settings here
- `ui/src/routes/` - Add new pages here
- `ui/src/components/` - Add new UI components here
---
## Development Modes
### Full Development (Recommended)
*Best for: Complete feature development*
```bash
# Deploy everything to your JetKVM device
./dev_deploy.sh -r <YOUR_DEVICE_IP>
```
### Frontend Only
*Best for: UI changes without device*
```bash
cd ui
npm install
./dev_device.sh <YOUR_DEVICE_IP>
```
### Quick Backend Changes
*Best for: API or backend logic changes*
```bash
# Skip frontend build for faster deployment
./dev_deploy.sh -r <YOUR_DEVICE_IP> --skip-ui-build
```
---
## Debugging Made Easy
### Check if everything is working
```bash
# Test connection to device
ping 192.168.1.100
# Check if JetKVM is running
ssh root@192.168.1.100 ps aux | grep jetkvm
```
### View live logs
```bash
ssh root@192.168.1.100
tail -f /var/log/jetkvm.log
```
### Reset everything (if stuck)
```bash
ssh root@192.168.1.100
rm /userdata/kvm_config.json
systemctl restart jetkvm
```
---
## Testing Your Changes
### Manual Testing
1. Deploy your changes: `./dev_deploy.sh -r <IP>`
2. Open browser: `http://<IP>`
3. Test your feature
4. Check logs: `ssh root@<IP> tail -f /var/log/jetkvm.log`
### Automated Testing
```bash
# Run all tests
./dev_deploy.sh -r <IP> --run-go-tests
# Frontend linting
cd ui && npm run lint
```
### API Testing
```bash
# Test login endpoint
curl -X POST http://<IP>/auth/password-local \
-H "Content-Type: application/json" \
-d '{"password": "test123"}'
```
---
## Common Issues & Solutions
### "Build failed" or "Permission denied"
```bash
# Fix permissions
ssh root@<IP> chmod +x /userdata/jetkvm/bin/jetkvm_app_debug
# Clean and rebuild
go clean -modcache
go mod tidy
make build_dev
```
### "Can't connect to device"
```bash
# Check network
ping <IP>
# Check SSH
ssh root@<IP> echo "Connection OK"
```
### "Frontend not updating"
```bash
# Clear cache and rebuild
cd ui
npm cache clean --force
rm -rf node_modules
npm install
```
---
## Next Steps
### Adding a New Feature
1. **Backend:** Add API endpoint in `web.go`
2. **Config:** Add settings in `config.go`
3. **Frontend:** Add UI in `ui/src/routes/`
4. **Test:** Deploy and test with `./dev_deploy.sh`
### Code Style
- **Go:** Follow standard Go conventions
- **TypeScript:** Use TypeScript for type safety
- **React:** Keep components small and reusable
### Environment Variables
```bash
# Enable debug logging
export LOG_TRACE_SCOPES="jetkvm,cloud,websocket,native,jsonrpc"
# Frontend development
export JETKVM_PROXY_URL="ws://<IP>"
```
---
## Need Help?
1. **Check logs first:** `ssh root@<IP> tail -f /var/log/jetkvm.log`
2. **Search issues:** [GitHub Issues](https://github.com/jetkvm/kvm/issues)
3. **Ask on Discord:** [JetKVM Discord](https://jetkvm.com/discord)
4. **Read docs:** [JetKVM Documentation](https://jetkvm.com/docs)
---
## Contributing
### Ready to contribute?
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
### Before submitting:
- [ ] Code works on device
- [ ] Tests pass
- [ ] Code follows style guidelines
- [ ] Documentation updated (if needed)
---
## Advanced Topics
### Performance Profiling
```bash
# Enable profiling
go build -o bin/jetkvm_app -ldflags="-X main.enableProfiling=true" cmd/main.go
# Access profiling
curl http://<IP>:6060/debug/pprof/
```
### Advanced Environment Variables
```bash
# Enable trace logging (useful for debugging)
export LOG_TRACE_SCOPES="jetkvm,cloud,websocket,native,jsonrpc"
# For frontend development
export JETKVM_PROXY_URL="ws://<JETKVM_IP>"
# Enable SSL in development
export USE_SSL=true
```
### Configuration Management
The application uses a JSON configuration file stored at `/userdata/kvm_config.json`.
#### Adding New Configuration Options
1. **Update the Config struct in `config.go`:**
```go
type Config struct {
// ... existing fields
NewFeatureEnabled bool `json:"new_feature_enabled"`
}
```
2. **Update the default configuration:**
```go
var defaultConfig = &Config{
// ... existing defaults
NewFeatureEnabled: false,
}
```
3. **Add migration logic if needed for existing installations**
---
**Happy coding!**
For more information, visit the [JetKVM Documentation](https://jetkvm.com/docs) or join our [Discord Server](https://jetkvm.com/discord).

View File

@ -8,7 +8,7 @@ VERSION ?= 0.4.6
PROMETHEUS_TAG := github.com/prometheus/common/version PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm KVM_PKG_NAME := github.com/jetkvm/kvm
GO_BUILD_ARGS := -tags netgo GO_BUILD_ARGS := -tags netgo -tags timetzdata
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS) GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
GO_LDFLAGS := \ GO_LDFLAGS := \
-s -w \ -s -w \

View File

@ -37,7 +37,9 @@ JetKVM is written in Go & TypeScript. with some bits and pieces written in C. An
The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud. The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud.
For most of local device development, all you need is to use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information. For comprehensive development information, including setup, testing, debugging, and contribution guidelines, see **[DEVELOPMENT.md](DEVELOPMENT.md)**.
For quick device development, use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information.
## Backend ## Backend

View File

@ -22,25 +22,12 @@ func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
return 0, errors.New("image not mounted") return 0, errors.New("image not mounted")
} }
source := currentVirtualMediaState.Source source := currentVirtualMediaState.Source
mountedImageSize := currentVirtualMediaState.Size
virtualMediaStateMutex.RUnlock() virtualMediaStateMutex.RUnlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) _, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
readLen := int64(len(p))
if off+readLen > mountedImageSize {
readLen = mountedImageSize - off
}
var data []byte
switch source { switch source {
case WebRTC:
data, err = webRTCDiskReader.Read(ctx, off, readLen)
if err != nil {
return 0, err
}
n = copy(p, data)
return n, nil
case HTTP: case HTTP:
return httpRangeReader.ReadAt(p, off) return httpRangeReader.ReadAt(p, off)
default: default:

View File

@ -1,9 +1,27 @@
package main package main
import ( import (
"flag"
"fmt"
"os"
"github.com/jetkvm/kvm" "github.com/jetkvm/kvm"
) )
func main() { func main() {
versionPtr := flag.Bool("version", false, "print version and exit")
versionJsonPtr := flag.Bool("version-json", false, "print version as json and exit")
flag.Parse()
if *versionPtr || *versionJsonPtr {
versionData, err := kvm.GetVersionData(*versionJsonPtr)
if err != nil {
fmt.Printf("failed to get version data: %v\n", err)
os.Exit(1)
}
fmt.Println(string(versionData))
return
}
kvm.Main() kvm.Main()
} }

View File

@ -9,6 +9,8 @@ import (
"github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network" "github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
) )
type WakeOnLanDevice struct { type WakeOnLanDevice struct {
@ -80,6 +82,7 @@ type Config struct {
CloudToken string `json:"cloud_token"` CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"` GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"` JigglerEnabled bool `json:"jiggler_enabled"`
JigglerConfig *JigglerConfig `json:"jiggler_config"`
AutoUpdateEnabled bool `json:"auto_update_enabled"` AutoUpdateEnabled bool `json:"auto_update_enabled"`
IncludePreRelease bool `json:"include_pre_release"` IncludePreRelease bool `json:"include_pre_release"`
HashedPassword string `json:"hashed_password"` HashedPassword string `json:"hashed_password"`
@ -111,11 +114,18 @@ var defaultConfig = &Config{
ActiveExtension: "", ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{}, KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270", DisplayRotation: "270",
KeyboardLayout: "en_US", KeyboardLayout: "en-US",
DisplayMaxBrightness: 64, DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes DisplayOffAfterSec: 1800, // 30 minutes
TLSMode: "", // This is the "Standard" jiggler option in the UI
JigglerConfig: &JigglerConfig{
InactivityLimitSeconds: 60,
JitterPercentage: 25,
ScheduleCronTab: "0 * * * * *",
Timezone: "UTC",
},
TLSMode: "",
UsbConfig: &usbgadget.Config{ UsbConfig: &usbgadget.Config{
VendorId: "0x1d6b", //The Linux Foundation VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget ProductId: "0x0104", //Multifunction Composite Gadget
@ -138,6 +148,21 @@ var (
configLock = &sync.Mutex{} configLock = &sync.Mutex{}
) )
var (
configSuccess = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_config_last_reload_successful",
Help: "The last configuration load succeeded",
},
)
configSuccessTime = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_config_last_reload_success_timestamp_seconds",
Help: "Timestamp of last successful config load",
},
)
)
func LoadConfig() { func LoadConfig() {
configLock.Lock() configLock.Lock()
defer configLock.Unlock() defer configLock.Unlock()
@ -153,6 +178,8 @@ func LoadConfig() {
file, err := os.Open(configPath) file, err := os.Open(configPath)
if err != nil { if err != nil {
logger.Debug().Msg("default config file doesn't exist, using default") logger.Debug().Msg("default config file doesn't exist, using default")
configSuccess.Set(1.0)
configSuccessTime.SetToCurrentTime()
return return
} }
defer file.Close() defer file.Close()
@ -161,6 +188,7 @@ func LoadConfig() {
loadedConfig := *defaultConfig loadedConfig := *defaultConfig
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Warn().Err(err).Msg("config file JSON parsing failed") logger.Warn().Err(err).Msg("config file JSON parsing failed")
configSuccess.Set(0.0)
return return
} }
@ -181,6 +209,9 @@ func LoadConfig() {
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel) logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
configSuccess.Set(1.0)
configSuccessTime.SetToCurrentTime()
logger.Info().Str("path", configPath).Msg("config loaded") logger.Info().Str("path", configPath).Msg("config loaded")
} }

53
dc_metrics.go Normal file
View File

@ -0,0 +1,53 @@
package kvm
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
)
var (
dcCurrentGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_current_amperes",
Help: "Current DC power consumption in amperes",
})
dcPowerGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_power_watts",
Help: "DC power consumption in watts",
})
dcVoltageGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_voltage_volts",
Help: "DC voltage in volts",
})
dcStateGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_power_state",
Help: "DC power state (1 = on, 0 = off)",
})
dcMetricsRegistered sync.Once
)
// registerDCMetrics registers the DC power metrics with Prometheus (called once when DC control is mounted)
func registerDCMetrics() {
dcMetricsRegistered.Do(func() {
prometheus.MustRegister(dcCurrentGauge)
prometheus.MustRegister(dcPowerGauge)
prometheus.MustRegister(dcVoltageGauge)
prometheus.MustRegister(dcStateGauge)
})
}
// updateDCMetrics updates the Prometheus metrics with current DC power state values
func updateDCMetrics(state DCPowerState) {
dcCurrentGauge.Set(state.Current)
dcPowerGauge.Set(state.Power)
dcVoltageGauge.Set(state.Voltage)
if state.IsOn {
dcStateGauge.Set(1)
} else {
dcStateGauge.Set(0)
}
}

View File

@ -28,6 +28,7 @@ show_help() {
echo " --run-go-tests Run go tests" echo " --run-go-tests Run go tests"
echo " --run-go-tests-only Run go tests and exit" echo " --run-go-tests-only Run go tests and exit"
echo " --skip-ui-build Skip frontend/UI build" echo " --skip-ui-build Skip frontend/UI build"
echo " -i, --install Build for release and install the app"
echo " --help Display this help message" echo " --help Display this help message"
echo echo
echo "Example:" echo "Example:"
@ -43,6 +44,7 @@ RESET_USB_HID_DEVICE=false
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}" LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
RUN_GO_TESTS=false RUN_GO_TESTS=false
RUN_GO_TESTS_ONLY=false RUN_GO_TESTS_ONLY=false
INSTALL_APP=false
# Parse command line arguments # Parse command line arguments
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -72,6 +74,10 @@ while [[ $# -gt 0 ]]; do
RUN_GO_TESTS=true RUN_GO_TESTS=true
shift shift
;; ;;
-i|--install)
INSTALL_APP=true
shift
;;
--help) --help)
show_help show_help
exit 0 exit 0
@ -139,28 +145,36 @@ EOF
fi fi
fi fi
msg_info "▶ Building go binary" if [ "$INSTALL_APP" = true ]
make build_dev then
msg_info "▶ Building release binary"
msg_info "▶ Killing any existing instances of the application" make build_release
# Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true" # Copy the binary to the remote host as if we were the OTA updater.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
msg_info "▶ Copying binary to remote host"
# Copy the binary to the remote host # Reboot the device, the new app will be deployed by the startup process.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
else
if [ "$RESET_USB_HID_DEVICE" = true ]; then msg_info "▶ Building development binary"
msg_info "▶ Resetting USB HID device" make build_dev
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
# Remove the old USB gadget configuration msg_info "▶ Killing any existing instances of the application"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*" ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
fi msg_info "▶ Copying binary to remote host"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
msg_info "▶ Deploying and running the application on the remote host"
# Deploy and run the application on the remote host if [ "$RESET_USB_HID_DEVICE" = true ]; then
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF msg_info "▶ Resetting USB HID device"
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
# Remove the old USB gadget configuration
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
fi
msg_info "▶ Deploying and running the application on the remote host"
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e set -e
# Set the library path to include the directory where librockit.so is located # Set the library path to include the directory where librockit.so is located
@ -177,7 +191,8 @@ cd "${REMOTE_PATH}"
chmod +x jetkvm_app_debug chmod +x jetkvm_app_debug
# Run the application in the background # Run the application in the background
PION_LOG_TRACE=${LOG_TRACE_SCOPES} GODEBUG=netdns=1 ./jetkvm_app_debug PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_debug.log
EOF EOF
fi
echo "Deployment complete." echo "Deployment complete."

View File

@ -30,7 +30,7 @@ const (
// do not call this function directly, use switchToScreenIfDifferent instead // do not call this function directly, use switchToScreenIfDifferent instead
// this function is not thread safe // this function is not thread safe
func switchToScreen(screen string) { func switchToScreen(screen string) {
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen}) _, err := CallCtrlAction("lv_scr_load", map[string]any{"obj": screen})
if err != nil { if err != nil {
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen") displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
return return
@ -39,15 +39,15 @@ func switchToScreen(screen string) {
} }
func lvObjSetState(objName string, state string) (*CtrlResponse, error) { func lvObjSetState(objName string, state string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": objName, "state": state}) return CallCtrlAction("lv_obj_set_state", map[string]any{"obj": objName, "state": state})
} }
func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) { func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_add_flag", map[string]interface{}{"obj": objName, "flag": flag}) return CallCtrlAction("lv_obj_add_flag", map[string]any{"obj": objName, "flag": flag})
} }
func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) { func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_clear_flag", map[string]interface{}{"obj": objName, "flag": flag}) return CallCtrlAction("lv_obj_clear_flag", map[string]any{"obj": objName, "flag": flag})
} }
func lvObjHide(objName string) (*CtrlResponse, error) { func lvObjHide(objName string) (*CtrlResponse, error) {
@ -59,27 +59,27 @@ func lvObjShow(objName string) (*CtrlResponse, error) {
} }
func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused
return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]interface{}{"obj": objName, "opa": opacity}) return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]any{"obj": objName, "opa": opacity})
} }
func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) { func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_fade_in", map[string]interface{}{"obj": objName, "time": duration}) return CallCtrlAction("lv_obj_fade_in", map[string]any{"obj": objName, "time": duration})
} }
func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) { func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_fade_out", map[string]interface{}{"obj": objName, "time": duration}) return CallCtrlAction("lv_obj_fade_out", map[string]any{"obj": objName, "time": duration})
} }
func lvLabelSetText(objName string, text string) (*CtrlResponse, error) { func lvLabelSetText(objName string, text string) (*CtrlResponse, error) {
return CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": text}) return CallCtrlAction("lv_label_set_text", map[string]any{"obj": objName, "text": text})
} }
func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) { func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) {
return CallCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src}) return CallCtrlAction("lv_img_set_src", map[string]any{"obj": objName, "src": src})
} }
func lvDispSetRotation(rotation string) (*CtrlResponse, error) { func lvDispSetRotation(rotation string) (*CtrlResponse, error) {
return CallCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation}) return CallCtrlAction("lv_disp_set_rotation", map[string]any{"rotation": rotation})
} }
func updateLabelIfChanged(objName string, newText string) { func updateLabelIfChanged(objName string, newText string) {

114
fuse.go
View File

@ -1,114 +0,0 @@
package kvm
import (
"context"
"os"
"sync"
"syscall"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type WebRTCStreamFile struct {
fs.Inode
mu sync.Mutex
Attr fuse.Attr
size uint64
}
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
func (f *WebRTCStreamFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
}
func (f *WebRTCStreamFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
return 0, syscall.EROFS
}
var _ = (fs.NodeGetattrer)((*WebRTCStreamFile)(nil))
func (f *WebRTCStreamFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
f.mu.Lock()
defer f.mu.Unlock()
out.Attr = f.Attr
out.Size = f.size
return fs.OK
}
func (f *WebRTCStreamFile) Setattr(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
f.mu.Lock()
defer f.mu.Unlock()
out.Attr = f.Attr
return fs.OK
}
func (f *WebRTCStreamFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
return fs.OK
}
type DiskReadRequest struct {
Start uint64 `json:"start"`
End uint64 `json:"end"`
}
var diskReadChan = make(chan []byte, 1)
func (f *WebRTCStreamFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
buf, err := webRTCDiskReader.Read(ctx, off, int64(len(dest)))
if err != nil {
return nil, syscall.EIO
}
return fuse.ReadResultData(buf), fs.OK
}
func (f *WebRTCStreamFile) SetSize(size uint64) {
f.mu.Lock()
defer f.mu.Unlock()
f.size = size
}
type FuseRoot struct {
fs.Inode
}
var webRTCStreamFile = &WebRTCStreamFile{}
func (r *FuseRoot) OnAdd(ctx context.Context) {
ch := r.NewPersistentInode(ctx, webRTCStreamFile, fs.StableAttr{Ino: 2})
r.AddChild("disk", ch, false)
}
func (r *FuseRoot) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Mode = 0755
return 0
}
var _ = (fs.NodeGetattrer)((*FuseRoot)(nil))
var _ = (fs.NodeOnAdder)((*FuseRoot)(nil))
const fuseMountPoint = "/mnt/webrtc"
var fuseServer *fuse.Server
func RunFuseServer() {
opts := &fs.Options{}
opts.DirectMountStrict = true
_ = os.Mkdir(fuseMountPoint, 0755)
var err error
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
if err != nil {
logger.Warn().Err(err).Msg("failed to mount fuse")
}
fuseServer.Wait()
}
type WebRTCImage struct {
Size uint64 `json:"size"`
Filename string `json:"filename"`
}

9
go.mod
View File

@ -11,6 +11,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/logger v1.2.6 github.com/gin-contrib/logger v1.2.6
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
github.com/go-co-op/gocron/v2 v2.16.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/guregu/null/v6 v6.0.0 github.com/guregu/null/v6 v6.0.0
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341
@ -28,9 +29,9 @@ require (
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netlink v1.3.1
go.bug.st/serial v1.6.4 go.bug.st/serial v1.6.4
golang.org/x/crypto v0.39.0 golang.org/x/crypto v0.40.0
golang.org/x/net v0.41.0 golang.org/x/net v0.41.0
golang.org/x/sys v0.33.0 golang.org/x/sys v0.34.0
) )
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
@ -50,6 +51,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
@ -75,6 +77,7 @@ require (
github.com/pion/turn/v4 v4.0.2 // indirect github.com/pion/turn/v4 v4.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
@ -82,7 +85,7 @@ require (
github.com/wlynxg/anet v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.18.0 // indirect golang.org/x/arch v0.18.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/text v0.26.0 // indirect golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

20
go.sum
View File

@ -38,6 +38,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -62,6 +64,8 @@ github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 h1:zPrkLSKi7kKJoN
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs= github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI= github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@ -146,6 +150,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE= 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/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@ -175,10 +181,12 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
@ -188,10 +196,10 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -3,6 +3,7 @@ package confparser
import ( import (
"fmt" "fmt"
"net" "net"
"net/url"
"reflect" "reflect"
"slices" "slices"
"strconv" "strconv"
@ -15,22 +16,22 @@ import (
type FieldConfig struct { type FieldConfig struct {
Name string Name string
Required bool Required bool
RequiredIf map[string]interface{} RequiredIf map[string]any
OneOf []string OneOf []string
ValidateTypes []string ValidateTypes []string
Defaults interface{} Defaults any
IsEmpty bool IsEmpty bool
CurrentValue interface{} CurrentValue any
TypeString string TypeString string
Delegated bool Delegated bool
shouldUpdateValue bool shouldUpdateValue bool
} }
func SetDefaultsAndValidate(config interface{}) error { func SetDefaultsAndValidate(config any) error {
return setDefaultsAndValidate(config, true) return setDefaultsAndValidate(config, true)
} }
func setDefaultsAndValidate(config interface{}, isRoot bool) error { func setDefaultsAndValidate(config any, isRoot bool) error {
// first we need to check if the config is a pointer // first we need to check if the config is a pointer
if reflect.TypeOf(config).Kind() != reflect.Ptr { if reflect.TypeOf(config).Kind() != reflect.Ptr {
return fmt.Errorf("config is not a pointer") return fmt.Errorf("config is not a pointer")
@ -54,7 +55,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
Name: field.Name, Name: field.Name,
OneOf: splitString(field.Tag.Get("one_of")), OneOf: splitString(field.Tag.Get("one_of")),
ValidateTypes: splitString(field.Tag.Get("validate_type")), ValidateTypes: splitString(field.Tag.Get("validate_type")),
RequiredIf: make(map[string]interface{}), RequiredIf: make(map[string]any),
CurrentValue: fieldValue.Interface(), CurrentValue: fieldValue.Interface(),
IsEmpty: false, IsEmpty: false,
TypeString: fieldType, TypeString: fieldType,
@ -141,8 +142,8 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
// now check if the field has required_if // now check if the field has required_if
requiredIf := field.Tag.Get("required_if") requiredIf := field.Tag.Get("required_if")
if requiredIf != "" { if requiredIf != "" {
requiredIfParts := strings.Split(requiredIf, ",") requiredIfParts := strings.SplitSeq(requiredIf, ",")
for _, part := range requiredIfParts { for part := range requiredIfParts {
partVal := strings.SplitN(part, "=", 2) partVal := strings.SplitN(part, "=", 2)
if len(partVal) != 2 { if len(partVal) != 2 {
return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf) return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf)
@ -167,7 +168,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
return nil return nil
} }
func validateFields(config interface{}, fields map[string]FieldConfig) error { func validateFields(config any, fields map[string]FieldConfig) error {
// now we can start to validate the fields // now we can start to validate the fields
for _, fieldConfig := range fields { for _, fieldConfig := range fields {
if err := fieldConfig.validate(fields); err != nil { if err := fieldConfig.validate(fields); err != nil {
@ -214,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error {
return nil return nil
} }
func (f *FieldConfig) populate(config interface{}) { func (f *FieldConfig) populate(config any) {
// update the field if it's not empty // update the field if it's not empty
if !f.shouldUpdateValue { if !f.shouldUpdateValue {
return return
@ -372,6 +373,10 @@ func (f *FieldConfig) validateField() error {
if _, err := idna.Lookup.ToASCII(val); err != nil { if _, err := idna.Lookup.ToASCII(val); err != nil {
return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val) return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
} }
case "proxy":
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val)
}
default: default:
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType) return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
} }

View File

@ -43,9 +43,11 @@ type testNetworkConfig struct {
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"` TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"` TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"` TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
TimeSyncNTPServers []string `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"`
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
} }
func TestValidateConfig(t *testing.T) { func TestValidateConfig(t *testing.T) {

View File

@ -16,7 +16,7 @@ func splitString(s string) []string {
return strings.Split(s, ",") return strings.Split(s, ",")
} }
func toString(v interface{}) (string, error) { func toString(v any) (string, error) {
switch v := v.(type) { switch v := v.(type) {
case string: case string:
return v, nil return v, nil

View File

@ -50,7 +50,7 @@ var (
TimeFormat: time.RFC3339, TimeFormat: time.RFC3339,
PartsOrder: []string{"time", "level", "scope", "component", "message"}, PartsOrder: []string{"time", "level", "scope", "component", "message"},
FieldsExclude: []string{"scope", "component"}, FieldsExclude: []string{"scope", "component"},
FormatPartValueByName: func(value interface{}, name string) string { FormatPartValueByName: func(value any, name string) string {
val := fmt.Sprintf("%s", value) val := fmt.Sprintf("%s", value)
if name == "component" { if name == "component" {
if value == nil { if value == nil {
@ -121,8 +121,8 @@ func (l *Logger) updateLogLevel() {
continue continue
} }
scopes := strings.Split(strings.ToLower(env), ",") scopes := strings.SplitSeq(strings.ToLower(env), ",")
for _, scope := range scopes { for scope := range scopes {
l.scopeLevels[scope] = level l.scopeLevels[scope] = level
} }
} }

View File

@ -13,32 +13,32 @@ type pionLogger struct {
func (c pionLogger) Trace(msg string) { func (c pionLogger) Trace(msg string) {
c.logger.Trace().Msg(msg) c.logger.Trace().Msg(msg)
} }
func (c pionLogger) Tracef(format string, args ...interface{}) { func (c pionLogger) Tracef(format string, args ...any) {
c.logger.Trace().Msgf(format, args...) c.logger.Trace().Msgf(format, args...)
} }
func (c pionLogger) Debug(msg string) { func (c pionLogger) Debug(msg string) {
c.logger.Debug().Msg(msg) c.logger.Debug().Msg(msg)
} }
func (c pionLogger) Debugf(format string, args ...interface{}) { func (c pionLogger) Debugf(format string, args ...any) {
c.logger.Debug().Msgf(format, args...) c.logger.Debug().Msgf(format, args...)
} }
func (c pionLogger) Info(msg string) { func (c pionLogger) Info(msg string) {
c.logger.Info().Msg(msg) c.logger.Info().Msg(msg)
} }
func (c pionLogger) Infof(format string, args ...interface{}) { func (c pionLogger) Infof(format string, args ...any) {
c.logger.Info().Msgf(format, args...) c.logger.Info().Msgf(format, args...)
} }
func (c pionLogger) Warn(msg string) { func (c pionLogger) Warn(msg string) {
c.logger.Warn().Msg(msg) c.logger.Warn().Msg(msg)
} }
func (c pionLogger) Warnf(format string, args ...interface{}) { func (c pionLogger) Warnf(format string, args ...any) {
c.logger.Warn().Msgf(format, args...) c.logger.Warn().Msgf(format, args...)
} }
func (c pionLogger) Error(msg string) { func (c pionLogger) Error(msg string) {
c.logger.Error().Msg(msg) c.logger.Error().Msg(msg)
} }
func (c pionLogger) Errorf(format string, args ...interface{}) { func (c pionLogger) Errorf(format string, args ...any) {
c.logger.Error().Msgf(format, args...) c.logger.Error().Msgf(format, args...)
} }

View File

@ -13,7 +13,7 @@ func GetDefaultLogger() *zerolog.Logger {
return &defaultLogger return &defaultLogger
} }
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error { func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
// TODO: move rootLogger to logging package // TODO: move rootLogger to logging package
if l == nil { if l == nil {
l = &defaultLogger l = &defaultLogger

View File

@ -3,6 +3,8 @@ package network
import ( import (
"fmt" "fmt"
"net" "net"
"net/http"
"net/url"
"time" "time"
"github.com/guregu/null/v6" "github.com/guregu/null/v6"
@ -32,8 +34,9 @@ type IPv6StaticConfig struct {
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"` DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
} }
type NetworkConfig struct { type NetworkConfig struct {
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"` HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"` IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
@ -45,9 +48,11 @@ type NetworkConfig struct {
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"` TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"` TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"` TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
TimeSyncNTPServers []string `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"`
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
} }
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions { func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
@ -69,6 +74,18 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
return listenOptions return listenOptions
} }
func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
return func(*http.Request) (*url.URL, error) {
if s.HTTPProxy.String == "" {
return nil, nil
} else {
proxyUrl, _ := url.Parse(s.HTTPProxy.String)
return proxyUrl, nil
}
}
}
func (s *NetworkInterfaceState) GetHostname() string { func (s *NetworkInterfaceState) GetHostname() string {
hostname := ToValidHostname(s.config.Hostname.String) hostname := ToValidHostname(s.config.Hostname.String)

View File

@ -42,7 +42,7 @@ func updateEtcHosts(hostname string, fqdn string) error {
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn) hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
hostLineExists := false hostLineExists := false
for _, line := range strings.Split(string(lines), "\n") { for line := range strings.SplitSeq(string(lines), "\n") {
if strings.HasPrefix(line, "127.0.1.1") { if strings.HasPrefix(line, "127.0.1.1") {
hostLineExists = true hostLineExists = true
line = hostLine line = hostLine

View File

@ -21,6 +21,7 @@ type NetworkInterfaceState struct {
ipv6Addr *net.IP ipv6Addr *net.IP
ipv6Addresses []IPv6Address ipv6Addresses []IPv6Address
ipv6LinkLocal *net.IP ipv6LinkLocal *net.IP
ntpAddresses []*net.IP
macAddr *net.HardwareAddr macAddr *net.HardwareAddr
l *zerolog.Logger l *zerolog.Logger
@ -76,6 +77,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
onInitialCheck: opts.OnInitialCheck, onInitialCheck: opts.OnInitialCheck,
cbConfigChange: opts.OnConfigChange, cbConfigChange: opts.OnConfigChange,
config: opts.NetworkConfig, config: opts.NetworkConfig,
ntpAddresses: make([]*net.IP, 0),
} }
// create the dhcp client // create the dhcp client
@ -89,7 +91,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
opts.Logger.Error().Err(err).Msg("failed to update network state") opts.Logger.Error().Err(err).Msg("failed to update network state")
return return
} }
_ = s.updateNtpServersFromLease(lease)
_ = s.setHostnameIfNotSame() _ = s.setHostnameIfNotSame()
opts.OnDhcpLeaseChange(lease) opts.OnDhcpLeaseChange(lease)
@ -135,6 +137,27 @@ func (s *NetworkInterfaceState) IPv6String() string {
return s.ipv6Addr.String() return s.ipv6Addr.String()
} }
func (s *NetworkInterfaceState) NtpAddresses() []*net.IP {
return s.ntpAddresses
}
func (s *NetworkInterfaceState) NtpAddressesString() []string {
ntpServers := []string{}
if s != nil {
s.l.Debug().Any("s", s).Msg("getting NTP address strings")
if len(s.ntpAddresses) > 0 {
for _, server := range s.ntpAddresses {
s.l.Debug().IPAddr("server", *server).Msg("converting NTP address")
ntpServers = append(ntpServers, server.String())
}
}
}
return ntpServers
}
func (s *NetworkInterfaceState) MAC() *net.HardwareAddr { func (s *NetworkInterfaceState) MAC() *net.HardwareAddr {
return s.macAddr return s.macAddr
} }
@ -318,6 +341,25 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
return dhcpTargetState, nil return dhcpTargetState, nil
} }
func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error {
if lease != nil && len(lease.NTPServers) > 0 {
s.l.Info().Msg("lease found, updating DHCP NTP addresses")
s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers))
for _, ntpServer := range lease.NTPServers {
if ntpServer != nil {
s.l.Info().IPAddr("ntp_server", ntpServer).Msg("NTP server found in lease")
s.ntpAddresses = append(s.ntpAddresses, &ntpServer)
}
}
} else {
s.l.Info().Msg("no NTP servers found in lease")
s.ntpAddresses = make([]*net.IP, 0, len(s.config.TimeSyncNTPServers))
}
return nil
}
func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
dhcpTargetState, err := s.update() dhcpTargetState, err := s.update()
if err != nil { if err != nil {

View File

@ -13,7 +13,7 @@ func lifetimeToTime(lifetime int) *time.Time {
return &t return &t
} }
func IsSame(a, b interface{}) bool { func IsSame(a, b any) bool {
aJSON, err := json.Marshal(a) aJSON, err := json.Marshal(a)
if err != nil { if err != nil {
return false return false

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"math/rand" "math/rand"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"time" "time"
) )
@ -19,9 +20,9 @@ var defaultHTTPUrls = []string{
// "http://www.msftconnecttest.com/connecttest.txt", // "http://www.msftconnecttest.com/connecttest.txt",
} }
func (t *TimeSync) queryAllHttpTime() (now *time.Time) { func (t *TimeSync) queryAllHttpTime(httpUrls []string) (now *time.Time) {
chunkSize := 4 chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
httpUrls := t.httpUrls t.l.Info().Strs("httpUrls", httpUrls).Int("chunkSize", chunkSize).Msg("querying HTTP URLs")
// shuffle the http urls to avoid always querying the same servers // shuffle the http urls to avoid always querying the same servers
rand.Shuffle(len(httpUrls), func(i, j int) { httpUrls[i], httpUrls[j] = httpUrls[j], httpUrls[i] }) rand.Shuffle(len(httpUrls), func(i, j int) { httpUrls[i], httpUrls[j] = httpUrls[j], httpUrls[i] })
@ -57,6 +58,7 @@ func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now
ctx, ctx,
url, url,
timeout, timeout,
t.networkConfig.GetTransportProxyFunc(),
) )
duration := time.Since(startTime) duration := time.Since(startTime)
@ -122,10 +124,16 @@ func queryHttpTime(
ctx context.Context, ctx context.Context,
url string, url string,
timeout time.Duration, timeout time.Duration,
proxyFunc func(*http.Request) (*url.URL, error),
) (now *time.Time, response *http.Response, err error) { ) (now *time.Time, response *http.Response, err error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = proxyFunc
client := http.Client{ client := http.Client{
Timeout: timeout, Transport: transport,
Timeout: timeout,
} }
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err

View File

@ -73,6 +73,7 @@ var (
}, },
[]string{"url"}, []string{"url"},
) )
metricNtpServerInfo = promauto.NewGaugeVec( metricNtpServerInfo = promauto.NewGaugeVec(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
Name: "jetkvm_timesync_ntp_server_info", Name: "jetkvm_timesync_ntp_server_info",

View File

@ -1,6 +1,7 @@
package timesync package timesync
import ( import (
"context"
"math/rand/v2" "math/rand/v2"
"strconv" "strconv"
"time" "time"
@ -21,9 +22,9 @@ var defaultNTPServers = []string{
"3.pool.ntp.org", "3.pool.ntp.org",
} }
func (t *TimeSync) queryNetworkTime() (now *time.Time, offset *time.Duration) { func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) {
chunkSize := 4 chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
ntpServers := t.ntpServers t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP servers")
// shuffle the ntp servers to avoid always querying the same servers // shuffle the ntp servers to avoid always querying the same servers
rand.Shuffle(len(ntpServers), func(i, j int) { ntpServers[i], ntpServers[j] = ntpServers[j], ntpServers[i] }) rand.Shuffle(len(ntpServers), func(i, j int) { ntpServers[i], ntpServers[j] = ntpServers[j], ntpServers[i] })
@ -46,6 +47,10 @@ type ntpResult struct {
func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (now *time.Time, offset *time.Duration) { func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (now *time.Time, offset *time.Duration) {
results := make(chan *ntpResult, len(servers)) results := make(chan *ntpResult, len(servers))
_, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for _, server := range servers { for _, server := range servers {
go func(server string) { go func(server string) {
scopedLogger := t.l.With(). scopedLogger := t.l.With().
@ -66,15 +71,25 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no
return return
} }
if response.IsKissOfDeath() {
scopedLogger.Warn().
Str("kiss_code", response.KissCode).
Msg("ignoring NTP server kiss of death")
results <- nil
return
}
rtt := float64(response.RTT.Milliseconds())
// set the last RTT // set the last RTT
metricNtpServerLastRTT.WithLabelValues( metricNtpServerLastRTT.WithLabelValues(
server, server,
).Set(float64(response.RTT.Milliseconds())) ).Set(rtt)
// set the RTT histogram // set the RTT histogram
metricNtpServerRttHistogram.WithLabelValues( metricNtpServerRttHistogram.WithLabelValues(
server, server,
).Observe(float64(response.RTT.Milliseconds())) ).Observe(rtt)
// set the server info // set the server info
metricNtpServerInfo.WithLabelValues( metricNtpServerInfo.WithLabelValues(
@ -91,10 +106,13 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no
scopedLogger.Info(). scopedLogger.Info().
Str("time", now.Format(time.RFC3339)). Str("time", now.Format(time.RFC3339)).
Str("reference", response.ReferenceString()). Str("reference", response.ReferenceString()).
Str("rtt", response.RTT.String()). Float64("rtt", rtt).
Str("clockOffset", response.ClockOffset.String()). Str("clockOffset", response.ClockOffset.String()).
Uint8("stratum", response.Stratum). Uint8("stratum", response.Stratum).
Msg("NTP server returned time") Msg("NTP server returned time")
cancel()
results <- &ntpResult{ results <- &ntpResult{
now: now, now: now,
offset: &response.ClockOffset, offset: &response.ClockOffset,

View File

@ -28,9 +28,8 @@ type TimeSync struct {
syncLock *sync.Mutex syncLock *sync.Mutex
l *zerolog.Logger l *zerolog.Logger
ntpServers []string networkConfig *network.NetworkConfig
httpUrls []string dhcpNtpAddresses []string
networkConfig *network.NetworkConfig
rtcDevicePath string rtcDevicePath string
rtcDevice *os.File //nolint:unused rtcDevice *os.File //nolint:unused
@ -64,14 +63,13 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
} }
t := &TimeSync{ t := &TimeSync{
syncLock: &sync.Mutex{}, syncLock: &sync.Mutex{},
l: opts.Logger, l: opts.Logger,
rtcDevicePath: rtcDevice, dhcpNtpAddresses: []string{},
rtcLock: &sync.Mutex{}, rtcDevicePath: rtcDevice,
preCheckFunc: opts.PreCheckFunc, rtcLock: &sync.Mutex{},
ntpServers: defaultNTPServers, preCheckFunc: opts.PreCheckFunc,
httpUrls: defaultHTTPUrls, networkConfig: opts.NetworkConfig,
networkConfig: opts.NetworkConfig,
} }
if t.rtcDevicePath != "" { if t.rtcDevicePath != "" {
@ -82,34 +80,42 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
return t return t
} }
func (t *TimeSync) SetDhcpNtpAddresses(addresses []string) {
t.dhcpNtpAddresses = addresses
}
func (t *TimeSync) getSyncMode() SyncMode { func (t *TimeSync) getSyncMode() SyncMode {
syncMode := SyncMode{ syncMode := SyncMode{
Ntp: true,
Http: true,
Ordering: []string{"ntp_dhcp", "ntp", "http"},
NtpUseFallback: true, NtpUseFallback: true,
HttpUseFallback: true, HttpUseFallback: true,
} }
var syncModeString string
if t.networkConfig != nil { if t.networkConfig != nil {
syncModeString = t.networkConfig.TimeSyncMode.String switch t.networkConfig.TimeSyncMode.String {
case "ntp_only":
syncMode.Http = false
case "http_only":
syncMode.Ntp = false
}
if t.networkConfig.TimeSyncDisableFallback.Bool { if t.networkConfig.TimeSyncDisableFallback.Bool {
syncMode.NtpUseFallback = false syncMode.NtpUseFallback = false
syncMode.HttpUseFallback = false syncMode.HttpUseFallback = false
} }
var syncOrdering = t.networkConfig.TimeSyncOrdering
if len(syncOrdering) > 0 {
syncMode.Ordering = syncOrdering
}
} }
switch syncModeString { t.l.Debug().Strs("Ordering", syncMode.Ordering).Bool("Ntp", syncMode.Ntp).Bool("Http", syncMode.Http).Bool("NtpUseFallback", syncMode.NtpUseFallback).Bool("HttpUseFallback", syncMode.HttpUseFallback).Msg("sync mode")
case "ntp_only":
syncMode.Ntp = true
case "http_only":
syncMode.Http = true
default:
syncMode.Ntp = true
syncMode.Http = true
}
return syncMode return syncMode
} }
func (t *TimeSync) doTimeSync() { func (t *TimeSync) doTimeSync() {
metricTimeSyncStatus.Set(0) metricTimeSyncStatus.Set(0)
for { for {
@ -154,16 +160,61 @@ func (t *TimeSync) Sync() error {
offset *time.Duration offset *time.Duration
) )
syncMode := t.getSyncMode()
metricTimeSyncCount.Inc() metricTimeSyncCount.Inc()
if syncMode.Ntp { syncMode := t.getSyncMode()
now, offset = t.queryNetworkTime()
}
if syncMode.Http && now == nil { Orders:
now = t.queryAllHttpTime() for _, mode := range syncMode.Ordering {
switch mode {
case "ntp_user_provided":
if syncMode.Ntp {
t.l.Info().Msg("using NTP custom servers")
now, offset = t.queryNetworkTime(t.networkConfig.TimeSyncNTPServers)
if now != nil {
t.l.Info().Str("source", "NTP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "ntp_dhcp":
if syncMode.Ntp {
t.l.Info().Msg("using NTP servers from DHCP")
now, offset = t.queryNetworkTime(t.dhcpNtpAddresses)
if now != nil {
t.l.Info().Str("source", "NTP DHCP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "ntp":
if syncMode.Ntp && syncMode.NtpUseFallback {
t.l.Info().Msg("using NTP fallback")
now, offset = t.queryNetworkTime(defaultNTPServers)
if now != nil {
t.l.Info().Str("source", "NTP fallback").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "http_user_provided":
if syncMode.Http {
t.l.Info().Msg("using HTTP custom URLs")
now = t.queryAllHttpTime(t.networkConfig.TimeSyncHTTPUrls)
if now != nil {
t.l.Info().Str("source", "HTTP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "http":
if syncMode.Http && syncMode.HttpUseFallback {
t.l.Info().Msg("using HTTP fallback")
now = t.queryAllHttpTime(defaultHTTPUrls)
if now != nil {
t.l.Info().Str("source", "HTTP fallback").Time("now", *now).Msg("time obtained")
break Orders
}
}
default:
t.l.Warn().Str("mode", mode).Msg("unknown time sync mode, skipping")
}
} }
if now == nil { if now == nil {

72
internal/tzdata/gen.go Normal file
View File

@ -0,0 +1,72 @@
//go:build ignore
package main
import (
"archive/zip"
"bytes"
"flag"
"fmt"
"os"
"text/template"
)
var tmpl = `// Code generated by "go run gen.go". DO NOT EDIT.
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
package tzdata
var TimeZones = []string{
{{- range . }}
"{{.}}",
{{- end }}
}
`
var filename = flag.String("output", "tzdata.go", "output file name")
func main() {
flag.Parse()
path := os.Getenv("ZONEINFO")
if path == "" {
fmt.Println("ZONEINFO is not set")
os.Exit(1)
}
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("ZONEINFO %s does not exist\n", path)
os.Exit(1)
}
zipfile, err := zip.OpenReader(path)
if err != nil {
fmt.Printf("Error opening ZONEINFO %s: %v\n", path, err)
os.Exit(1)
}
defer zipfile.Close()
timezones := []string{}
for _, file := range zipfile.File {
timezones = append(timezones, file.Name)
}
var buf bytes.Buffer
tmpl, err := template.New("tzdata").Parse(tmpl)
if err != nil {
fmt.Printf("Error parsing template: %v\n", err)
os.Exit(1)
}
err = tmpl.Execute(&buf, timezones)
if err != nil {
fmt.Printf("Error executing template: %v\n", err)
os.Exit(1)
}
err = os.WriteFile(*filename, buf.Bytes(), 0644)
if err != nil {
fmt.Printf("Error writing file %s: %v\n", *filename, err)
os.Exit(1)
}
}

602
internal/tzdata/tzdata.go Normal file
View File

@ -0,0 +1,602 @@
// Code generated by "go run gen.go". DO NOT EDIT.
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
package tzdata
var TimeZones = []string{
"Africa/Abidjan",
"Africa/Accra",
"Africa/Addis_Ababa",
"Africa/Algiers",
"Africa/Asmara",
"Africa/Asmera",
"Africa/Bamako",
"Africa/Bangui",
"Africa/Banjul",
"Africa/Bissau",
"Africa/Blantyre",
"Africa/Brazzaville",
"Africa/Bujumbura",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/Conakry",
"Africa/Dakar",
"Africa/Dar_es_Salaam",
"Africa/Djibouti",
"Africa/Douala",
"Africa/El_Aaiun",
"Africa/Freetown",
"Africa/Gaborone",
"Africa/Harare",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Kampala",
"Africa/Khartoum",
"Africa/Kigali",
"Africa/Kinshasa",
"Africa/Lagos",
"Africa/Libreville",
"Africa/Lome",
"Africa/Luanda",
"Africa/Lubumbashi",
"Africa/Lusaka",
"Africa/Malabo",
"Africa/Maputo",
"Africa/Maseru",
"Africa/Mbabane",
"Africa/Mogadishu",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Niamey",
"Africa/Nouakchott",
"Africa/Ouagadougou",
"Africa/Porto-Novo",
"Africa/Sao_Tome",
"Africa/Timbuktu",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Anguilla",
"America/Antigua",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/ComodRivadavia",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Aruba",
"America/Asuncion",
"America/Atikokan",
"America/Atka",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Buenos_Aires",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Catamarca",
"America/Cayenne",
"America/Cayman",
"America/Chicago",
"America/Chihuahua",
"America/Ciudad_Juarez",
"America/Coral_Harbour",
"America/Cordoba",
"America/Costa_Rica",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Dominica",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Ensenada",
"America/Fort_Nelson",
"America/Fort_Wayne",
"America/Fortaleza",
"America/Glace_Bay",
"America/Godthab",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Grenada",
"America/Guadeloupe",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Indianapolis",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Indianapolis",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Jujuy",
"America/Juneau",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/Knox_IN",
"America/Kralendijk",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Louisville",
"America/Lower_Princes",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Marigot",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Mendoza",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Montreal",
"America/Montserrat",
"America/Nassau",
"America/New_York",
"America/Nipigon",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Nuuk",
"America/Ojinaga",
"America/Panama",
"America/Pangnirtung",
"America/Paramaribo",
"America/Phoenix",
"America/Port-au-Prince",
"America/Port_of_Spain",
"America/Porto_Acre",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rainy_River",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Rosario",
"America/Santa_Isabel",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Shiprock",
"America/Sitka",
"America/St_Barthelemy",
"America/St_Johns",
"America/St_Kitts",
"America/St_Lucia",
"America/St_Thomas",
"America/St_Vincent",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Thunder_Bay",
"America/Tijuana",
"America/Toronto",
"America/Tortola",
"America/Vancouver",
"America/Virgin",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"America/Yellowknife",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/DumontDUrville",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/McMurdo",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/South_Pole",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Arctic/Longyearbyen",
"Asia/Aden",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Ashkhabad",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Bahrain",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Calcutta",
"Asia/Chita",
"Asia/Choibalsan",
"Asia/Chongqing",
"Asia/Chungking",
"Asia/Colombo",
"Asia/Dacca",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Harbin",
"Asia/Hebron",
"Asia/Ho_Chi_Minh",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Istanbul",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Kashgar",
"Asia/Kathmandu",
"Asia/Katmandu",
"Asia/Khandyga",
"Asia/Kolkata",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Kuwait",
"Asia/Macao",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Muscat",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Phnom_Penh",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qostanay",
"Asia/Qyzylorda",
"Asia/Rangoon",
"Asia/Riyadh",
"Asia/Saigon",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Tel_Aviv",
"Asia/Thimbu",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ujung_Pandang",
"Asia/Ulaanbaatar",
"Asia/Ulan_Bator",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vientiane",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yangon",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faeroe",
"Atlantic/Faroe",
"Atlantic/Jan_Mayen",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/St_Helena",
"Atlantic/Stanley",
"Australia/ACT",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Canberra",
"Australia/Currie",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/LHI",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/NSW",
"Australia/North",
"Australia/Perth",
"Australia/Queensland",
"Australia/South",
"Australia/Sydney",
"Australia/Tasmania",
"Australia/Victoria",
"Australia/West",
"Australia/Yancowinna",
"Brazil/Acre",
"Brazil/DeNoronha",
"Brazil/East",
"Brazil/West",
"CET",
"CST6CDT",
"Canada/Atlantic",
"Canada/Central",
"Canada/Eastern",
"Canada/Mountain",
"Canada/Newfoundland",
"Canada/Pacific",
"Canada/Saskatchewan",
"Canada/Yukon",
"Chile/Continental",
"Chile/EasterIsland",
"Cuba",
"EET",
"EST",
"EST5EDT",
"Egypt",
"Eire",
"Etc/GMT",
"Etc/GMT+0",
"Etc/GMT+1",
"Etc/GMT+10",
"Etc/GMT+11",
"Etc/GMT+12",
"Etc/GMT+2",
"Etc/GMT+3",
"Etc/GMT+4",
"Etc/GMT+5",
"Etc/GMT+6",
"Etc/GMT+7",
"Etc/GMT+8",
"Etc/GMT+9",
"Etc/GMT-0",
"Etc/GMT-1",
"Etc/GMT-10",
"Etc/GMT-11",
"Etc/GMT-12",
"Etc/GMT-13",
"Etc/GMT-14",
"Etc/GMT-2",
"Etc/GMT-3",
"Etc/GMT-4",
"Etc/GMT-5",
"Etc/GMT-6",
"Etc/GMT-7",
"Etc/GMT-8",
"Etc/GMT-9",
"Etc/GMT0",
"Etc/Greenwich",
"Etc/UCT",
"Etc/UTC",
"Etc/Universal",
"Etc/Zulu",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belfast",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Bratislava",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Busingen",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Guernsey",
"Europe/Helsinki",
"Europe/Isle_of_Man",
"Europe/Istanbul",
"Europe/Jersey",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Kyiv",
"Europe/Lisbon",
"Europe/Ljubljana",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Mariehamn",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Nicosia",
"Europe/Oslo",
"Europe/Paris",
"Europe/Podgorica",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/San_Marino",
"Europe/Sarajevo",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Skopje",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Tiraspol",
"Europe/Ulyanovsk",
"Europe/Uzhgorod",
"Europe/Vaduz",
"Europe/Vatican",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zagreb",
"Europe/Zaporozhye",
"Europe/Zurich",
"Factory",
"GB",
"GB-Eire",
"GMT",
"GMT+0",
"GMT-0",
"GMT0",
"Greenwich",
"HST",
"Hongkong",
"Iceland",
"Indian/Antananarivo",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Comoro",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Mayotte",
"Indian/Reunion",
"Iran",
"Israel",
"Jamaica",
"Japan",
"Kwajalein",
"Libya",
"MET",
"MST",
"MST7MDT",
"Mexico/BajaNorte",
"Mexico/BajaSur",
"Mexico/General",
"NZ",
"NZ-CHAT",
"Navajo",
"PRC",
"PST8PDT",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Chuuk",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Johnston",
"Pacific/Kanton",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Midway",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Pohnpei",
"Pacific/Ponape",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Saipan",
"Pacific/Samoa",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Truk",
"Pacific/Wake",
"Pacific/Wallis",
"Pacific/Yap",
"Poland",
"Portugal",
"ROC",
"ROK",
"Singapore",
"Turkey",
"UCT",
"US/Alaska",
"US/Aleutian",
"US/Arizona",
"US/Central",
"US/East-Indiana",
"US/Eastern",
"US/Hawaii",
"US/Indiana-Starke",
"US/Michigan",
"US/Mountain",
"US/Pacific",
"US/Samoa",
"UTC",
"Universal",
"W-SU",
"WET",
"Zulu",
}

View File

@ -101,7 +101,7 @@ func (l *Lease) SetLeaseExpiry() (time.Time, error) {
func UnmarshalDHCPCLease(lease *Lease, str string) error { func UnmarshalDHCPCLease(lease *Lease, str string) error {
// parse the lease file as a map // parse the lease file as a map
data := make(map[string]string) data := make(map[string]string)
for _, line := range strings.Split(str, "\n") { for line := range strings.SplitSeq(str, "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
// skip empty lines and comments // skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
@ -165,7 +165,7 @@ func UnmarshalDHCPCLease(lease *Lease, str string) error {
field.Set(reflect.ValueOf(ip)) field.Set(reflect.ValueOf(ip))
case []net.IP: case []net.IP:
val := make([]net.IP, 0) val := make([]net.IP, 0)
for _, ipStr := range strings.Fields(value) { for ipStr := range strings.FieldsSeq(value) {
ip := net.ParseIP(ipStr) ip := net.ParseIP(ipStr)
if ip == nil { if ip == nil {
continue continue

View File

@ -52,7 +52,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
} }
func (c *DHCPClient) getWatchPaths() []string { func (c *DHCPClient) getWatchPaths() []string {
watchPaths := make(map[string]interface{}) watchPaths := make(map[string]any)
watchPaths[filepath.Dir(c.leaseFile)] = nil watchPaths[filepath.Dir(c.leaseFile)] = nil
if c.pidFile != "" { if c.pidFile != "" {

View File

@ -1,10 +1,10 @@
package usbgadget package usbgadget
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"os" "os"
"reflect"
"time" "time"
) )
@ -14,9 +14,10 @@ var keyboardConfig = gadgetConfigItem{
path: []string{"functions", "hid.usb0"}, path: []string{"functions", "hid.usb0"},
configPath: []string{"hid.usb0"}, configPath: []string{"hid.usb0"},
attrs: gadgetAttributes{ attrs: gadgetAttributes{
"protocol": "1", "protocol": "1",
"subclass": "1", "subclass": "1",
"report_length": "8", "report_length": "8",
"no_out_endpoint": "0",
}, },
reportDesc: keyboardReportDesc, reportDesc: keyboardReportDesc,
} }
@ -60,6 +61,8 @@ var keyboardReportDesc = []byte{
const ( const (
hidReadBufferSize = 8 hidReadBufferSize = 8
hidKeyBufferSize = 6
hidErrorRollOver = 0x01
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf // https://www.usb.org/sites/default/files/documents/hid1_11.pdf
// https://www.usb.org/sites/default/files/hut1_2.pdf // https://www.usb.org/sites/default/files/hut1_2.pdf
KeyboardLedMaskNumLock = 1 << 0 KeyboardLedMaskNumLock = 1 << 0
@ -67,7 +70,9 @@ const (
KeyboardLedMaskScrollLock = 1 << 2 KeyboardLedMaskScrollLock = 1 << 2
KeyboardLedMaskCompose = 1 << 3 KeyboardLedMaskCompose = 1 << 3
KeyboardLedMaskKana = 1 << 4 KeyboardLedMaskKana = 1 << 4
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana // power on/off LED is 5
KeyboardLedMaskShift = 1 << 6
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana | KeyboardLedMaskShift
) )
// Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK, // Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK,
@ -80,6 +85,7 @@ type KeyboardState struct {
ScrollLock bool `json:"scroll_lock"` ScrollLock bool `json:"scroll_lock"`
Compose bool `json:"compose"` Compose bool `json:"compose"`
Kana bool `json:"kana"` Kana bool `json:"kana"`
Shift bool `json:"shift"` // This is not part of the main USB HID spec
} }
func getKeyboardState(b byte) KeyboardState { func getKeyboardState(b byte) KeyboardState {
@ -90,27 +96,27 @@ func getKeyboardState(b byte) KeyboardState {
ScrollLock: b&KeyboardLedMaskScrollLock != 0, ScrollLock: b&KeyboardLedMaskScrollLock != 0,
Compose: b&KeyboardLedMaskCompose != 0, Compose: b&KeyboardLedMaskCompose != 0,
Kana: b&KeyboardLedMaskKana != 0, Kana: b&KeyboardLedMaskKana != 0,
Shift: b&KeyboardLedMaskShift != 0,
} }
} }
func (u *UsbGadget) updateKeyboardState(b byte) { func (u *UsbGadget) updateKeyboardState(state byte) {
u.keyboardStateLock.Lock() u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock() defer u.keyboardStateLock.Unlock()
if b&^ValidKeyboardLedMasks != 0 { if state&^ValidKeyboardLedMasks != 0 {
u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring") u.log.Warn().Uint8("state", state).Msg("ignoring invalid bits")
return return
} }
newState := getKeyboardState(b) if u.keyboardState == state {
if reflect.DeepEqual(u.keyboardState, newState) {
return return
} }
u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated") u.log.Trace().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated")
u.keyboardState = newState u.keyboardState = state
if u.onKeyboardStateChange != nil { if u.onKeyboardStateChange != nil {
(*u.onKeyboardStateChange)(newState) (*u.onKeyboardStateChange)(getKeyboardState(state))
} }
} }
@ -122,7 +128,35 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
u.keyboardStateLock.Lock() u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock() defer u.keyboardStateLock.Unlock()
return u.keyboardState return getKeyboardState(u.keyboardState)
}
func (u *UsbGadget) GetKeysDownState() KeysDownState {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
return u.keysDownState
}
func (u *UsbGadget) updateKeyDownState(state KeysDownState) {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
if u.keysDownState.Modifier == state.Modifier &&
bytes.Equal(u.keysDownState.Keys, state.Keys) {
return // No change in key down state
}
u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("keysDownState updated")
u.keysDownState = state
if u.onKeysDownChange != nil {
(*u.onKeysDownChange)(state)
}
}
func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
u.onKeysDownChange = &f
} }
func (u *UsbGadget) listenKeyboardEvents() { func (u *UsbGadget) listenKeyboardEvents() {
@ -141,7 +175,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
l.Info().Msg("context done") l.Info().Msg("context done")
return return
default: default:
l.Trace().Msg("reading from keyboard") l.Trace().Msg("reading from keyboard for LED state changes")
if u.keyboardHidFile == nil { if u.keyboardHidFile == nil {
u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil") u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
// show the error every 100 times to avoid spamming the logs // show the error every 100 times to avoid spamming the logs
@ -158,7 +192,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
} }
u.resetLogSuppressionCounter("keyboardHidFileRead") u.resetLogSuppressionCounter("keyboardHidFileRead")
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard") l.Trace().Int("n", n).Uints8("buf", buf).Msg("got data from keyboard")
if n != 1 { if n != 1 {
l.Trace().Int("n", n).Msg("expected 1 byte, got") l.Trace().Int("n", n).Msg("expected 1 byte, got")
continue continue
@ -194,12 +228,12 @@ func (u *UsbGadget) OpenKeyboardHidFile() error {
return u.openKeyboardHidFile() return u.openKeyboardHidFile()
} }
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error { func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error {
if err := u.openKeyboardHidFile(); err != nil { if err := u.openKeyboardHidFile(); err != nil {
return err return err
} }
_, err := u.keyboardHidFile.Write(data) _, err := u.keyboardHidFile.Write(append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...))
if err != nil { if err != nil {
u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0") u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
u.keyboardHidFile.Close() u.keyboardHidFile.Close()
@ -210,24 +244,147 @@ func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
return nil return nil
} }
func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error { func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState {
// if we just reported an error roll over, we should clear the keys
if keys[0] == hidErrorRollOver {
for i := range keys {
keys[i] = 0
}
}
downState := KeysDownState{
Modifier: modifier,
Keys: []byte(keys[:]),
}
u.updateKeyDownState(downState)
return downState
}
func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) (KeysDownState, error) {
u.keyboardLock.Lock() u.keyboardLock.Lock()
defer u.keyboardLock.Unlock() defer u.keyboardLock.Unlock()
defer u.resetUserInputTime()
u.log.Trace().Uint8("modifier", modifier).Bytes("keys", keys).Msg("KeyboardReport") u.log.Trace().Uint8("modifier", modifier).Bytes("keys", keys).Msg("KeyboardReport")
if len(keys) > 6 { if len(keys) > hidKeyBufferSize {
keys = keys[:6] keys = keys[:hidKeyBufferSize]
} }
if len(keys) < 6 { if len(keys) < hidKeyBufferSize {
keys = append(keys, make([]uint8, 6-len(keys))...) keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...)
} }
err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]}) err := u.keyboardWriteHidFile(modifier, keys)
if err != nil { if err != nil {
return err u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0")
} }
u.resetUserInputTime() return u.UpdateKeysDown(modifier, keys), err
return nil }
const (
// https://www.usb.org/sites/default/files/documents/hut1_2.pdf
// Dynamic Flags (DV)
LeftControl = 0xE0
LeftShift = 0xE1
LeftAlt = 0xE2
LeftSuper = 0xE3 // Left GUI (e.g. Windows key, Apple Command key)
RightControl = 0xE4
RightShift = 0xE5
RightAlt = 0xE6
RightSuper = 0xE7 // Right GUI (e.g. Windows key, Apple Command key)
)
const (
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf Appendix C
ModifierMaskLeftControl = 0x01
ModifierMaskRightControl = 0x10
ModifierMaskLeftShift = 0x02
ModifierMaskRightShift = 0x20
ModifierMaskLeftAlt = 0x04
ModifierMaskRightAlt = 0x40
ModifierMaskLeftSuper = 0x08
ModifierMaskRightSuper = 0x80
)
// KeyCodeToMaskMap is a slice of KeyCodeMask for quick lookup
var KeyCodeToMaskMap = map[byte]byte{
LeftControl: ModifierMaskLeftControl,
LeftShift: ModifierMaskLeftShift,
LeftAlt: ModifierMaskLeftAlt,
LeftSuper: ModifierMaskLeftSuper,
RightControl: ModifierMaskRightControl,
RightShift: ModifierMaskRightShift,
RightAlt: ModifierMaskRightAlt,
RightSuper: ModifierMaskRightSuper,
}
func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
defer u.resetUserInputTime()
// IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver
// for handling key presses and releases. It ensures that the USB gadget
// behaves similarly to a real USB HID keyboard. This logic is paralleled
// in the client/browser-side code in useKeyboard.ts so make sure to keep
// them in sync.
var state = u.keysDownState
modifier := state.Modifier
keys := append([]byte(nil), state.Keys...)
if mask, exists := KeyCodeToMaskMap[key]; exists {
// If the key is a modifier key, we update the keyboardModifier state
// by setting or clearing the corresponding bit in the modifier byte.
// This allows us to track the state of dynamic modifier keys like
// Shift, Control, Alt, and Super.
if press {
modifier |= mask
} else {
modifier &^= mask
}
} else {
// handle other keys that are not modifier keys by placing or removing them
// from the key buffer since the buffer tracks currently pressed keys
overrun := true
for i := range hidKeyBufferSize {
// If we find the key in the buffer the buffer, we either remove it (if press is false)
// or do nothing (if down is true) because the buffer tracks currently pressed keys
// and if we find a zero byte, we can place the key there (if press is true)
if keys[i] == key || keys[i] == 0 {
if press {
keys[i] = key // overwrites the zero byte or the same key if already pressed
} else {
// we are releasing the key, remove it from the buffer
if keys[i] != 0 {
copy(keys[i:], keys[i+1:])
keys[hidKeyBufferSize-1] = 0 // Clear the last byte
}
}
overrun = false // We found a slot for the key
break
}
}
// If we reach here it means we didn't find an empty slot or the key in the buffer
if overrun {
if press {
u.log.Error().Uint8("key", key).Msg("keyboard buffer overflow, key not added")
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
for i := range keys {
keys[i] = hidErrorRollOver
}
} else {
// If we are releasing a key, and we didn't find it in a slot, who cares?
u.log.Warn().Uint8("key", key).Msg("key not found in buffer, nothing to release")
}
}
}
err := u.keyboardWriteHidFile(modifier, keys)
if err != nil {
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keypress report to hidg0")
}
return u.UpdateKeysDown(modifier, keys), err
} }

View File

@ -11,9 +11,10 @@ var absoluteMouseConfig = gadgetConfigItem{
path: []string{"functions", "hid.usb1"}, path: []string{"functions", "hid.usb1"},
configPath: []string{"hid.usb1"}, configPath: []string{"hid.usb1"},
attrs: gadgetAttributes{ attrs: gadgetAttributes{
"protocol": "2", "protocol": "2",
"subclass": "0", "subclass": "0",
"report_length": "6", "report_length": "6",
"no_out_endpoint": "1",
}, },
reportDesc: absoluteMouseCombinedReportDesc, reportDesc: absoluteMouseCombinedReportDesc,
} }
@ -84,19 +85,19 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
return nil return nil
} }
func (u *UsbGadget) AbsMouseReport(x, y int, buttons uint8) error { func (u *UsbGadget) AbsMouseReport(x int, y int, buttons uint8) error {
u.absMouseLock.Lock() u.absMouseLock.Lock()
defer u.absMouseLock.Unlock() defer u.absMouseLock.Unlock()
u.log.Trace().Int("x", x).Int("y", y).Uint8("buttons", buttons).Msg("AbsMouseReport") u.log.Trace().Int("x", x).Int("y", y).Uint8("buttons", buttons).Msg("AbsMouseReport")
err := u.absMouseWriteHidFile([]byte{ err := u.absMouseWriteHidFile([]byte{
1, // Report ID 1 1, // Report ID 1
buttons, // Buttons buttons, // Buttons
uint8(x), // X Low Byte byte(x), // X Low Byte
uint8(x >> 8), // X High Byte byte(x >> 8), // X High Byte
uint8(y), // Y Low Byte byte(y), // Y Low Byte
uint8(y >> 8), // Y High Byte byte(y >> 8), // Y High Byte
}) })
if err != nil { if err != nil {
return err return err

View File

@ -11,9 +11,10 @@ var relativeMouseConfig = gadgetConfigItem{
path: []string{"functions", "hid.usb2"}, path: []string{"functions", "hid.usb2"},
configPath: []string{"hid.usb2"}, configPath: []string{"hid.usb2"},
attrs: gadgetAttributes{ attrs: gadgetAttributes{
"protocol": "2", "protocol": "2",
"subclass": "1", "subclass": "1",
"report_length": "4", "report_length": "4",
"no_out_endpoint": "1",
}, },
reportDesc: relativeMouseCombinedReportDesc, reportDesc: relativeMouseCombinedReportDesc,
} }
@ -74,15 +75,15 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
return nil return nil
} }
func (u *UsbGadget) RelMouseReport(mx, my int8, buttons uint8) error { func (u *UsbGadget) RelMouseReport(mx int8, my int8, buttons uint8) error {
u.relMouseLock.Lock() u.relMouseLock.Lock()
defer u.relMouseLock.Unlock() defer u.relMouseLock.Unlock()
err := u.relMouseWriteHidFile([]byte{ err := u.relMouseWriteHidFile([]byte{
buttons, // Buttons buttons, // Buttons
uint8(mx), // X byte(mx), // X
uint8(my), // Y byte(my), // Y
0, // Wheel 0, // Wheel
}) })
if err != nil { if err != nil {
return err return err

View File

@ -41,6 +41,11 @@ var defaultUsbGadgetDevices = Devices{
MassStorage: true, MassStorage: true,
} }
type KeysDownState struct {
Modifier byte `json:"modifier"`
Keys ByteSlice `json:"keys"`
}
// UsbGadget is a struct that represents a USB gadget. // UsbGadget is a struct that represents a USB gadget.
type UsbGadget struct { type UsbGadget struct {
name string name string
@ -60,7 +65,9 @@ type UsbGadget struct {
relMouseHidFile *os.File relMouseHidFile *os.File
relMouseLock sync.Mutex relMouseLock sync.Mutex
keyboardState KeyboardState keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana)
keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys)
keyboardStateLock sync.Mutex keyboardStateLock sync.Mutex
keyboardStateCtx context.Context keyboardStateCtx context.Context
keyboardStateCancel context.CancelFunc keyboardStateCancel context.CancelFunc
@ -77,6 +84,7 @@ type UsbGadget struct {
txLock sync.Mutex txLock sync.Mutex
onKeyboardStateChange *func(state KeyboardState) onKeyboardStateChange *func(state KeyboardState)
onKeysDownChange *func(state KeysDownState)
log *zerolog.Logger log *zerolog.Logger
@ -122,7 +130,8 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
txLock: sync.Mutex{}, txLock: sync.Mutex{},
keyboardStateCtx: keyboardCtx, keyboardStateCtx: keyboardCtx,
keyboardStateCancel: keyboardCancel, keyboardStateCancel: keyboardCancel,
keyboardState: KeyboardState{}, keyboardState: 0,
keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes
enabledDevices: *enabledDevices, enabledDevices: *enabledDevices,
lastUserInput: time.Now(), lastUserInput: time.Now(),
log: logger, log: logger,

View File

@ -2,6 +2,7 @@ package usbgadget
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strconv" "strconv"
@ -10,6 +11,31 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
type ByteSlice []byte
func (s ByteSlice) MarshalJSON() ([]byte, error) {
vals := make([]int, len(s))
for i, v := range s {
vals[i] = int(v)
}
return json.Marshal(vals)
}
func (s *ByteSlice) UnmarshalJSON(data []byte) error {
var vals []int
if err := json.Unmarshal(data, &vals); err != nil {
return err
}
*s = make([]byte, len(vals))
for i, v := range vals {
if v < 0 || v > 255 {
return fmt.Errorf("value %d out of byte range", v)
}
(*s)[i] = byte(v)
}
return nil
}
func joinPath(basePath string, paths []string) string { func joinPath(basePath string, paths []string) string {
pathArr := append([]string{basePath}, paths...) pathArr := append([]string{basePath}, paths...)
return filepath.Join(pathArr...) return filepath.Join(pathArr...)
@ -81,7 +107,7 @@ func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool)
return false return false
} }
func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) { func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...any) {
u.logSuppressionLock.Lock() u.logSuppressionLock.Lock()
defer u.logSuppressionLock.Unlock() defer u.logSuppressionLock.Unlock()

View File

@ -1,39 +1,156 @@
package kvm package kvm
import ( import (
"fmt"
"math/rand"
"time" "time"
_ "time/tzdata"
"github.com/go-co-op/gocron/v2"
"github.com/jetkvm/kvm/internal/tzdata"
) )
var lastUserInput = time.Now() type JigglerConfig struct {
InactivityLimitSeconds int `json:"inactivity_limit_seconds"`
JitterPercentage int `json:"jitter_percentage"`
ScheduleCronTab string `json:"schedule_cron_tab"`
Timezone string `json:"timezone,omitempty"`
}
var jigglerEnabled = false var jigglerEnabled = false
var jobDelta time.Duration = 0
var scheduler gocron.Scheduler = nil
func rpcSetJigglerState(enabled bool) { func rpcSetJigglerState(enabled bool) {
jigglerEnabled = enabled jigglerEnabled = enabled
} }
func rpcGetJigglerState() bool { func rpcGetJigglerState() bool {
return jigglerEnabled return jigglerEnabled
} }
func rpcGetTimezones() []string {
return tzdata.TimeZones
}
func rpcGetJigglerConfig() (JigglerConfig, error) {
return *config.JigglerConfig, nil
}
func rpcSetJigglerConfig(jigglerConfig JigglerConfig) error {
logger.Info().Msgf("jigglerConfig: %v, %v, %v, %v", jigglerConfig.InactivityLimitSeconds, jigglerConfig.JitterPercentage, jigglerConfig.ScheduleCronTab, jigglerConfig.Timezone)
config.JigglerConfig = &jigglerConfig
err := removeExistingCrobJobs(scheduler)
if err != nil {
return fmt.Errorf("error removing cron jobs from scheduler %v", err)
}
err = runJigglerCronTab()
if err != nil {
return fmt.Errorf("error scheduling jiggler crontab: %v", err)
}
err = SaveConfig()
if err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func removeExistingCrobJobs(s gocron.Scheduler) error {
for _, j := range s.Jobs() {
err := s.RemoveJob(j.ID())
if err != nil {
return err
}
}
return nil
}
func initJiggler() { func initJiggler() {
go runJiggler() ensureConfigLoaded()
err := runJigglerCronTab()
if err != nil {
logger.Error().Msgf("Error scheduling jiggler crontab: %v", err)
return
}
}
func runJigglerCronTab() error {
cronTab := config.JigglerConfig.ScheduleCronTab
// Apply timezone if specified and valid
if config.JigglerConfig.Timezone != "" && config.JigglerConfig.Timezone != "UTC" {
// Validate timezone before applying
if _, err := time.LoadLocation(config.JigglerConfig.Timezone); err != nil {
logger.Warn().Msgf("Invalid timezone '%s', falling back to UTC: %v", config.JigglerConfig.Timezone, err)
// Don't add TZ prefix, let it run in UTC
} else {
cronTab = fmt.Sprintf("TZ=%s %s", config.JigglerConfig.Timezone, cronTab)
}
}
s, err := gocron.NewScheduler()
if err != nil {
return err
}
scheduler = s
_, err = s.NewJob(
gocron.CronJob(
cronTab,
true,
),
gocron.NewTask(
func() {
runJiggler()
},
),
)
if err != nil {
return err
}
s.Start()
delta, err := calculateJobDelta(s)
jobDelta = delta
logger.Info().Msgf("Time between jiggler runs: %v", jobDelta)
if err != nil {
return err
}
return nil
} }
func runJiggler() { func runJiggler() {
for { if jigglerEnabled {
if jigglerEnabled { if config.JigglerConfig.JitterPercentage != 0 {
if time.Since(lastUserInput) > 20*time.Second { jitter := calculateJitterDuration(jobDelta)
//TODO: change to rel mouse time.Sleep(jitter)
err := rpcAbsMouseReport(1, 1, 0) }
if err != nil { inactivitySeconds := config.JigglerConfig.InactivityLimitSeconds
logger.Warn().Err(err).Msg("Failed to jiggle mouse") timeSinceLastInput := time.Since(gadget.GetLastUserInputTime())
} logger.Debug().Msgf("Time since last user input %v", timeSinceLastInput)
err = rpcAbsMouseReport(0, 0, 0) if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second {
if err != nil { logger.Debug().Msg("Jiggling mouse...")
logger.Warn().Err(err).Msg("Failed to reset mouse position") //TODO: change to rel mouse
} err := rpcAbsMouseReport(1, 1, 0)
if err != nil {
logger.Warn().Msgf("Failed to jiggle mouse: %v", err)
}
err = rpcAbsMouseReport(0, 0, 0)
if err != nil {
logger.Warn().Msgf("Failed to reset mouse position: %v", err)
} }
} }
time.Sleep(20 * time.Second)
} }
} }
func calculateJobDelta(s gocron.Scheduler) (time.Duration, error) {
j := s.Jobs()[0]
runs, err := j.NextRuns(2)
if err != nil {
return 0.0, err
}
return runs[1].Sub(runs[0]), nil
}
func calculateJitterDuration(delta time.Duration) time.Duration {
jitter := rand.Float64() * float64(config.JigglerConfig.JitterPercentage) / 100 * delta.Seconds()
return time.Duration(jitter * float64(time.Second))
}

View File

@ -13,29 +13,30 @@ import (
"time" "time"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"github.com/rs/zerolog"
"go.bug.st/serial" "go.bug.st/serial"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
) )
type JSONRPCRequest struct { type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
Method string `json:"method"` Method string `json:"method"`
Params map[string]interface{} `json:"params,omitempty"` Params map[string]any `json:"params,omitempty"`
ID interface{} `json:"id,omitempty"` ID any `json:"id,omitempty"`
} }
type JSONRPCResponse struct { type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
Result interface{} `json:"result,omitempty"` Result any `json:"result,omitempty"`
Error interface{} `json:"error,omitempty"` Error any `json:"error,omitempty"`
ID interface{} `json:"id"` ID any `json:"id"`
} }
type JSONRPCEvent struct { type JSONRPCEvent struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
Method string `json:"method"` Method string `json:"method"`
Params interface{} `json:"params,omitempty"` Params any `json:"params,omitempty"`
} }
type DisplayRotationSettings struct { type DisplayRotationSettings struct {
@ -61,7 +62,7 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
} }
} }
func writeJSONRPCEvent(event string, params interface{}, session *Session) { func writeJSONRPCEvent(event string, params any, session *Session) {
request := JSONRPCEvent{ request := JSONRPCEvent{
JSONRPC: "2.0", JSONRPC: "2.0",
Method: event, Method: event,
@ -102,7 +103,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
errorResponse := JSONRPCResponse{ errorResponse := JSONRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
Error: map[string]interface{}{ Error: map[string]any{
"code": -32700, "code": -32700,
"message": "Parse error", "message": "Parse error",
}, },
@ -123,7 +124,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
if !ok { if !ok {
errorResponse := JSONRPCResponse{ errorResponse := JSONRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
Error: map[string]interface{}{ Error: map[string]any{
"code": -32601, "code": -32601,
"message": "Method not found", "message": "Method not found",
}, },
@ -133,13 +134,12 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return return
} }
scopedLogger.Trace().Msg("Calling RPC handler") result, err := callRPCHandler(scopedLogger, handler, request.Params)
result, err := callRPCHandler(handler, request.Params)
if err != nil { if err != nil {
scopedLogger.Error().Err(err).Msg("Error calling RPC handler") scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
errorResponse := JSONRPCResponse{ errorResponse := JSONRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
Error: map[string]interface{}{ Error: map[string]any{
"code": -32603, "code": -32603,
"message": "Internal error", "message": "Internal error",
"data": err.Error(), "data": err.Error(),
@ -200,7 +200,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
func rpcSetStreamQualityFactor(factor float64) error { func rpcSetStreamQualityFactor(factor float64) error {
logger.Info().Float64("factor", factor).Msg("Setting stream quality factor") logger.Info().Float64("factor", factor).Msg("Setting stream quality factor")
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor}) var _, err = CallCtrlAction("set_video_quality_factor", map[string]any{"quality_factor": factor})
if err != nil { if err != nil {
return err return err
} }
@ -240,7 +240,7 @@ func rpcSetEDID(edid string) error {
} else { } else {
logger.Info().Str("edid", edid).Msg("Setting EDID") logger.Info().Str("edid", edid).Msg("Setting EDID")
} }
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid}) _, err := CallCtrlAction("set_edid", map[string]any{"edid": edid})
if err != nil { if err != nil {
return err return err
} }
@ -467,12 +467,12 @@ func rpcSetTLSState(state TLSState) error {
} }
type RPCHandler struct { type RPCHandler struct {
Func interface{} Func any
Params []string Params []string
} }
// call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls // call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result interface{}, err error) { func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) {
// Use defer to recover from a panic // Use defer to recover from a panic
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -486,11 +486,11 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result i
}() }()
// Call the handler // Call the handler
result, err = riskyCallRPCHandler(handler, params) result, err = riskyCallRPCHandler(logger, handler, params)
return result, err return result, err // do not combine these two lines into one, as it breaks the above defer function's setting of err
} }
func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) { func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (any, error) {
handlerValue := reflect.ValueOf(handler.Func) handlerValue := reflect.ValueOf(handler.Func)
handlerType := handlerValue.Type() handlerType := handlerValue.Type()
@ -499,20 +499,24 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
} }
numParams := handlerType.NumIn() numParams := handlerType.NumIn()
args := make([]reflect.Value, numParams) paramNames := handler.Params // Get the parameter names from the RPCHandler
// Get the parameter names from the RPCHandler
paramNames := handler.Params
if len(paramNames) != numParams { if len(paramNames) != numParams {
return nil, errors.New("mismatch between handler parameters and defined parameter names") err := fmt.Errorf("mismatch between handler parameters (%d) and defined parameter names (%d)", numParams, len(paramNames))
logger.Error().Strs("paramNames", paramNames).Err(err).Msg("Cannot call RPC handler")
return nil, err
} }
for i := 0; i < numParams; i++ { args := make([]reflect.Value, numParams)
for i := range numParams {
paramType := handlerType.In(i) paramType := handlerType.In(i)
paramName := paramNames[i] paramName := paramNames[i]
paramValue, ok := params[paramName] paramValue, ok := params[paramName]
if !ok { if !ok {
return nil, errors.New("missing parameter: " + paramName) err := fmt.Errorf("missing parameter: %s", paramName)
logger.Error().Err(err).Msg("Cannot marshal arguments for RPC handler")
return nil, err
} }
convertedValue := reflect.ValueOf(paramValue) convertedValue := reflect.ValueOf(paramValue)
@ -529,7 +533,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 { if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 {
intValue := int(elemValue.Float()) intValue := int(elemValue.Float())
if intValue < 0 || intValue > 255 { if intValue < 0 || intValue > 255 {
return nil, fmt.Errorf("value out of range for uint8: %v", intValue) return nil, fmt.Errorf("value out of range for uint8: %v for parameter %s", intValue, paramName)
} }
newSlice.Index(j).SetUint(uint64(intValue)) newSlice.Index(j).SetUint(uint64(intValue))
} else { } else {
@ -545,12 +549,12 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
} else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map { } else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map {
jsonData, err := json.Marshal(convertedValue.Interface()) jsonData, err := json.Marshal(convertedValue.Interface())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal map to JSON: %v", err) return nil, fmt.Errorf("failed to marshal map to JSON: %v for parameter %s", err, paramName)
} }
newStruct := reflect.New(paramType).Interface() newStruct := reflect.New(paramType).Interface()
if err := json.Unmarshal(jsonData, newStruct); err != nil { if err := json.Unmarshal(jsonData, newStruct); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err) return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v for parameter %s", err, paramName)
} }
args[i] = reflect.ValueOf(newStruct).Elem() args[i] = reflect.ValueOf(newStruct).Elem()
} else { } else {
@ -561,6 +565,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
} }
} }
logger.Trace().Msg("Calling RPC handler")
results := handlerValue.Call(args) results := handlerValue.Call(args)
if len(results) == 0 { if len(results) == 0 {
@ -568,23 +573,32 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
} }
if len(results) == 1 { if len(results) == 1 {
if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { if ok, err := asError(results[0]); ok {
if !results[0].IsNil() { return nil, err
return nil, results[0].Interface().(error) }
return results[0].Interface(), nil
}
if len(results) == 2 {
if ok, err := asError(results[1]); ok {
if err != nil {
return nil, err
} }
return nil, nil
} }
return results[0].Interface(), nil return results[0].Interface(), nil
} }
if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { return nil, fmt.Errorf("too many return values from handler: %d", len(results))
if !results[1].IsNil() { }
return nil, results[1].Interface().(error)
}
return results[0].Interface(), nil
}
return nil, errors.New("unexpected return values from handler") func asError(value reflect.Value) (bool, error) {
if value.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
if value.IsNil() {
return true, nil
}
return true, value.Interface().(error)
}
return false, nil
} }
func rpcSetMassStorageMode(mode string) (string, error) { func rpcSetMassStorageMode(mode string) (string, error) {
@ -923,7 +937,7 @@ func rpcSetKeyboardLayout(layout string) error {
return nil return nil
} }
func getKeyboardMacros() (interface{}, error) { func getKeyboardMacros() (any, error) {
macros := make([]KeyboardMacro, len(config.KeyboardMacros)) macros := make([]KeyboardMacro, len(config.KeyboardMacros))
copy(macros, config.KeyboardMacros) copy(macros, config.KeyboardMacros)
@ -931,10 +945,10 @@ func getKeyboardMacros() (interface{}, error) {
} }
type KeyboardMacrosParams struct { type KeyboardMacrosParams struct {
Macros []interface{} `json:"macros"` Macros []any `json:"macros"`
} }
func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) { func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
if params.Macros == nil { if params.Macros == nil {
return nil, fmt.Errorf("missing or invalid macros parameter") return nil, fmt.Errorf("missing or invalid macros parameter")
} }
@ -942,7 +956,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
newMacros := make([]KeyboardMacro, 0, len(params.Macros)) newMacros := make([]KeyboardMacro, 0, len(params.Macros))
for i, item := range params.Macros { for i, item := range params.Macros {
macroMap, ok := item.(map[string]interface{}) macroMap, ok := item.(map[string]any)
if !ok { if !ok {
return nil, fmt.Errorf("invalid macro at index %d", i) return nil, fmt.Errorf("invalid macro at index %d", i)
} }
@ -960,16 +974,16 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
} }
steps := []KeyboardMacroStep{} steps := []KeyboardMacroStep{}
if stepsArray, ok := macroMap["steps"].([]interface{}); ok { if stepsArray, ok := macroMap["steps"].([]any); ok {
for _, stepItem := range stepsArray { for _, stepItem := range stepsArray {
stepMap, ok := stepItem.(map[string]interface{}) stepMap, ok := stepItem.(map[string]any)
if !ok { if !ok {
continue continue
} }
step := KeyboardMacroStep{} step := KeyboardMacroStep{}
if keysArray, ok := stepMap["keys"].([]interface{}); ok { if keysArray, ok := stepMap["keys"].([]any); ok {
for _, k := range keysArray { for _, k := range keysArray {
if keyStr, ok := k.(string); ok { if keyStr, ok := k.(string); ok {
step.Keys = append(step.Keys, keyStr) step.Keys = append(step.Keys, keyStr)
@ -977,7 +991,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
} }
} }
if modsArray, ok := stepMap["modifiers"].([]interface{}); ok { if modsArray, ok := stepMap["modifiers"].([]any); ok {
for _, m := range modsArray { for _, m := range modsArray {
if modStr, ok := m.(string); ok { if modStr, ok := m.(string); ok {
step.Modifiers = append(step.Modifiers, modStr) step.Modifiers = append(step.Modifiers, modStr)
@ -1047,6 +1061,8 @@ var rpcHandlers = map[string]RPCHandler{
"renewDHCPLease": {Func: rpcRenewDHCPLease}, "renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState}, "getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
"getKeyDownState": {Func: rpcGetKeysDownState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
@ -1056,6 +1072,9 @@ var rpcHandlers = map[string]RPCHandler{
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}}, "rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}}, "setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState}, "getJigglerState": {Func: rpcGetJigglerState},
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
"getJigglerConfig": {Func: rpcGetJigglerConfig},
"getTimezones": {Func: rpcGetTimezones},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}}, "sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor}, "getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}}, "setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
@ -1084,7 +1103,6 @@ var rpcHandlers = map[string]RPCHandler{
"getVirtualMediaState": {Func: rpcGetVirtualMediaState}, "getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace}, "getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}}, "mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}}, "mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles}, "listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}}, "deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},

2
log.go
View File

@ -5,7 +5,7 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error { func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
return logging.ErrorfL(l, format, err, args...) return logging.ErrorfL(l, format, err, args...)
} }

View File

@ -9,6 +9,7 @@ import (
"net" "net"
"os" "os"
"os/exec" "os/exec"
"strings"
"sync" "sync"
"time" "time"
@ -20,18 +21,18 @@ import (
var ctrlSocketConn net.Conn var ctrlSocketConn net.Conn
type CtrlAction struct { type CtrlAction struct {
Action string `json:"action"` Action string `json:"action"`
Seq int32 `json:"seq,omitempty"` Seq int32 `json:"seq,omitempty"`
Params map[string]interface{} `json:"params,omitempty"` Params map[string]any `json:"params,omitempty"`
} }
type CtrlResponse struct { type CtrlResponse struct {
Seq int32 `json:"seq,omitempty"` Seq int32 `json:"seq,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Errno int32 `json:"errno,omitempty"` Errno int32 `json:"errno,omitempty"`
Result map[string]interface{} `json:"result,omitempty"` Result map[string]any `json:"result,omitempty"`
Event string `json:"event,omitempty"` Event string `json:"event,omitempty"`
Data json.RawMessage `json:"data,omitempty"` Data json.RawMessage `json:"data,omitempty"`
} }
type EventHandler func(event CtrlResponse) type EventHandler func(event CtrlResponse)
@ -47,7 +48,7 @@ var (
nativeCmdLock = &sync.Mutex{} nativeCmdLock = &sync.Mutex{}
) )
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) { func CallCtrlAction(action string, params map[string]any) (*CtrlResponse, error) {
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
ctrlAction := CtrlAction{ ctrlAction := CtrlAction{
@ -366,6 +367,22 @@ func shouldOverwrite(destPath string, srcHash []byte) bool {
return !bytes.Equal(srcHash, dstHash) return !bytes.Equal(srcHash, dstHash)
} }
func getNativeSha256() ([]byte, error) {
version, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
if err != nil {
return nil, err
}
return version, nil
}
func GetNativeVersion() (string, error) {
version, err := getNativeSha256()
if err != nil {
return "", err
}
return strings.TrimSpace(string(version)), nil
}
func ensureBinaryUpdated(destPath string) error { func ensureBinaryUpdated(destPath string) error {
srcFile, err := resource.ResourceFS.Open("jetkvm_native") srcFile, err := resource.ResourceFS.Open("jetkvm_native")
if err != nil { if err != nil {
@ -373,7 +390,7 @@ func ensureBinaryUpdated(destPath string) error {
} }
defer srcFile.Close() defer srcFile.Close()
srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256") srcHash, err := getNativeSha256()
if err != nil { if err != nil {
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update") nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
srcHash = nil srcHash = nil
@ -412,7 +429,7 @@ func ensureBinaryUpdated(destPath string) error {
func restoreHdmiEdid() { func restoreHdmiEdid() {
if config.EdidString != "" { if config.EdidString != "" {
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID") nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString}) _, err := CallCtrlAction("set_edid", map[string]any{"edid": config.EdidString})
if err != nil { if err != nil {
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID") nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
} }

View File

@ -19,6 +19,16 @@ func networkStateChanged() {
// do not block the main thread // do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true) go waitCtrlAndRequestDisplayUpdate(true)
if timeSync != nil {
if networkState != nil {
timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString())
}
if err := timeSync.Sync(); err != nil {
networkLogger.Error().Err(err).Msg("failed to sync time after network state change")
}
}
// always restart mDNS when the network state changes // always restart mDNS when the network state changes
if mDNS != nil { if mDNS != nil {
_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode()) _ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())

14
ota.go
View File

@ -50,6 +50,10 @@ const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
var builtAppVersion = "0.1.0+dev" var builtAppVersion = "0.1.0+dev"
func GetBuiltAppVersion() string {
return builtAppVersion
}
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) { func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
appVersion, err = semver.NewVersion(builtAppVersion) appVersion, err = semver.NewVersion(builtAppVersion)
if err != nil { if err != nil {
@ -89,7 +93,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
return nil, fmt.Errorf("error creating request: %w", err) return nil, fmt.Errorf("error creating request: %w", err)
} }
resp, err := http.DefaultClient.Do(req) transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
client := &http.Client{
Transport: transport,
}
resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("error sending request: %w", err) return nil, fmt.Errorf("error sending request: %w", err)
} }
@ -135,6 +146,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
client := http.Client{ client := http.Client{
Timeout: 10 * time.Minute, Timeout: 10 * time.Minute,
Transport: &http.Transport{ Transport: &http.Transport{
Proxy: config.NetworkConfig.GetTransportProxyFunc(),
TLSHandshakeTimeout: 30 * time.Second, TLSHandshakeTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
RootCAs: rootcerts.ServerCertPool(), RootCAs: rootcerts.ServerCertPool(),

View File

@ -1,65 +0,0 @@
package kvm
import (
"context"
"encoding/json"
"errors"
)
type RemoteImageReader interface {
Read(ctx context.Context, offset int64, size int64) ([]byte, error)
}
type WebRTCDiskReader struct {
}
var webRTCDiskReader WebRTCDiskReader
func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) ([]byte, error) {
virtualMediaStateMutex.RLock()
if currentVirtualMediaState == nil {
virtualMediaStateMutex.RUnlock()
return nil, errors.New("image not mounted")
}
if currentVirtualMediaState.Source != WebRTC {
virtualMediaStateMutex.RUnlock()
return nil, errors.New("image not mounted from webrtc")
}
mountedImageSize := currentVirtualMediaState.Size
virtualMediaStateMutex.RUnlock()
end := offset + size
if end > mountedImageSize {
end = mountedImageSize
}
req := DiskReadRequest{
Start: uint64(offset),
End: uint64(end),
}
jsonBytes, err := json.Marshal(req)
if err != nil {
return nil, err
}
if currentSession == nil || currentSession.DiskChannel == nil {
return nil, errors.New("not active session")
}
logger.Debug().Str("request", string(jsonBytes)).Msg("reading from webrtc")
err = currentSession.DiskChannel.SendText(string(jsonBytes))
if err != nil {
return nil, err
}
var buf []byte
for {
select {
case data := <-diskReadChan:
buf = data[16:]
case <-ctx.Done():
return nil, context.Canceled
}
if len(buf) >= int(end-offset) {
break
}
}
return buf, nil
}

View File

@ -128,6 +128,7 @@ func pressATXResetButton(duration time.Duration) error {
func mountDCControl() error { func mountDCControl() error {
_ = port.SetMode(defaultMode) _ = port.SetMode(defaultMode)
registerDCMetrics()
go runDCControl() go runDCControl()
return nil return nil
} }
@ -206,6 +207,9 @@ func runDCControl() {
dcState.Current = amps dcState.Current = amps
dcState.Power = watts dcState.Power = watts
// Update Prometheus metrics
updateDCMetrics(dcState)
if currentSession != nil { if currentSession != nil {
writeJSONRPCEvent("dcState", dcState, currentSession) writeJSONRPCEvent("dcState", dcState, currentSession)
} }

View File

@ -66,6 +66,10 @@ module.exports = defineConfig([{
groups: ["builtin", "external", "internal", "parent", "sibling"], groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always", "newlines-between": "always",
}], }],
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_", "varsIgnorePattern": "^_"
}],
}, },
settings: { settings: {

View File

@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- These are the fonts used in the app --> <!-- These are the fonts used in the app -->
<link <link
@ -27,7 +27,14 @@
/> />
<title>JetKVM</title> <title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" /> <link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="icon" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="JetKVM" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#051946" />
<meta name="description" content="A web-based KVM console for managing remote servers." />
<script> <script>
// Initial theme setup // Initial theme setup
document.documentElement.classList.toggle( document.documentElement.classList.toggle(

1548
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "kvm-ui", "name": "kvm-ui",
"private": true, "private": true,
"version": "0.0.0", "version": "2025.08.25.2300",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "22.15.0" "node": "22.15.0"
@ -19,66 +19,66 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.3", "@headlessui/react": "^2.2.7",
"@headlessui/tailwindcss": "^0.2.2", "@headlessui/tailwindcss": "^0.2.2",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@vitejs/plugin-basic-ssl": "^2.0.0", "@vitejs/plugin-basic-ssl": "^2.1.0",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.3", "cva": "^1.0.0-beta.4",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^11.0.3", "focus-trap-react": "^11.0.4",
"framer-motion": "^12.11.4", "framer-motion": "^12.23.12",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"react": "^19.1.0", "react": "^19.1.1",
"react-animate-height": "^3.2.3", "react-animate-height": "^3.2.3",
"react-dom": "^19.1.0", "react-dom": "^19.1.1",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.6.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.8.72", "react-simple-keyboard": "^3.8.115",
"react-use-websocket": "^4.13.0", "react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.1",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"validator": "^13.15.0", "validator": "^13.15.15",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.9", "@eslint/compat": "^1.3.2",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.26.0", "@eslint/js": "^9.34.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.7", "@tailwindcss/vite": "^4.1.12",
"@types/react": "^19.1.4", "@types/react": "^19.1.11",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.8",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/validator": "^13.15.0", "@types/validator": "^13.15.2",
"@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/eslint-plugin": "^8.41.0",
"@typescript-eslint/parser": "^8.32.1", "@typescript-eslint/parser": "^8.41.0",
"@vitejs/plugin-react-swc": "^3.9.0", "@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.26.0", "eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0", "globals": "^16.3.0",
"postcss": "^8.5.3", "postcss": "^8.5.6",
"prettier": "^3.5.3", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.12",
"typescript": "^5.8.3", "typescript": "^5.9.2",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
ui/public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 B

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

1
ui/public/favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}@media (prefers-color-scheme:dark){:root{filter:none}}</style></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
ui/public/jetkvm.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,21 @@
{
"name": "JetKVM",
"short_name": "JetKVM",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#002b36",
"background_color": "#051946",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -26,17 +26,13 @@ export default function Actionbar({
requestFullscreen: () => Promise<void>; requestFullscreen: () => Promise<void>;
}) { }) {
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
const { setDisableVideoFocusTrap, terminalType, setTerminalType, toggleSidebarView } = useUiStore();
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const terminalType = useUiStore(state => state.terminalType);
const setTerminalType = useUiStore(state => state.setTerminalType);
const remoteVirtualMediaState = useMountMediaStore( const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState, state => state.remoteVirtualMediaState,
); );
const developerMode = useSettingsStore(state => state.developerMode); const { developerMode } = useSettingsStore();
// This is the only way to get a reliable state change for the popover // This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover // at time of writing this there is no mount, or unmount event for the popover
@ -47,13 +43,13 @@ export default function Actionbar({
isOpen.current = open; isOpen.current = open;
if (!open) { if (!open) {
setTimeout(() => { setTimeout(() => {
setDisableFocusTrap(false); setDisableVideoFocusTrap(false);
console.log("Popover is closing. Returning focus trap to video"); console.debug("Popover is closing. Returning focus trap to video");
}, 0); }, 0);
} }
} }
}, },
[setDisableFocusTrap], [setDisableVideoFocusTrap],
); );
return ( return (
@ -81,7 +77,7 @@ export default function Actionbar({
text="Paste text" text="Paste text"
LeadingIcon={MdOutlineContentPasteGo} LeadingIcon={MdOutlineContentPasteGo}
onClick={() => { onClick={() => {
setDisableFocusTrap(true); setDisableVideoFocusTrap(true);
}} }}
/> />
</PopoverButton> </PopoverButton>
@ -123,7 +119,7 @@ export default function Actionbar({
); );
}} }}
onClick={() => { onClick={() => {
setDisableFocusTrap(true); setDisableVideoFocusTrap(true);
}} }}
/> />
</PopoverButton> </PopoverButton>
@ -154,7 +150,7 @@ export default function Actionbar({
theme="light" theme="light"
text="Wake on LAN" text="Wake on LAN"
onClick={() => { onClick={() => {
setDisableFocusTrap(true); setDisableVideoFocusTrap(true);
}} }}
LeadingIcon={({ className }) => ( LeadingIcon={({ className }) => (
<svg <svg
@ -204,7 +200,7 @@ export default function Actionbar({
theme="light" theme="light"
text="Virtual Keyboard" text="Virtual Keyboard"
LeadingIcon={FaKeyboard} LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)} onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/> />
</div> </div>
</div> </div>
@ -218,7 +214,7 @@ export default function Actionbar({
text="Extension" text="Extension"
LeadingIcon={LuCable} LeadingIcon={LuCable}
onClick={() => { onClick={() => {
setDisableFocusTrap(true); setDisableVideoFocusTrap(true);
}} }}
/> />
</PopoverButton> </PopoverButton>
@ -243,7 +239,7 @@ export default function Actionbar({
theme="light" theme="light"
text="Virtual Keyboard" text="Virtual Keyboard"
LeadingIcon={FaKeyboard} LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)} onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/> />
</div> </div>
<div className="hidden md:block"> <div className="hidden md:block">
@ -268,7 +264,10 @@ export default function Actionbar({
theme="light" theme="light"
text="Settings" text="Settings"
LeadingIcon={LuSettings} LeadingIcon={LuSettings}
onClick={() => navigateTo("/settings")} onClick={() => {
setDisableVideoFocusTrap(true);
navigateTo("/settings")
}}
/> />
</div> </div>

View File

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

View File

@ -48,7 +48,7 @@ export default function DashboardNavbar({
navigate("/"); navigate("/");
}, [navigate, setUser]); }, [navigate, setUser]);
const usbState = useHidStore(state => state.usbState); const { usbState } = useHidStore();
// for testing // for testing
//userEmail = "user@example.org"; //userEmail = "user@example.org";

View File

@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { import {
@ -7,65 +7,68 @@ import {
useRTCStore, useRTCStore,
useSettingsStore, useSettingsStore,
useVideoStore, useVideoStore,
VideoState
} from "@/hooks/stores"; } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
export default function InfoBar() { export default function InfoBar() {
const activeKeys = useHidStore(state => state.activeKeys); const { keysDownState } = useHidStore();
const activeModifiers = useHidStore(state => state.activeModifiers); const { mouseX, mouseY, mouseMove } = useMouseStore();
const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY);
const mouseMove = useMouseStore(state => state.mouseMove);
const videoClientSize = useVideoStore( const videoClientSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, (state: VideoState) => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
); );
const videoSize = useVideoStore( const videoSize = useVideoStore(
state => `${Math.round(state.width)}x${Math.round(state.height)}`, (state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`,
); );
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const { rpcDataChannel } = useRTCStore();
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
const settings = useSettingsStore();
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
useEffect(() => { useEffect(() => {
if (!rpcDataChannel) return; if (!rpcDataChannel) return;
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed"); rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = e => rpcDataChannel.onerror = (e: Event) =>
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]); }, [rpcDataChannel]);
const keyboardLedState = useHidStore(state => state.keyboardLedState); const { keyboardLedState, usbState } = useHidStore();
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); const { isTurnServerInUse } = useRTCStore();
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); const { hdmiState } = useVideoStore();
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); const displayKeys = useMemo(() => {
if (!showPressedKeys)
return "";
const usbState = useHidStore(state => state.usbState); const activeModifierMask = keysDownState.modifier || 0;
const hdmiState = useVideoStore(state => state.hdmiState); const keysDown = keysDownState.keys || [];
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name);
return [...modifierNames,...keyNames].join(", ");
}, [keysDownState, showPressedKeys]);
return ( return (
<div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300"> <div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300">
<div className="flex flex-wrap items-stretch justify-between gap-1"> <div className="flex flex-wrap items-stretch justify-between gap-1">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex flex-wrap items-center pl-2 gap-x-4"> <div className="flex flex-wrap items-center pl-2 gap-x-4">
{settings.debugMode ? ( {debugMode ? (
<div className="flex"> <div className="flex">
<span className="text-xs font-semibold">Resolution:</span>{" "} <span className="text-xs font-semibold">Resolution:</span>{" "}
<span className="text-xs">{videoSize}</span> <span className="text-xs">{videoSize}</span>
</div> </div>
) : null} ) : null}
{settings.debugMode ? ( {debugMode ? (
<div className="flex"> <div className="flex">
<span className="text-xs font-semibold">Video Size: </span> <span className="text-xs font-semibold">Video Size: </span>
<span className="text-xs">{videoClientSize}</span> <span className="text-xs">{videoClientSize}</span>
</div> </div>
) : null} ) : null}
{(settings.debugMode && settings.mouseMode == "absolute") ? ( {(debugMode && mouseMode == "absolute") ? (
<div className="flex w-[118px] items-center gap-x-1"> <div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span> <span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs"> <span className="text-xs">
@ -74,7 +77,7 @@ export default function InfoBar() {
</div> </div>
) : null} ) : null}
{(settings.debugMode && settings.mouseMode == "relative") ? ( {(debugMode && mouseMode == "relative") ? (
<div className="flex w-[118px] items-center gap-x-1"> <div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Last Move:</span> <span className="text-xs font-semibold">Last Move:</span>
<span className="text-xs"> <span className="text-xs">
@ -85,13 +88,13 @@ export default function InfoBar() {
</div> </div>
) : null} ) : null}
{settings.debugMode && ( {debugMode && (
<div className="flex w-[156px] items-center gap-x-1"> <div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span> <span className="text-xs font-semibold">USB State:</span>
<span className="text-xs">{usbState}</span> <span className="text-xs">{usbState}</span>
</div> </div>
)} )}
{settings.debugMode && ( {debugMode && (
<div className="flex w-[156px] items-center gap-x-1"> <div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HDMI State:</span> <span className="text-xs font-semibold">HDMI State:</span>
<span className="text-xs">{hdmiState}</span> <span className="text-xs">{hdmiState}</span>
@ -102,14 +105,7 @@ export default function InfoBar() {
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span> <span className="text-xs font-semibold">Keys:</span>
<h2 className="text-xs"> <h2 className="text-xs">
{[ {displayKeys}
...activeKeys.map(
x => Object.entries(keys).filter(y => y[1] === x)[0][0],
),
activeModifiers.map(
x => Object.entries(modifiers).filter(y => y[1] === x)[0][0],
),
].join(", ")}
</h2> </h2>
</div> </div>
)} )}
@ -122,23 +118,10 @@ export default function InfoBar() {
</div> </div>
)} )}
{keyboardLedStateSyncAvailable ? (
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
keyboardLedSync !== "browser"
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
title={"Your keyboard LED state is managed by" + (keyboardLedSync === "browser" ? " the browser" : " the host")}
>
{keyboardLedSync === "browser" ? "Browser" : "Host"}
</div>
) : null}
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
keyboardLedState?.caps_lock keyboardLedState.caps_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
@ -148,7 +131,7 @@ export default function InfoBar() {
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
keyboardLedState?.num_lock keyboardLedState.num_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
@ -158,23 +141,28 @@ export default function InfoBar() {
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
keyboardLedState?.scroll_lock keyboardLedState.scroll_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
> >
Scroll Lock Scroll Lock
</div> </div>
{keyboardLedState?.compose ? ( {keyboardLedState.compose ? (
<div className="shrink-0 p-1 px-1.5 text-xs"> <div className="shrink-0 p-1 px-1.5 text-xs">
Compose Compose
</div> </div>
) : null} ) : null}
{keyboardLedState?.kana ? ( {keyboardLedState.kana ? (
<div className="shrink-0 p-1 px-1.5 text-xs"> <div className="shrink-0 p-1 px-1.5 text-xs">
Kana Kana
</div> </div>
) : null} ) : null}
{keyboardLedState.shift ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Shift
</div>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@ -26,7 +26,7 @@ type InputFieldProps = {
type InputFieldWithLabelProps = InputFieldProps & { type InputFieldWithLabelProps = InputFieldProps & {
label: React.ReactNode; label: React.ReactNode;
description?: string | null; description?: React.ReactNode | string | null;
}; };
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField( const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(

View File

@ -0,0 +1,176 @@
import { useEffect, useMemo, useState } from "react";
import { LuExternalLink } from "react-icons/lu";
import { Button, LinkButton } from "@components/Button";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { InputFieldWithLabel } from "./InputField";
import { SelectMenuBasic } from "./SelectMenuBasic";
export interface JigglerConfig {
inactivity_limit_seconds: number;
jitter_percentage: number;
schedule_cron_tab: string;
timezone?: string;
}
export function JigglerSetting({
onSave,
defaultJigglerState,
}: {
onSave: (jigglerConfig: JigglerConfig) => void;
defaultJigglerState?: JigglerConfig;
}) {
const [jigglerConfigState, setJigglerConfigState] = useState<JigglerConfig>(
defaultJigglerState || {
inactivity_limit_seconds: 20,
jitter_percentage: 0,
schedule_cron_tab: "*/20 * * * * *",
timezone: "UTC",
},
);
const { send } = useJsonRpc();
const [timezones, setTimezones] = useState<string[]>([]);
useEffect(() => {
send("getTimezones", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setTimezones(resp.result as string[]);
});
}, [send]);
const timezoneOptions = useMemo(
() =>
timezones.map((timezone: string) => ({
value: timezone,
label: timezone,
})),
[timezones],
);
const exampleConfigs = [
{
name: "Business Hours 9-17",
config: {
inactivity_limit_seconds: 60,
jitter_percentage: 25,
schedule_cron_tab: "0 * 9-17 * * 1-5",
timezone: jigglerConfigState.timezone || "UTC",
},
},
{
name: "Business Hours 8-17",
config: {
inactivity_limit_seconds: 60,
jitter_percentage: 25,
schedule_cron_tab: "0 * 8-17 * * 1-5",
timezone: jigglerConfigState.timezone || "UTC",
},
},
];
return (
<div className="space-y-4">
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Examples
</h4>
<div className="flex flex-wrap gap-2">
{exampleConfigs.map((example, index) => (
<Button
key={index}
size="XS"
theme="light"
text={example.name}
onClick={() => setJigglerConfigState(example.config)}
/>
))}
<LinkButton
to="https://crontab.guru/examples.html"
size="XS"
theme="light"
text="More examples"
LeadingIcon={LuExternalLink}
/>
</div>
</div>
<div className="grid grid-cols-1 items-end gap-4 md:grid-cols-2">
<InputFieldWithLabel
required
size="SM"
label="Cron Schedule"
description="Cron expression for scheduling"
placeholder="*/20 * * * * *"
value={jigglerConfigState.schedule_cron_tab}
onChange={e =>
setJigglerConfigState({
...jigglerConfigState,
schedule_cron_tab: e.target.value,
})
}
/>
<InputFieldWithLabel
size="SM"
label="Inactivity Limit Seconds"
description="Inactivity time before jiggle"
value={jigglerConfigState.inactivity_limit_seconds}
type="number"
min="1"
max="100"
onChange={e =>
setJigglerConfigState({
...jigglerConfigState,
inactivity_limit_seconds: Number(e.target.value),
})
}
/>
<InputFieldWithLabel
required
size="SM"
label="Random delay"
description="To avoid recognizable patterns"
placeholder="25"
TrailingElm={<span className="px-2 text-xs text-slate-500">%</span>}
value={jigglerConfigState.jitter_percentage}
type="number"
min="0"
max="100"
onChange={e =>
setJigglerConfigState({
...jigglerConfigState,
jitter_percentage: Number(e.target.value),
})
}
/>
<SelectMenuBasic
size="SM"
label="Timezone"
description="Timezone for cron schedule"
value={jigglerConfigState.timezone || "UTC"}
disabled={timezones.length === 0}
onChange={e =>
setJigglerConfigState({
...jigglerConfigState,
timezone: e.target.value,
})
}
options={timezoneOptions}
/>
</div>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text="Save Jiggler Config"
onClick={() => onSave(jigglerConfigState)}
/>
</div>
</div>
);
}

View File

@ -10,7 +10,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
export default function MacroBar() { export default function MacroBar() {
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore(); const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
const { executeMacro } = useKeyboard(); const { executeMacro } = useKeyboard();
const [send] = useJsonRpc(); const { send } = useJsonRpc();
useEffect(() => { useEffect(() => {
setSendFn(send); setSendFn(send);

View File

@ -1,17 +1,18 @@
import { useState } from "react"; import { useState } from "react";
import { LuPlus } from "react-icons/lu"; import { LuPlus } from "react-icons/lu";
import { KeySequence } from "@/hooks/stores";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { InputFieldWithLabel, FieldError } from "@/components/InputField"; import FieldLabel from "@/components/FieldLabel";
import Fieldset from "@/components/Fieldset"; import Fieldset from "@/components/Fieldset";
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import { MacroStepCard } from "@/components/MacroStepCard"; import { MacroStepCard } from "@/components/MacroStepCard";
import { import {
DEFAULT_DELAY, DEFAULT_DELAY,
MAX_STEPS_PER_MACRO, MAX_STEPS_PER_MACRO,
MAX_KEYS_PER_STEP, MAX_KEYS_PER_STEP,
} from "@/constants/macros"; } from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel"; import { KeySequence } from "@/hooks/stores";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
interface ValidationErrors { interface ValidationErrors {
name?: string; name?: string;
@ -44,6 +45,7 @@ export function MacroForm({
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({}); const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
const [errors, setErrors] = useState<ValidationErrors>({}); const [errors, setErrors] = useState<ValidationErrors>({});
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { selectedKeyboard } = useKeyboardLayout();
const showTemporaryError = (message: string) => { const showTemporaryError = (message: string) => {
setErrorMessage(message); setErrorMessage(message);
@ -234,6 +236,7 @@ export function MacroForm({
} }
onDelayChange={delay => handleDelayChange(stepIndex, delay)} onDelayChange={delay => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1} isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
keyboard={selectedKeyboard}
/> />
))} ))}
</div> </div>

View File

@ -1,23 +1,18 @@
import { useMemo } from "react";
import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu"; import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Combobox } from "@/components/Combobox"; import { Combobox } from "@/components/Combobox";
import { SelectMenuBasic } from "@/components/SelectMenuBasic"; import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel"; import FieldLabel from "@/components/FieldLabel";
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
import { KeyboardLayout } from "@/keyboardLayouts";
import { keys, modifiers } from "@/keyboardMappings";
// Filter out modifier keys since they're handled in the modifiers section // Filter out modifier keys since they're handled in the modifiers section
const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta']; const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
const keyOptions = Object.keys(keys)
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
.map(key => ({
value: key,
label: keyDisplayMap[key] || key,
}));
const modifierOptions = Object.keys(modifiers).map(modifier => ({ const modifierOptions = Object.keys(modifiers).map(modifier => ({
value: modifier, value: modifier,
label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"), label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
@ -67,6 +62,7 @@ interface MacroStepCardProps {
onModifierChange: (modifiers: string[]) => void; onModifierChange: (modifiers: string[]) => void;
onDelayChange: (delay: number) => void; onDelayChange: (delay: number) => void;
isLastStep: boolean; isLastStep: boolean;
keyboard: KeyboardLayout
} }
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => { const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
@ -84,9 +80,22 @@ export function MacroStepCard({
keyQuery, keyQuery,
onModifierChange, onModifierChange,
onDelayChange, onDelayChange,
isLastStep isLastStep,
keyboard
}: MacroStepCardProps) { }: MacroStepCardProps) {
const getFilteredKeys = () => { const { keyDisplayMap } = keyboard;
const keyOptions = useMemo(() =>
Object.keys(keys)
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
.map(key => ({
value: key,
label: keyDisplayMap[key] || key,
})),
[keyDisplayMap]
);
const filteredKeys = useMemo(() => {
const selectedKeys = ensureArray(step.keys); const selectedKeys = ensureArray(step.keys);
const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value)); const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
@ -95,7 +104,7 @@ export function MacroStepCard({
} else { } else {
return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase())); return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
} }
}; }, [keyOptions, keyQuery, step.keys]);
return ( return (
<Card className="p-4"> <Card className="p-4">
@ -204,7 +213,7 @@ export function MacroStepCard({
}} }}
displayValue={() => keyQuery} displayValue={() => keyQuery}
onInputChange={onKeyQueryChange} onInputChange={onKeyQueryChange}
options={getFilteredKeys} options={() => filteredKeys}
disabledMessage="Max keys reached" disabledMessage="Max keys reached"
size="SM" size="SM"
immediate immediate

View File

@ -26,7 +26,7 @@ type SelectMenuProps = Pick<
const sizes = { const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs", XS: "h-[24.5px] pl-3 pr-8 text-xs",
SM: "h-[32px] pl-3 pr-8 text-[13px]", SM: "h-[36px] pl-3 pr-8 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm", MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base", LG: "h-[48px] pl-4 pr-10 px-5 text-base",
}; };
@ -62,7 +62,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
"text-sm", "text-sm",
)} )}
> >
{label && <FieldLabel label={label} id={id} as="span" />} {label && <FieldLabel label={label} id={id} />}
<Card className="w-auto border! border-solid border-slate-800/30! shadow-xs outline-0 dark:border-slate-300/30!"> <Card className="w-auto border! border-solid border-slate-800/30! shadow-xs outline-0 dark:border-slate-300/30!">
<select <select
ref={ref} ref={ref}

View File

@ -0,0 +1,11 @@
export default function SettingsNestedSection({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="ml-2 border-l border-slate-800/30 pl-4 dark:border-slate-300/30">
{children}
</div>
);
}

View File

@ -1,6 +1,6 @@
import "react-simple-keyboard/build/css/index.css"; import "react-simple-keyboard/build/css/index.css";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { useXTerm } from "react-xtermjs"; import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebLinksAddon } from "@xterm/addon-web-links";
@ -65,21 +65,22 @@ function Terminal({
readonly dataChannel: RTCDataChannel; readonly dataChannel: RTCDataChannel;
readonly type: AvailableTerminalTypes; readonly type: AvailableTerminalTypes;
}) { }) {
const enableTerminal = useUiStore(state => state.terminalType == type); const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore();
const setTerminalType = useUiStore(state => state.setTerminalType);
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG }); const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
const isTerminalTypeEnabled = useMemo(() => {
return terminalType == type;
}, [terminalType, type]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
setDisableKeyboardFocusTrap(enableTerminal); setDisableVideoFocusTrap(isTerminalTypeEnabled);
}, 500); }, 500);
return () => { return () => {
setDisableKeyboardFocusTrap(false); setDisableVideoFocusTrap(false);
}; };
}, [ref, instance, enableTerminal, setDisableKeyboardFocusTrap, type]); }, [setDisableVideoFocusTrap, isTerminalTypeEnabled]);
const readyState = dataChannel.readyState; const readyState = dataChannel.readyState;
useEffect(() => { useEffect(() => {
@ -116,7 +117,7 @@ function Terminal({
const { domEvent } = e; const { domEvent } = e;
if (domEvent.key === "Escape") { if (domEvent.key === "Escape") {
setTerminalType("none"); setTerminalType("none");
setDisableKeyboardFocusTrap(false); setDisableVideoFocusTrap(false);
domEvent.preventDefault(); domEvent.preventDefault();
} }
}); });
@ -131,7 +132,7 @@ function Terminal({
onDataHandler.dispose(); onDataHandler.dispose();
onKeyHandler.dispose(); onKeyHandler.dispose();
}; };
}, [instance, dataChannel, readyState, setDisableKeyboardFocusTrap, setTerminalType]); }, [dataChannel, instance, readyState, setDisableVideoFocusTrap, setTerminalType]);
useEffect(() => { useEffect(() => {
if (!instance) return; if (!instance) return;
@ -158,7 +159,7 @@ function Terminal({
return () => { return () => {
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
}; };
}, [ref, instance]); }, [instance]);
return ( return (
<div <div
@ -175,9 +176,9 @@ function Terminal({
], ],
{ {
"pointer-events-none translate-y-[500px] opacity-100 transition duration-300": "pointer-events-none translate-y-[500px] opacity-100 transition duration-300":
!enableTerminal, !isTerminalTypeEnabled,
"pointer-events-auto -translate-y-[0px] opacity-100 transition duration-300": "pointer-events-auto -translate-y-[0px] opacity-100 transition duration-300":
enableTerminal, isTerminalTypeEnabled,
}, },
)} )}
> >

View File

@ -4,9 +4,7 @@ import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png"; import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards"; import StatusCard from "@components/StatusCards";
import { HidState } from "@/hooks/stores"; import { USBStates } from "@/hooks/stores";
type USBStates = HidState["usbState"];
type StatusProps = Record< type StatusProps = Record<
USBStates, USBStates,
@ -67,7 +65,7 @@ export default function USBStateStatus({
}; };
const props = StatusCardProps[state]; const props = StatusCardProps[state];
if (!props) { if (!props) {
console.log("Unsupported USB state: ", state); console.warn("Unsupported USB state: ", state);
return; return;
} }

View File

@ -1,6 +1,6 @@
import { useCallback , useEffect, useState } from "react"; import { useCallback , useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications"; import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings"; import { SettingsItem } from "../routes/devices.$id.settings";
@ -59,7 +59,7 @@ const usbPresets = [
]; ];
export function UsbDeviceSetting() { export function UsbDeviceSetting() {
const [send] = useJsonRpc(); const { send } = useJsonRpc();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [usbDeviceConfig, setUsbDeviceConfig] = const [usbDeviceConfig, setUsbDeviceConfig] =
@ -67,7 +67,7 @@ export function UsbDeviceSetting() {
const [selectedPreset, setSelectedPreset] = useState<string>("default"); const [selectedPreset, setSelectedPreset] = useState<string>("default");
const syncUsbDeviceConfig = useCallback(() => { const syncUsbDeviceConfig = useCallback(() => {
send("getUsbDevices", {}, resp => { send("getUsbDevices", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
console.error("Failed to load USB devices:", resp.error); console.error("Failed to load USB devices:", resp.error);
notifications.error( notifications.error(
@ -97,7 +97,7 @@ export function UsbDeviceSetting() {
const handleUsbConfigChange = useCallback( const handleUsbConfigChange = useCallback(
(devices: UsbDeviceConfig) => { (devices: UsbDeviceConfig) => {
setLoading(true); setLoading(true);
send("setUsbDevices", { devices }, async resp => { send("setUsbDevices", { devices }, async (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`, `Failed to set usb devices: ${resp.error.data || "Unknown error"}`,
@ -127,7 +127,7 @@ export function UsbDeviceSetting() {
); );
const handlePresetChange = useCallback( const handlePresetChange = useCallback(
async (e: React.ChangeEvent<HTMLSelectElement>) => { (e: React.ChangeEvent<HTMLSelectElement>) => {
const newPreset = e.target.value; const newPreset = e.target.value;
setSelectedPreset(newPreset); setSelectedPreset(newPreset);

View File

@ -4,7 +4,7 @@ import { Button } from "@components/Button";
import { UsbConfigState } from "../hooks/stores"; import { UsbConfigState } from "../hooks/stores";
import { useJsonRpc } from "../hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications"; import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings"; import { SettingsItem } from "../routes/devices.$id.settings";
@ -54,7 +54,7 @@ const usbConfigs = [
type UsbConfigMap = Record<string, USBConfig>; type UsbConfigMap = Record<string, USBConfig>;
export function UsbInfoSetting() { export function UsbInfoSetting() {
const [send] = useJsonRpc(); const { send } = useJsonRpc();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [usbConfigProduct, setUsbConfigProduct] = useState(""); const [usbConfigProduct, setUsbConfigProduct] = useState("");
@ -94,15 +94,15 @@ export function UsbInfoSetting() {
); );
const syncUsbConfigProduct = useCallback(() => { const syncUsbConfigProduct = useCallback(() => {
send("getUsbConfig", {}, resp => { send("getUsbConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error); console.error("Failed to load USB Config:", resp.error);
notifications.error( notifications.error(
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`, `Failed to load USB Config: ${resp.error.data || "Unknown error"}`,
); );
} else { } else {
console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result);
const usbConfigState = resp.result as UsbConfigState; const usbConfigState = resp.result as UsbConfigState;
console.log("syncUsbConfigProduct#getUsbConfig result:", usbConfigState);
const product = usbConfigs.map(u => u.value).includes(usbConfigState.product) const product = usbConfigs.map(u => u.value).includes(usbConfigState.product)
? usbConfigState.product ? usbConfigState.product
: "custom"; : "custom";
@ -114,7 +114,7 @@ export function UsbInfoSetting() {
const handleUsbConfigChange = useCallback( const handleUsbConfigChange = useCallback(
(usbConfig: USBConfig) => { (usbConfig: USBConfig) => {
setLoading(true); setLoading(true);
send("setUsbConfig", { usbConfig }, async resp => { send("setUsbConfig", { usbConfig }, async (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set usb config: ${resp.error.data || "Unknown error"}`, `Failed to set usb config: ${resp.error.data || "Unknown error"}`,
@ -137,7 +137,7 @@ export function UsbInfoSetting() {
); );
useEffect(() => { useEffect(() => {
send("getDeviceID", {}, async resp => { send("getDeviceID", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
return notifications.error( return notifications.error(
`Failed to get device ID: ${resp.error.data || "Unknown error"}`, `Failed to get device ID: ${resp.error.data || "Unknown error"}`,
@ -205,10 +205,10 @@ function USBConfigDialog({
product: "", product: "",
}); });
const [send] = useJsonRpc(); const { send } = useJsonRpc();
const syncUsbConfig = useCallback(() => { const syncUsbConfig = useCallback(() => {
send("getUsbConfig", {}, resp => { send("getUsbConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error); console.error("Failed to load USB Config:", resp.error);
} else { } else {

View File

@ -1,4 +1,3 @@
import { useShallow } from "zustand/react/shallow";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -13,9 +12,10 @@ import "react-simple-keyboard/build/css/index.css";
import AttachIconRaw from "@/assets/attach-icon.svg"; import AttachIconRaw from "@/assets/attach-icon.svg";
import DetachIconRaw from "@/assets/detach-icon.svg"; import DetachIconRaw from "@/assets/detach-icon.svg";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings"; import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import { keys, modifiers, latchingKeys, decodeModifiers } from "@/keyboardMappings";
export const DetachIcon = ({ className }: { className?: string }) => { export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />; return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
@ -26,34 +26,47 @@ const AttachIcon = ({ className }: { className?: string }) => {
}; };
function KeyboardWrapper() { function KeyboardWrapper() {
const [layoutName, setLayoutName] = useState("default");
const keyboardRef = useRef<HTMLDivElement>(null); const keyboardRef = useRef<HTMLDivElement>(null);
const showAttachedVirtualKeyboard = useUiStore( const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = useUiStore();
state => state.isAttachedVirtualKeyboardVisible, const { keysDownState, /* keyboardLedState,*/ isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
); const { handleKeyPress, executeMacro } = useKeyboard();
const setShowAttachedVirtualKeyboard = useUiStore( const { selectedKeyboard } = useKeyboardLayout();
state => state.setAttachedVirtualKeyboardVisibility,
);
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 }); const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock)); const keyDisplayMap = useMemo(() => {
return selectedKeyboard.keyDisplayMap;
}, [selectedKeyboard]);
// HID related states const virtualKeyboard = useMemo(() => {
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); return selectedKeyboard.virtualKeyboard;
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); }, [selectedKeyboard]);
const isKeyboardLedManagedByHost = useMemo(() =>
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
[keyboardLedSync, keyboardLedStateSyncAvailable],
);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive); //const isCapsLockActive = useMemo(() => {
// return (keyboardLedState.caps_lock);
//}, [keyboardLedState]);
const { isShiftActive, /*isControlActive, isAltActive, isMetaActive, isAltGrActive*/ } = useMemo(() => {
return decodeModifiers(keysDownState.modifier);
}, [keysDownState]);
const mainLayoutName = useMemo(() => {
const layoutName = isShiftActive ? "shift": "default";
return layoutName;
}, [isShiftActive]);
const keyNamesForDownKeys = useMemo(() => {
const activeModifierMask = keysDownState.modifier || 0;
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
const keysDown = keysDownState.keys || [];
const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name);
return [...modifierNames,...keyNames, ' ']; // we have to have at least one space to avoid keyboard whining
}, [keysDownState]);
const startDrag = useCallback((e: MouseEvent | TouchEvent) => { const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return; if (!keyboardRef.current) return;
if (e instanceof TouchEvent && e.touches.length > 1) return; if (e instanceof TouchEvent && e.touches.length > 1) return;
@ -123,94 +136,69 @@ function KeyboardWrapper() {
}; };
}, [endDrag, onDrag, startDrag]); }, [endDrag, onDrag, startDrag]);
const onKeyUp = useCallback(
async (_: string, e: MouseEvent | undefined) => {
e?.preventDefault();
e?.stopPropagation();
},
[]
);
const onKeyDown = useCallback( const onKeyDown = useCallback(
(key: string) => { async (key: string, e: MouseEvent | undefined) => {
const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight"; e?.preventDefault();
const isKeyCaps = key === "CapsLock"; e?.stopPropagation();
const cleanKey = key.replace(/[()]/g, "");
const keyHasShiftModifier = key.includes("(");
// Handle toggle of layout for shift or caps lock
const toggleLayout = () => {
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
};
// handle the fake key-macros we have defined for common combinations
if (key === "CtrlAltDelete") { if (key === "CtrlAltDelete") {
sendKeyboardEvent( await executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]);
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return; return;
} }
if (key === "AltMetaEscape") { if (key === "AltMetaEscape") {
sendKeyboardEvent( await executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]);
[keys["Escape"]],
[modifiers["MetaLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return; return;
} }
if (key === "CtrlAltBackspace") { if (key === "CtrlAltBackspace") {
sendKeyboardEvent( await executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]);
[keys["Backspace"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return; return;
} }
if (isKeyShift || isKeyCaps) { // if they press any of the latching keys, we send a keypress down event and the release it automatically (on timer)
toggleLayout(); if (latchingKeys.includes(key)) {
console.debug(`Latching key pressed: ${key} sending down and delayed up pair`);
if (isCapsLockActive) { handleKeyPress(keys[key], true)
if (!isKeyboardLedManagedByHost) { setTimeout(() => handleKeyPress(keys[key], false), 100);
setIsCapsLockActive(false); return;
}
sendKeyboardEvent([keys["CapsLock"]], []);
return;
}
} }
// Handle caps lock state change // if they press any of the dynamic keys, we send a keypress down event but we don't release it until they click it again
if (isKeyCaps && !isKeyboardLedManagedByHost) { if (Object.keys(modifiers).includes(key)) {
setIsCapsLockActive(!isCapsLockActive); const currentlyDown = keyNamesForDownKeys.includes(key);
console.debug(`Dynamic key pressed: ${key} was currently down: ${currentlyDown}, toggling state`);
handleKeyPress(keys[key], !currentlyDown)
return;
} }
// Collect new active keys and modifiers // otherwise, just treat it as a down+up pair
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : []; const cleanKey = key.replace(/[()]/g, "");
const newModifiers = console.debug(`Regular key pressed: ${cleanKey} sending down and up pair`);
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : []; handleKeyPress(keys[cleanKey], true);
setTimeout(() => handleKeyPress(keys[cleanKey], false), 50);
// Update current keys and modifiers
sendKeyboardEvent(newKeys, newModifiers);
// If shift was used as a modifier and caps lock is not active, revert to default layout
if (keyHasShiftModifier && !isCapsLockActive) {
setLayoutName("default");
}
setTimeout(resetKeyboardState, 100);
}, },
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive], [executeMacro, handleKeyPress, keyNamesForDownKeys],
); );
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
return ( return (
<div <div
className="transition-all duration-500 ease-in-out" className="transition-all duration-500 ease-in-out"
style={{ style={{
marginBottom: virtualKeyboard ? "0px" : `-${350}px`, marginBottom: isVirtualKeyboardEnabled ? "0px" : `-${350}px`,
}} }}
> >
<AnimatePresence> <AnimatePresence>
{virtualKeyboard && ( {isVirtualKeyboardEnabled && (
<motion.div <motion.div
initial={{ opacity: 0, y: "100%" }} initial={{ opacity: 0, y: "100%" }}
animate={{ opacity: 1, y: "0%" }} animate={{ opacity: 1, y: "0%" }}
@ -222,30 +210,30 @@ function KeyboardWrapper() {
> >
<div <div
className={cx( className={cx(
!showAttachedVirtualKeyboard !isAttachedVirtualKeyboardVisible
? "fixed left-0 top-0 z-50 select-none" ? "fixed left-0 top-0 z-50 select-none"
: "relative", : "relative",
)} )}
ref={keyboardRef} ref={keyboardRef}
style={{ style={{
...(!showAttachedVirtualKeyboard ...(!isAttachedVirtualKeyboardVisible
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` } ? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}), : {}),
}} }}
> >
<Card <Card
className={cx("overflow-hidden", { className={cx("overflow-hidden", {
"rounded-none": showAttachedVirtualKeyboard, "rounded-none": isAttachedVirtualKeyboardVisible,
})} })}
> >
<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="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"> <div className="absolute left-2 flex items-center gap-x-2">
{showAttachedVirtualKeyboard ? ( {isAttachedVirtualKeyboardVisible ? (
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Detach" text="Detach"
onClick={() => setShowAttachedVirtualKeyboard(false)} onClick={() => setAttachedVirtualKeyboardVisibility(false)}
/> />
) : ( ) : (
<Button <Button
@ -253,7 +241,7 @@ function KeyboardWrapper() {
theme="light" theme="light"
text="Attach" text="Attach"
LeadingIcon={AttachIcon} LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)} onClick={() => setAttachedVirtualKeyboardVisibility(true)}
/> />
)} )}
</div> </div>
@ -266,7 +254,7 @@ function KeyboardWrapper() {
theme="light" theme="light"
text="Hide" text="Hide"
LeadingIcon={ChevronDownIcon} LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)} onClick={() => setVirtualKeyboardEnabled(false)}
/> />
</div> </div>
</div> </div>
@ -275,66 +263,61 @@ function KeyboardWrapper() {
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700"> <div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
<Keyboard <Keyboard
baseClass="simple-keyboard-main" baseClass="simple-keyboard-main"
layoutName={layoutName} layoutName={mainLayoutName}
onKeyPress={onKeyDown} onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
buttonTheme={[ buttonTheme={[
{ {
class: "combination-key", class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace", buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
}, },
{
class: "down-key",
buttons: keyNamesForDownKeys.join(" "),
},
]} ]}
display={keyDisplayMap} display={keyDisplayMap}
layout={{ layout={virtualKeyboard.main}
default: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"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 CtrlAltBackspace",
"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} disableButtonHold={true}
syncInstanceInputs={true} enableLayoutCandidates={false}
debug={false} preventMouseDownDefault={true}
preventMouseUpDefault={true}
stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
/> />
<div className="controlArrows"> <div className="controlArrows">
<Keyboard <Keyboard
baseClass="simple-keyboard-control" baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default" theme="simple-keyboard hg-theme-default hg-layout-default"
layoutName={layoutName} layoutName="default"
onKeyPress={onKeyDown} onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
display={keyDisplayMap} display={keyDisplayMap}
layout={{ layout={virtualKeyboard.control}
default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"], disableButtonHold={true}
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"], enableLayoutCandidates={false}
}} preventMouseDownDefault={true}
syncInstanceInputs={true} preventMouseUpDefault={true}
debug={false} stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
/> />
<Keyboard <Keyboard
baseClass="simple-keyboard-arrows" baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default" theme="simple-keyboard hg-theme-default hg-layout-default"
onKeyPress={onKeyDown} onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
display={keyDisplayMap} display={keyDisplayMap}
layout={{ layout={virtualKeyboard.arrows}
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"], disableButtonHold={true}
}} enableLayoutCandidates={false}
syncInstanceInputs={true} preventMouseDownDefault={true}
debug={false} preventMouseUpDefault={true}
stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
/> />
</div> </div>
{ /* TODO add optional number pad */ }
</div> </div>
</div> </div>
</Card> </Card>

View File

@ -9,9 +9,8 @@ import notifications from "@/notifications";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings"; import { keys } from "@/keyboardMappings";
import { import {
useHidStore,
useMouseStore, useMouseStore,
useRTCStore, useRTCStore,
useSettingsStore, useSettingsStore,
@ -28,15 +27,14 @@ import {
export default function WebRTCVideo() { export default function WebRTCVideo() {
// Video and stream related refs and states // Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null); const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream); const { mediaStream, peerConnectionState } = useRTCStore();
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const [isPointerLockActive, setIsPointerLockActive] = useState(false); const [isPointerLockActive, setIsPointerLockActive] = useState(false);
const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false);
// Store hooks // Store hooks
const settings = useSettingsStore(); const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); const { handleKeyPress, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition); const { setMousePosition, setMouseMove } = useMouseStore();
const setMouseMove = useMouseStore(state => state.setMouseMove);
const { const {
setClientSize: setVideoClientSize, setClientSize: setVideoClientSize,
setSize: setVideoSize, setSize: setVideoSize,
@ -44,49 +42,39 @@ export default function WebRTCVideo() {
height: videoHeight, height: videoHeight,
clientWidth: videoClientWidth, clientWidth: videoClientWidth,
clientHeight: videoClientHeight, clientHeight: videoClientHeight,
hdmiState,
} = useVideoStore(); } = useVideoStore();
// Video enhancement settings // Video enhancement settings
const videoSaturation = useSettingsStore(state => state.videoSaturation); const { videoSaturation, videoBrightness, videoContrast } = useSettingsStore();
const videoBrightness = useSettingsStore(state => state.videoBrightness);
const videoContrast = useSettingsStore(state => state.videoContrast);
// HID related states
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = useMemo(() =>
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
[keyboardLedSync, keyboardLedStateSyncAvailable],
);
const setIsNumLockActive = useHidStore(state => state.setIsNumLockActive);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
const setIsScrollLockActive = useHidStore(state => state.setIsScrollLockActive);
// RTC related states // RTC related states
const peerConnection = useRTCStore(state => state.peerConnection); const { peerConnection } = useRTCStore();
const hidDataChannel = useRTCStore(state => state.hidDataChannel); const hidDataChannel = useRTCStore(state => state.hidDataChannel);
// HDMI and UI states // HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isVideoLoading = !isPlaying; const isVideoLoading = !isPlaying;
// Mouse wheel states
const [blockWheelEvent, setBlockWheelEvent] = useState(false); const [blockWheelEvent, setBlockWheelEvent] = useState(false);
// Misc states and hooks // Misc states and hooks
const [send] = useJsonRpc(); const { send } = useJsonRpc();
// Video-related // Video-related
const handleResize = useCallback(
( { width, height }: { width: number | undefined; height: number | undefined }) => {
if (!videoElm.current) return;
// Do something with width and height, e.g.:
setVideoClientSize(width || 0, height || 0);
setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight);
},
[setVideoClientSize, setVideoSize]
);
useResizeObserver({ useResizeObserver({
ref: videoElm as React.RefObject<HTMLElement>, ref: videoElm as React.RefObject<HTMLElement>,
onResize: ({ width, height }) => { onResize: handleResize,
// This is actually client size, not videoSize
if (width && height) {
if (!videoElm.current) return;
setVideoClientSize(width, height);
setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight);
}
},
}); });
const updateVideoSizeStore = useCallback( const updateVideoSizeStore = useCallback(
@ -107,15 +95,15 @@ export default function WebRTCVideo() {
function updateVideoSizeOnMount() { function updateVideoSizeOnMount() {
if (videoElm.current) updateVideoSizeStore(videoElm.current); if (videoElm.current) updateVideoSizeStore(videoElm.current);
}, },
[setVideoClientSize, updateVideoSizeStore, setVideoSize], [updateVideoSizeStore],
); );
// Pointer lock and keyboard lock related // Pointer lock and keyboard lock related
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost"; const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
const isFullscreenEnabled = document.fullscreenEnabled; const isFullscreenEnabled = document.fullscreenEnabled;
const checkNavigatorPermissions = useCallback(async (permissionName: string) => { const checkNavigatorPermissions = useCallback(async (permissionName: string) => {
if (!navigator.permissions || !navigator.permissions.query) { if (!navigator || !navigator.permissions || !navigator.permissions.query) {
return false; // if can't query permissions, assume NOT granted return false; // if can't query permissions, assume NOT granted
} }
@ -149,29 +137,31 @@ export default function WebRTCVideo() {
if (videoElm.current === null) return; if (videoElm.current === null) return;
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock"); const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
if (isKeyboardLockGranted && "keyboard" in navigator) { if (isKeyboardLockGranted && navigator && "keyboard" in navigator) {
try { try {
// @ts-expect-error - keyboard lock is not supported in all browsers // @ts-expect-error - keyboard lock is not supported in all browsers
await navigator.keyboard.lock(); await navigator.keyboard.lock();
setIsKeyboardLockActive(true);
} catch { } catch {
// ignore errors // ignore errors
} }
} }
}, [checkNavigatorPermissions]); }, [checkNavigatorPermissions, setIsKeyboardLockActive]);
const releaseKeyboardLock = useCallback(async () => { const releaseKeyboardLock = useCallback(async () => {
if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return; if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return;
if ("keyboard" in navigator) { if (navigator && "keyboard" in navigator) {
try { try {
// @ts-expect-error - keyboard unlock is not supported in all browsers // @ts-expect-error - keyboard unlock is not supported in all browsers
await navigator.keyboard.unlock(); await navigator.keyboard.unlock();
} catch { } catch {
// ignore errors // ignore errors
} }
setIsKeyboardLockActive(false);
} }
}, []); }, [setIsKeyboardLockActive]);
useEffect(() => { useEffect(() => {
if (!isPointerLockPossible || !videoElm.current) return; if (!isPointerLockPossible || !videoElm.current) return;
@ -197,7 +187,7 @@ export default function WebRTCVideo() {
}, [isPointerLockPossible]); }, [isPointerLockPossible]);
const requestFullscreen = useCallback(async () => { const requestFullscreen = useCallback(async () => {
if (!isFullscreenEnabled || !videoElm.current) return; if (!isFullscreenEnabled || !videoElm.current) return;
// per https://wicg.github.io/keyboard-lock/#system-key-press-handler // per https://wicg.github.io/keyboard-lock/#system-key-press-handler
// If keyboard lock is activated after fullscreen is already in effect, then the user my // If keyboard lock is activated after fullscreen is already in effect, then the user my
@ -354,153 +344,58 @@ export default function WebRTCVideo() {
sendAbsMouseMovement(0, 0, 0); sendAbsMouseMovement(0, 0, 0);
}, [sendAbsMouseMovement]); }, [sendAbsMouseMovement]);
// Keyboard-related
const handleModifierKeys = useCallback(
(e: KeyboardEvent, activeModifiers: number[]) => {
const { shiftKey, ctrlKey, altKey, metaKey } = e;
const filteredModifiers = activeModifiers.filter(Boolean);
// Example: activeModifiers = [0x01, 0x02, 0x04, 0x08]
// Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft
return (
filteredModifiers
// Shift: Keep if Shift is pressed or if the key isn't a Shift key
// Example: If shiftKey is true, keep all modifiers
// If shiftKey is false, filter out 0x02 (ShiftLeft) and 0x20 (ShiftRight)
.filter(
modifier =>
shiftKey ||
(modifier !== modifiers["ShiftLeft"] &&
modifier !== modifiers["ShiftRight"]),
)
// Ctrl: Keep if Ctrl is pressed or if the key isn't a Ctrl key
// Example: If ctrlKey is true, keep all modifiers
// If ctrlKey is false, filter out 0x01 (ControlLeft) and 0x10 (ControlRight)
.filter(
modifier =>
ctrlKey ||
(modifier !== modifiers["ControlLeft"] &&
modifier !== modifiers["ControlRight"]),
)
// Alt: Keep if Alt is pressed or if the key isn't an Alt key
// Example: If altKey is true, keep all modifiers
// If altKey is false, filter out 0x04 (AltLeft)
//
// But intentionally do not filter out 0x40 (AltRight) to accomodate
// Alt Gr (Alt Graph) as a modifier. Oddly, Alt Gr does not declare
// itself to be an altKey. For example, the KeyboardEvent for
// Alt Gr + 2 has the following structure:
// - altKey: false
// - code: "Digit2"
// - type: [ "keydown" | "keyup" ]
//
// For context, filteredModifiers aims to keep track which modifiers
// are being pressed on the physical keyboard at any point in time.
// There is logic in the keyUpHandler and keyDownHandler to add and
// remove 0x40 (AltRight) from the list of new modifiers.
//
// But relying on the two handlers alone to track the state of the
// modifier bears the risk that the key up event for Alt Gr could
// get lost while the browser window is temporarily out of focus,
// which means the Alt Gr key state would then be "stuck". At this
// point, we would need to rely on the user to press Alt Gr again
// to properly release the state of that modifier.
.filter(modifier => altKey || modifier !== modifiers["AltLeft"])
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
// Example: If metaKey is true, keep all modifiers
// If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight)
.filter(
modifier =>
metaKey ||
(modifier !== modifiers["MetaLeft"] && modifier !== modifiers["MetaRight"]),
)
);
},
[],
);
const keyDownHandler = useCallback( const keyDownHandler = useCallback(
async (e: KeyboardEvent) => { (e: KeyboardEvent) => {
e.preventDefault(); e.preventDefault();
const prev = useHidStore.getState(); const code = getAdjustedKeyCode(e);
let code = e.code; const hidKey = keys[code];
const key = e.key;
if (!isKeyboardLedManagedByHost) { if (hidKey === undefined) {
setIsNumLockActive(e.getModifierState("NumLock")); console.warn(`Key down not mapped: ${code}`);
setIsCapsLockActive(e.getModifierState("CapsLock")); return;
setIsScrollLockActive(e.getModifierState("ScrollLock"));
} }
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
code = "IntlBackslash";
}
// Add the key to the active keys
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
// Add the modifier to the active modifiers
const newModifiers = handleModifierKeys(e, [
...prev.activeModifiers,
modifiers[code],
]);
// When pressing the meta key + another key, the key will never trigger a keyup // When pressing the meta key + another key, the key will never trigger a keyup
// event, so we need to clear the keys after a short delay // event, so we need to clear the keys after a short delay
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089 // https://bugs.chromium.org/p/chromium/issues/detail?id=28089
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
if (e.metaKey) { if (e.metaKey && hidKey < 0xE0) {
setTimeout(() => { setTimeout(() => {
const prev = useHidStore.getState(); console.debug(`Forcing the meta key release of associated key: ${hidKey}`);
sendKeyboardEvent([], newModifiers || prev.activeModifiers); handleKeyPress(hidKey, false);
}, 10); }, 10);
} }
console.debug(`Key down: ${hidKey}`);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); handleKeyPress(hidKey, true);
if (!isKeyboardLockActive && hidKey === keys.MetaLeft) {
// If the left meta key was just pressed and we're not keyboard locked
// we'll never see the keyup event because the browser is going to lose
// focus so set a deferred keyup after a short delay
setTimeout(() => {
console.debug(`Forcing the left meta key release`);
handleKeyPress(hidKey, false);
}, 100);
}
}, },
[ [handleKeyPress, isKeyboardLockActive],
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
],
); );
const keyUpHandler = useCallback( const keyUpHandler = useCallback(
(e: KeyboardEvent) => { async (e: KeyboardEvent) => {
e.preventDefault(); e.preventDefault();
const prev = useHidStore.getState(); const code = getAdjustedKeyCode(e);
const hidKey = keys[code];
if (!isKeyboardLedManagedByHost) { if (hidKey === undefined) {
setIsNumLockActive(e.getModifierState("NumLock")); console.warn(`Key up not mapped: ${code}`);
setIsCapsLockActive(e.getModifierState("CapsLock")); return;
setIsScrollLockActive(e.getModifierState("ScrollLock"));
} }
// Filtering out the key that was just released (keys[e.code]) console.debug(`Key up: ${hidKey}`);
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean); handleKeyPress(hidKey, false);
// Filter out the modifier that was just released
const newModifiers = handleModifierKeys(
e,
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
}, },
[ [handleKeyPress],
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
],
); );
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
@ -511,7 +406,7 @@ export default function WebRTCVideo() {
// Fix only works in chrome based browsers. // Fix only works in chrome based browsers.
if (e.code === "Space") { if (e.code === "Space") {
if (videoElm.current.paused) { if (videoElm.current.paused) {
console.log("Force playing video"); console.debug("Force playing video");
videoElm.current.play(); videoElm.current.play();
} }
} }
@ -554,13 +449,7 @@ export default function WebRTCVideo() {
// We set the as early as possible // We set the as early as possible
addStreamToVideoElm(mediaStream); addStreamToVideoElm(mediaStream);
}, },
[ [addStreamToVideoElm, mediaStream],
setVideoClientSize,
mediaStream,
updateVideoSizeStore,
peerConnection,
addStreamToVideoElm,
],
); );
// Setup Keyboard Events // Setup Keyboard Events
@ -616,7 +505,7 @@ export default function WebRTCVideo() {
videoElmRefValue.addEventListener("mousemove", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("mousemove", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerdown", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler :absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal, signal,
passive: true, passive: true,
@ -667,6 +556,28 @@ export default function WebRTCVideo() {
return true; return true;
}, [isPlaying, isPointerLockActive, isPointerLockPossible, isVideoLoading, settings.mouseMode, videoHeight, videoWidth]); }, [isPlaying, isPointerLockActive, isPointerLockPossible, isVideoLoading, settings.mouseMode, videoHeight, videoWidth]);
// Conditionally set the filter style so we don't fallback to software rendering if these values are default of 1.0
const videoStyle = useMemo(() => {
const isDefault = videoSaturation === 1.0 && videoBrightness === 1.0 && videoContrast === 1.0;
return isDefault
? {} // No filter if all settings are default (1.0)
: {
filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`,
};
}, [videoSaturation, videoBrightness, videoContrast]);
function getAdjustedKeyCode(e: KeyboardEvent) {
const key = e.key;
let code = e.code;
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
code = "IntlBackslash";
}
return code;
}
return ( return (
<div className="grid h-full w-full grid-rows-(--grid-layout)"> <div className="grid h-full w-full grid-rows-(--grid-layout)">
<div className="flex min-h-[39.5px] flex-col"> <div className="flex min-h-[39.5px] flex-col">
@ -699,50 +610,48 @@ export default function WebRTCVideo() {
<PointerLockBar show={showPointerLockBar} /> <PointerLockBar show={showPointerLockBar} />
<div className="relative mx-4 my-2 flex items-center justify-center 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"> <div className="relative flex h-full w-full items-center justify-center">
<video <video
ref={videoElm} ref={videoElm}
autoPlay={true} autoPlay
controls={false} controls={false}
onPlaying={onVideoPlaying} onPlaying={onVideoPlaying}
onPlay={onVideoPlaying} onPlay={onVideoPlaying}
muted={true} muted
playsInline playsInline
disablePictureInPicture disablePictureInPicture
controlsList="nofullscreen" controlsList="nofullscreen"
style={{ style={videoStyle}
filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`, className={cx(
}} "max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
className={cx( {
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000", "cursor-none": settings.isCursorHidden,
{ "opacity-0":
"cursor-none": settings.isCursorHidden, isVideoLoading ||
"opacity-0": hdmiError ||
isVideoLoading || peerConnectionState !== "connected",
hdmiError || "opacity-60!": showPointerLockBar,
peerConnectionState !== "connected", "animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":
"opacity-60!": showPointerLockBar, isPlaying,
"animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20": },
isPlaying,
},
)}
/>
{peerConnection?.connectionState == "connected" && (
<div
style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div className="relative h-full w-full rounded-md">
<LoadingVideoOverlay show={isVideoLoading} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
}}
/>
</div>
</div>
)} )}
/>
{peerConnection?.connectionState == "connected" && (
<div
style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div className="relative h-full w-full rounded-md">
<LoadingVideoOverlay show={isVideoLoading} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
}}
/>
</div>
</div>
)}
</div> </div>
</div> </div>
<VirtualKeyboard /> <VirtualKeyboard />

View File

@ -7,7 +7,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "@/notifications"; import notifications from "@/notifications";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import { useJsonRpc } from "../../hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "../../hooks/useJsonRpc";
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
@ -23,7 +23,7 @@ export function ATXPowerControl() {
> | null>(null); > | null>(null);
const [atxState, setAtxState] = useState<ATXState | null>(null); const [atxState, setAtxState] = useState<ATXState | null>(null);
const [send] = useJsonRpc(function onRequest(resp) { const { send } = useJsonRpc(function onRequest(resp) {
if (resp.method === "atxState") { if (resp.method === "atxState") {
setAtxState(resp.params as ATXState); setAtxState(resp.params as ATXState);
} }
@ -31,7 +31,7 @@ export function ATXPowerControl() {
// Request initial state // Request initial state
useEffect(() => { useEffect(() => {
send("getATXState", {}, resp => { send("getATXState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`, `Failed to get ATX state: ${resp.error.data || "Unknown error"}`,
@ -54,7 +54,7 @@ export function ATXPowerControl() {
const timer = setTimeout(() => { const timer = setTimeout(() => {
// Send long press action // Send long press action
console.log("Sending long press ATX power action"); console.log("Sending long press ATX power action");
send("setATXPowerAction", { action: "power-long" }, resp => { send("setATXPowerAction", { action: "power-long" }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`, `Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
@ -75,7 +75,7 @@ export function ATXPowerControl() {
// Send short press action // Send short press action
console.log("Sending short press ATX power action"); console.log("Sending short press ATX power action");
send("setATXPowerAction", { action: "power-short" }, resp => { send("setATXPowerAction", { action: "power-short" }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`, `Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
@ -127,7 +127,7 @@ export function ATXPowerControl() {
LeadingIcon={LuRotateCcw} LeadingIcon={LuRotateCcw}
text="Reset" text="Reset"
onClick={() => { onClick={() => {
send("setATXPowerAction", { action: "reset" }, resp => { send("setATXPowerAction", { action: "reset" }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`, `Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,

View File

@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import notifications from "@/notifications";
import FieldLabel from "@components/FieldLabel"; import FieldLabel from "@components/FieldLabel";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
@ -19,11 +19,11 @@ interface DCPowerState {
} }
export function DCPowerControl() { export function DCPowerControl() {
const [send] = useJsonRpc(); const { send } = useJsonRpc();
const [powerState, setPowerState] = useState<DCPowerState | null>(null); const [powerState, setPowerState] = useState<DCPowerState | null>(null);
const getDCPowerState = useCallback(() => { const getDCPowerState = useCallback(() => {
send("getDCPowerState", {}, resp => { send("getDCPowerState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to get DC power state: ${resp.error.data || "Unknown error"}`, `Failed to get DC power state: ${resp.error.data || "Unknown error"}`,
@ -35,7 +35,7 @@ export function DCPowerControl() {
}, [send]); }, [send]);
const handlePowerToggle = (enabled: boolean) => { const handlePowerToggle = (enabled: boolean) => {
send("setDCPowerState", { enabled }, resp => { send("setDCPowerState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`, `Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
@ -47,7 +47,7 @@ export function DCPowerControl() {
}; };
const handleRestoreChange = (state: number) => { const handleRestoreChange = (state: number) => {
// const state = powerState?.restoreState === 0 ? 1 : powerState?.restoreState === 1 ? 2 : 0; // const state = powerState?.restoreState === 0 ? 1 : powerState?.restoreState === 1 ? 2 : 0;
send("setDCRestoreState", { state }, resp => { send("setDCRestoreState", { state }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`, `Failed to set DC power state: ${resp.error.data || "Unknown error"}`,

View File

@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { useUiStore } from "@/hooks/stores"; import { useUiStore } from "@/hooks/stores";
import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SelectMenuBasic } from "@components/SelectMenuBasic";
@ -17,7 +17,7 @@ interface SerialSettings {
} }
export function SerialConsole() { export function SerialConsole() {
const [send] = useJsonRpc(); const { send } = useJsonRpc();
const [settings, setSettings] = useState<SerialSettings>({ const [settings, setSettings] = useState<SerialSettings>({
baudRate: "9600", baudRate: "9600",
dataBits: "8", dataBits: "8",
@ -26,7 +26,7 @@ export function SerialConsole() {
}); });
useEffect(() => { useEffect(() => {
send("getSerialSettings", {}, resp => { send("getSerialSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to get serial settings: ${resp.error.data || "Unknown error"}`, `Failed to get serial settings: ${resp.error.data || "Unknown error"}`,
@ -39,7 +39,7 @@ export function SerialConsole() {
const handleSettingChange = (setting: keyof SerialSettings, value: string) => { const handleSettingChange = (setting: keyof SerialSettings, value: string) => {
const newSettings = { ...settings, [setting]: value }; const newSettings = { ...settings, [setting]: value };
send("setSerialSettings", { settings: newSettings }, resp => { send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to update serial settings: ${resp.error.data || "Unknown error"}`, `Failed to update serial settings: ${resp.error.data || "Unknown error"}`,
@ -49,7 +49,7 @@ export function SerialConsole() {
setSettings(newSettings); setSettings(newSettings);
}); });
}; };
const setTerminalType = useUiStore(state => state.setTerminalType); const { setTerminalType } = useUiStore();
return ( return (
<div className="space-y-4"> <div className="space-y-4">

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu"; import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl"; import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
@ -39,12 +39,12 @@ const AVAILABLE_EXTENSIONS: Extension[] = [
]; ];
export default function ExtensionPopover() { export default function ExtensionPopover() {
const [send] = useJsonRpc(); const { send } = useJsonRpc();
const [activeExtension, setActiveExtension] = useState<Extension | null>(null); const [activeExtension, setActiveExtension] = useState<Extension | null>(null);
// Load active extension on component mount // Load active extension on component mount
useEffect(() => { useEffect(() => {
send("getActiveExtension", {}, resp => { send("getActiveExtension", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
const extensionId = resp.result as string; const extensionId = resp.result as string;
if (extensionId) { if (extensionId) {
@ -57,7 +57,7 @@ export default function ExtensionPopover() {
}, [send]); }, [send]);
const handleSetActiveExtension = (extension: Extension | null) => { const handleSetActiveExtension = (extension: Extension | null) => {
send("setActiveExtension", { extensionId: extension?.id || "" }, resp => { send("setActiveExtension", { extensionId: extension?.id || "" }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set active extension: ${resp.error.data || "Unknown error"}`, `Failed to set active extension: ${resp.error.data || "Unknown error"}`,

View File

@ -1,9 +1,6 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { PlusCircleIcon } from "@heroicons/react/20/solid"; import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react"; import { forwardRef, useEffect, useCallback } from "react";
import { import {
LuArrowUpFromLine,
LuCheckCheck,
LuLink, LuLink,
LuPlus, LuPlus,
LuRadioReceiver, LuRadioReceiver,
@ -14,40 +11,19 @@ import { useLocation } from "react-router-dom";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores"; import { RemoteVirtualMediaState, useMountMediaStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications"; import notifications from "@/notifications";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => { const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats); const { send } = useJsonRpc();
const [send] = useJsonRpc();
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } = const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
useMountMediaStore(); useMountMediaStore();
const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null;
const secondLastItem =
Array.from(diskDataChannelStats)[diskDataChannelStats.size - 2];
const lastItem = Array.from(diskDataChannelStats)[diskDataChannelStats.size - 1];
if (!secondLastItem || !lastItem) return 0;
const lastTime = lastItem[0];
const secondLastTime = secondLastItem[0];
const timeDelta = lastTime - secondLastTime;
const lastBytesSent = lastItem[1].bytesSent;
const secondLastBytesSent = secondLastItem[1].bytesSent;
const bytesDelta = lastBytesSent - secondLastBytesSent;
return bytesDelta / timeDelta;
}, [diskDataChannelStats]);
const syncRemoteVirtualMediaState = useCallback(() => { const syncRemoteVirtualMediaState = useCallback(() => {
send("getVirtualMediaState", {}, response => { send("getVirtualMediaState", {}, (response: JsonRpcResponse) => {
if ("error" in response) { if ("error" in response) {
notifications.error( notifications.error(
`Failed to get virtual media state: ${response.error.message}`, `Failed to get virtual media state: ${response.error.message}`,
@ -59,7 +35,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
}, [send, setRemoteVirtualMediaState]); }, [send, setRemoteVirtualMediaState]);
const handleUnmount = () => { const handleUnmount = () => {
send("unmountImage", {}, response => { send("unmountImage", {}, (response: JsonRpcResponse) => {
if ("error" in response) { if ("error" in response) {
notifications.error(`Failed to unmount image: ${response.error.message}`); notifications.error(`Failed to unmount image: ${response.error.message}`);
} else { } else {
@ -94,42 +70,6 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const { source, filename, size, url, path } = remoteVirtualMediaState; const { source, filename, size, url, path } = remoteVirtualMediaState;
switch (source) { switch (source) {
case "WebRTC":
return (
<>
<div className="space-y-1">
<div className="flex items-center gap-x-2">
<LuCheckCheck className="h-5 text-green-500" />
<h3 className="text-base font-semibold text-black dark:text-white">
Streaming from Browser
</h3>
</div>
<Card className="w-auto px-2 py-1">
<div className="w-full truncate text-sm text-black dark:text-white">
{formatters.truncateMiddle(filename, 50)}
</div>
</Card>
</div>
<div className="my-2 flex flex-col items-center gap-y-2">
<div className="w-full text-sm text-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between">
<span>{formatters.bytes(size ?? 0)}</span>
<div className="flex items-center gap-x-1">
<LuArrowUpFromLine
className="h-4 text-blue-700 dark:text-blue-500"
strokeWidth={2}
/>
<span>
{bytesSentPerSecond !== null
? `${formatters.bytes(bytesSentPerSecond)}/s`
: "N/A"}
</span>
</div>
</div>
</div>
</div>
</>
);
case "HTTP": case "HTTP":
return ( return (
<div className=""> <div className="">
@ -202,18 +142,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
description="Mount an image to boot from or install an operating system." description="Mount an image to boot from or install an operating system."
/> />
{remoteVirtualMediaState?.source === "WebRTC" ? ( <div
<Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
<ExclamationTriangleIcon className="h-4 text-yellow-500" />
<div className="flex w-full items-center text-black">
<div>Closing this tab will unmount the image</div>
</div>
</div>
</Card>
) : null}
<div
className="animate-fadeIn opacity-0 space-y-2" className="animate-fadeIn opacity-0 space-y-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu"; import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid"; import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
@ -7,104 +7,102 @@ import { Button } from "@components/Button";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea"; import { TextAreaWithLabel } from "@components/TextArea";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import { layouts, chars } from "@/keyboardLayouts"; import { KeyStroke } from "@/keyboardLayouts";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import notifications from "@/notifications"; import notifications from "@/notifications";
const hidKeyboardPayload = (keys: number[], modifier: number) => { const hidKeyboardPayload = (modifier: number, keys: number[]) => {
return { keys, modifier }; return { modifier, keys };
}; };
const modifierCode = (shift?: boolean, altRight?: boolean) => { const modifierCode = (shift?: boolean, altRight?: boolean) => {
return (shift ? modifiers["ShiftLeft"] : 0) return (shift ? modifiers.ShiftLeft : 0)
| (altRight ? modifiers["AltRight"] : 0) | (altRight ? modifiers.AltRight : 0)
} }
const noModifier = 0 const noModifier = 0
export default function PasteModal() { export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null); const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const setPasteMode = useHidStore(state => state.setPasteModeEnabled); const { setPasteModeEnabled } = useHidStore();
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); const { setDisableVideoFocusTrap } = useUiStore();
const [send] = useJsonRpc(); const { send } = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const { rpcDataChannel } = useRTCStore();
const [invalidChars, setInvalidChars] = useState<string[]>([]); const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose(); const close = useClose();
const keyboardLayout = useSettingsStore(state => state.keyboardLayout); const { setKeyboardLayout } = useSettingsStore();
const setKeyboardLayout = useSettingsStore( const { selectedKeyboard } = useKeyboardLayout();
state => state.setKeyboardLayout,
);
// this ensures we always get the original en_US if it hasn't been set yet
const safeKeyboardLayout = useMemo(() => {
if (keyboardLayout && keyboardLayout.length > 0)
return keyboardLayout;
return "en_US";
}, [keyboardLayout]);
useEffect(() => { useEffect(() => {
send("getKeyboardLayout", {}, resp => { send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
setKeyboardLayout(resp.result as string); setKeyboardLayout(resp.result as string);
}); });
}, [send, setKeyboardLayout]); }, [send, setKeyboardLayout]);
const onCancelPasteMode = useCallback(() => { const onCancelPasteMode = useCallback(() => {
setPasteMode(false); setPasteModeEnabled(false);
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
setInvalidChars([]); setInvalidChars([]);
}, [setDisableVideoFocusTrap, setPasteMode]); }, [setDisableVideoFocusTrap, setPasteModeEnabled]);
const onConfirmPaste = useCallback(async () => { const onConfirmPaste = useCallback(async () => {
setPasteMode(false); setPasteModeEnabled(false);
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return; if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
if (!safeKeyboardLayout) return; if (!selectedKeyboard) return;
if (!chars[safeKeyboardLayout]) return;
const text = TextAreaRef.current.value; const text = TextAreaRef.current.value;
try { try {
for (const char of text) { for (const char of text) {
const { key, shift, altRight, deadKey, accentKey } = chars[safeKeyboardLayout][char] const keyprops = selectedKeyboard.chars[char];
if (!keyprops) continue;
const { key, shift, altRight, deadKey, accentKey } = keyprops;
if (!key) continue; if (!key) continue;
const keyz = [ keys[key] ]; // if this is an accented character, we need to send that accent FIRST
const modz = [ modifierCode(shift, altRight) ];
if (deadKey) {
keyz.push(keys["Space"]);
modz.push(noModifier);
}
if (accentKey) { if (accentKey) {
keyz.unshift(keys[accentKey.key]) await sendKeystroke({modifier: modifierCode(accentKey.shift, accentKey.altRight), keys: [ keys[accentKey.key] ] })
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
} }
for (const [index, kei] of keyz.entries()) { // now send the actual key
await new Promise<void>((resolve, reject) => { await sendKeystroke({ modifier: modifierCode(shift, altRight), keys: [ keys[key] ]});
send(
"keyboardReport", // if what was requested was a dead key, we need to send an unmodified space to emit
hidKeyboardPayload([kei], modz[index]), // just the accent character
params => { if (deadKey) {
if ("error" in params) return reject(params.error); await sendKeystroke({ modifier: noModifier, keys: [ keys["Space"] ] });
send("keyboardReport", hidKeyboardPayload([], 0), params => {
if ("error" in params) return reject(params.error);
resolve();
});
},
);
});
} }
// now send a message with no keys down to "release" the keys
await sendKeystroke({ modifier: 0, keys: [] });
} }
} catch (error) { } catch (error) {
console.error(error); console.error("Failed to paste text:", error);
notifications.error("Failed to paste text"); notifications.error("Failed to paste text");
} }
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
async function sendKeystroke(stroke: KeyStroke) {
await new Promise<void>((resolve, reject) => {
send(
"keyboardReport",
hidKeyboardPayload(stroke.modifier, stroke.keys),
params => {
if ("error" in params) return reject(params.error);
resolve();
}
);
});
}
}, [selectedKeyboard, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteModeEnabled]);
useEffect(() => { useEffect(() => {
if (TextAreaRef.current) { if (TextAreaRef.current) {
@ -154,7 +152,7 @@ export default function PasteModal() {
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments // @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)] [...new Intl.Segmenter().segment(value)]
.map(x => x.segment) .map(x => x.segment)
.filter(char => !chars[safeKeyboardLayout][char]), .filter(char => !selectedKeyboard.chars[char]),
), ),
]; ];
@ -175,7 +173,7 @@ export default function PasteModal() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-slate-600 dark:text-slate-400"> <p className="text-xs text-slate-600 dark:text-slate-400">
Sending text using keyboard layout: {layouts[safeKeyboardLayout]} Sending text using keyboard layout: {selectedKeyboard.isoCode}-{selectedKeyboard.name}
</p> </p>
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@ import { useClose } from "@headlessui/react";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores"; import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
@ -14,26 +14,24 @@ import AddDeviceForm from "./AddDeviceForm";
export default function WakeOnLanModal() { export default function WakeOnLanModal() {
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]); const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); const { setDisableVideoFocusTrap } = useUiStore();
const { rpcDataChannel } = useRTCStore();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const { send } = useJsonRpc();
const [send] = useJsonRpc();
const close = useClose(); const close = useClose();
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null); const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
const onCancelWakeOnLanModal = useCallback(() => { const onCancelWakeOnLanModal = useCallback(() => {
setDisableVideoFocusTrap(false);
close(); close();
setDisableFocusTrap(false); }, [close, setDisableVideoFocusTrap]);
}, [close, setDisableFocusTrap]);
const onSendMagicPacket = useCallback( const onSendMagicPacket = useCallback(
(macAddress: string) => { (macAddress: string) => {
setErrorMessage(null); setErrorMessage(null);
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
send("sendWOLMagicPacket", { macAddress }, resp => { send("sendWOLMagicPacket", { macAddress }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
const isInvalid = resp.error.data?.includes("invalid MAC address"); const isInvalid = resp.error.data?.includes("invalid MAC address");
if (isInvalid) { if (isInvalid) {
@ -43,16 +41,16 @@ export default function WakeOnLanModal() {
} }
} else { } else {
notifications.success("Magic Packet sent successfully"); notifications.success("Magic Packet sent successfully");
setDisableFocusTrap(false); setDisableVideoFocusTrap(false);
close(); close();
} }
}); });
}, },
[close, rpcDataChannel?.readyState, send, setDisableFocusTrap], [close, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap],
); );
const syncStoredDevices = useCallback(() => { const syncStoredDevices = useCallback(() => {
send("getWakeOnLanDevices", {}, resp => { send("getWakeOnLanDevices", {}, (resp: JsonRpcResponse) => {
if ("result" in resp) { if ("result" in resp) {
setStoredDevices(resp.result as StoredDevice[]); setStoredDevices(resp.result as StoredDevice[]);
} else { } else {
@ -70,7 +68,7 @@ export default function WakeOnLanModal() {
(index: number) => { (index: number) => {
const updatedDevices = storedDevices.filter((_, i) => i !== index); const updatedDevices = storedDevices.filter((_, i) => i !== index);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => { send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
console.error("Failed to update Wake-on-LAN devices:", resp.error); console.error("Failed to update Wake-on-LAN devices:", resp.error);
} else { } else {
@ -78,7 +76,7 @@ export default function WakeOnLanModal() {
} }
}); });
}, },
[storedDevices, send, syncStoredDevices], [send, storedDevices, syncStoredDevices],
); );
const onAddDevice = useCallback( const onAddDevice = useCallback(
@ -86,7 +84,7 @@ export default function WakeOnLanModal() {
if (!name || !macAddress) return; if (!name || !macAddress) return;
const updatedDevices = [...storedDevices, { name, macAddress }]; const updatedDevices = [...storedDevices, { name, macAddress }];
console.log("updatedDevices", updatedDevices); console.log("updatedDevices", updatedDevices);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => { send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
console.error("Failed to add Wake-on-LAN device:", resp.error); console.error("Failed to add Wake-on-LAN device:", resp.error);
setAddDeviceErrorMessage("Failed to add device"); setAddDeviceErrorMessage("Failed to add device");

View File

@ -37,10 +37,18 @@ function createChartArray<T, K extends keyof T>(
} }
export default function ConnectionStatsSidebar() { export default function ConnectionStatsSidebar() {
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats); const { sidebarView, setSidebarView } = useUiStore();
const {
const candidatePairStats = useRTCStore(state => state.candidatePairStats); mediaStream,
const setSidebarView = useUiStore(state => state.setSidebarView); peerConnection,
inboundRtpStats,
appendInboundRtpStats,
candidatePairStats,
appendCandidatePairStats,
appendLocalCandidateStats,
appendRemoteCandidateStats,
appendDiskDataChannelStats,
} = useRTCStore();
function isMetricSupported<T, K extends keyof T>( function isMetricSupported<T, K extends keyof T>(
stream: Map<number, T>, stream: Map<number, T>,
@ -49,20 +57,6 @@ export default function ConnectionStatsSidebar() {
return Array.from(stream).some(([, stat]) => stat[metric] !== undefined); return Array.from(stream).some(([, stat]) => stat[metric] !== undefined);
} }
const appendInboundRtpStats = useRTCStore(state => state.appendInboundRtpStats);
const appendIceCandidatePair = useRTCStore(state => state.appendCandidatePairStats);
const appendDiskDataChannelStats = useRTCStore(
state => state.appendDiskDataChannelStats,
);
const appendLocalCandidateStats = useRTCStore(state => state.appendLocalCandidateStats);
const appendRemoteCandidateStats = useRTCStore(
state => state.appendRemoteCandidateStats,
);
const peerConnection = useRTCStore(state => state.peerConnection);
const mediaStream = useRTCStore(state => state.mediaStream);
const sidebarView = useUiStore(state => state.sidebarView);
useInterval(function collectWebRTCStats() { useInterval(function collectWebRTCStats() {
(async () => { (async () => {
if (!mediaStream) return; if (!mediaStream) return;
@ -80,8 +74,7 @@ export default function ConnectionStatsSidebar() {
successfulLocalCandidateId = report.localCandidateId; successfulLocalCandidateId = report.localCandidateId;
successfulRemoteCandidateId = report.remoteCandidateId; successfulRemoteCandidateId = report.remoteCandidateId;
} }
appendCandidatePairStats(report);
appendIceCandidatePair(report);
} else if (report.type === "local-candidate") { } else if (report.type === "local-candidate") {
// We only want to append the local candidate stats that were used in nominated candidate pair // We only want to append the local candidate stats that were used in nominated candidate pair
if (successfulLocalCandidateId === report.id) { if (successfulLocalCandidateId === report.id) {

View File

@ -47,12 +47,12 @@ export interface User {
picture?: string; picture?: string;
} }
interface UserState { export interface UserState {
user: User | null; user: User | null;
setUser: (user: User | null) => void; setUser: (user: User | null) => void;
} }
interface UIState { export interface UIState {
sidebarView: AvailableSidebarViews | null; sidebarView: AvailableSidebarViews | null;
setSidebarView: (view: AvailableSidebarViews | null) => void; setSidebarView: (view: AvailableSidebarViews | null) => void;
@ -68,21 +68,21 @@ interface UIState {
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void; setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
terminalType: AvailableTerminalTypes; terminalType: AvailableTerminalTypes;
setTerminalType: (enabled: UIState["terminalType"]) => void; setTerminalType: (type: UIState["terminalType"]) => void;
} }
export const useUiStore = create<UIState>(set => ({ export const useUiStore = create<UIState>(set => ({
terminalType: "none", terminalType: "none",
setTerminalType: type => set({ terminalType: type }), setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }),
sidebarView: null, sidebarView: null,
setSidebarView: view => set({ sidebarView: view }), setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }),
disableVideoFocusTrap: false, disableVideoFocusTrap: false,
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }), setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }),
isWakeOnLanModalVisible: false, isWakeOnLanModalVisible: false,
setWakeOnLanModalVisibility: enabled => set({ isWakeOnLanModalVisible: enabled }), setWakeOnLanModalVisibility: (enabled: boolean) => set({ isWakeOnLanModalVisible: enabled }),
toggleSidebarView: view => toggleSidebarView: view =>
set(state => { set(state => {
@ -94,20 +94,17 @@ export const useUiStore = create<UIState>(set => ({
}), }),
isAttachedVirtualKeyboardVisible: true, isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: enabled => setAttachedVirtualKeyboardVisibility: (enabled: boolean) =>
set({ isAttachedVirtualKeyboardVisible: enabled }), set({ isAttachedVirtualKeyboardVisible: enabled }),
})); }));
interface RTCState { export interface RTCState {
peerConnection: RTCPeerConnection | null; peerConnection: RTCPeerConnection | null;
setPeerConnection: (pc: RTCState["peerConnection"]) => void; setPeerConnection: (pc: RTCState["peerConnection"]) => void;
setRpcDataChannel: (channel: RTCDataChannel) => void; setRpcDataChannel: (channel: RTCDataChannel) => void;
rpcDataChannel: RTCDataChannel | null; rpcDataChannel: RTCDataChannel | null;
diskChannel: RTCDataChannel | null;
setDiskChannel: (channel: RTCDataChannel) => void;
peerConnectionState: RTCPeerConnectionState | null; peerConnectionState: RTCPeerConnectionState | null;
setPeerConnectionState: (state: RTCPeerConnectionState) => void; setPeerConnectionState: (state: RTCPeerConnectionState) => void;
@ -118,18 +115,18 @@ interface RTCState {
setMediaStream: (stream: MediaStream) => void; setMediaStream: (stream: MediaStream) => void;
videoStreamStats: RTCInboundRtpStreamStats | null; videoStreamStats: RTCInboundRtpStreamStats | null;
appendVideoStreamStats: (state: RTCInboundRtpStreamStats) => void; appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => void;
videoStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>; videoStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>;
isTurnServerInUse: boolean; isTurnServerInUse: boolean;
setTurnServerInUse: (inUse: boolean) => void; setTurnServerInUse: (inUse: boolean) => void;
inboundRtpStats: Map<number, RTCInboundRtpStreamStats>; inboundRtpStats: Map<number, RTCInboundRtpStreamStats>;
appendInboundRtpStats: (state: RTCInboundRtpStreamStats) => void; appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => void;
clearInboundRtpStats: () => void; clearInboundRtpStats: () => void;
candidatePairStats: Map<number, RTCIceCandidatePairStats>; candidatePairStats: Map<number, RTCIceCandidatePairStats>;
appendCandidatePairStats: (pair: RTCIceCandidatePairStats) => void; appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => void;
clearCandidatePairStats: () => void; clearCandidatePairStats: () => void;
// Remote ICE candidates stat type doesn't exist as of today // Remote ICE candidates stat type doesn't exist as of today
@ -141,7 +138,7 @@ interface RTCState {
// Disk data channel stats type doesn't exist as of today // Disk data channel stats type doesn't exist as of today
diskDataChannelStats: Map<number, RTCDataChannelStats>; diskDataChannelStats: Map<number, RTCDataChannelStats>;
appendDiskDataChannelStats: (stat: RTCDataChannelStats) => void; appendDiskDataChannelStats: (stats: RTCDataChannelStats) => void;
terminalChannel: RTCDataChannel | null; terminalChannel: RTCDataChannel | null;
setTerminalChannel: (channel: RTCDataChannel) => void; setTerminalChannel: (channel: RTCDataChannel) => void;
@ -152,81 +149,78 @@ interface RTCState {
export const useRTCStore = create<RTCState>(set => ({ export const useRTCStore = create<RTCState>(set => ({
peerConnection: null, peerConnection: null,
setPeerConnection: pc => set({ peerConnection: pc }), setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }),
rpcDataChannel: null, rpcDataChannel: null,
setRpcDataChannel: channel => set({ rpcDataChannel: channel }), setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }),
hidDataChannel: null, hidDataChannel: null,
setHidDataChannel: channel => set({ hidDataChannel: channel }), setHidDataChannel: channel => set({ hidDataChannel: channel }),
transceiver: null, transceiver: null,
setTransceiver: transceiver => set({ transceiver }), setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }),
peerConnectionState: null, peerConnectionState: null,
setPeerConnectionState: state => set({ peerConnectionState: state }), setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }),
diskChannel: null,
setDiskChannel: channel => set({ diskChannel: channel }),
mediaStream: null, mediaStream: null,
setMediaStream: stream => set({ mediaStream: stream }), setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }),
videoStreamStats: null, videoStreamStats: null,
appendVideoStreamStats: stats => set({ videoStreamStats: stats }), appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }),
videoStreamStatsHistory: new Map(), videoStreamStatsHistory: new Map(),
isTurnServerInUse: false, isTurnServerInUse: false,
setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }), setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }),
inboundRtpStats: new Map(), inboundRtpStats: new Map(),
appendInboundRtpStats: newStat => { appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => {
set(prevState => ({ set(prevState => ({
inboundRtpStats: appendStatToMap(newStat, prevState.inboundRtpStats), inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats),
})); }));
}, },
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }), clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
candidatePairStats: new Map(), candidatePairStats: new Map(),
appendCandidatePairStats: newStat => { appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => {
set(prevState => ({ set(prevState => ({
candidatePairStats: appendStatToMap(newStat, prevState.candidatePairStats), candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats),
})); }));
}, },
clearCandidatePairStats: () => set({ candidatePairStats: new Map() }), clearCandidatePairStats: () => set({ candidatePairStats: new Map() }),
localCandidateStats: new Map(), localCandidateStats: new Map(),
appendLocalCandidateStats: newStat => { appendLocalCandidateStats: (stats: RTCIceCandidateStats) => {
set(prevState => ({ set(prevState => ({
localCandidateStats: appendStatToMap(newStat, prevState.localCandidateStats), localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats),
})); }));
}, },
remoteCandidateStats: new Map(), remoteCandidateStats: new Map(),
appendRemoteCandidateStats: newStat => { appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => {
set(prevState => ({ set(prevState => ({
remoteCandidateStats: appendStatToMap(newStat, prevState.remoteCandidateStats), remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats),
})); }));
}, },
diskDataChannelStats: new Map(), diskDataChannelStats: new Map(),
appendDiskDataChannelStats: newStat => { appendDiskDataChannelStats: (stats: RTCDataChannelStats) => {
set(prevState => ({ set(prevState => ({
diskDataChannelStats: appendStatToMap(newStat, prevState.diskDataChannelStats), diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats),
})); }));
}, },
// Add these new properties to the store implementation // Add these new properties to the store implementation
terminalChannel: null, terminalChannel: null,
setTerminalChannel: channel => set({ terminalChannel: channel }), setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }),
})); }));
interface MouseMove { export interface MouseMove {
x: number; x: number;
y: number; y: number;
buttons: number; buttons: number;
} }
interface MouseState { export interface MouseState {
mouseX: number; mouseX: number;
mouseY: number; mouseY: number;
mouseMove?: MouseMove; mouseMove?: MouseMove;
@ -238,9 +232,17 @@ export const useMouseStore = create<MouseState>(set => ({
mouseX: 0, mouseX: 0,
mouseY: 0, mouseY: 0,
setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), setMouseMove: (move?: MouseMove) => set({ mouseMove: move }),
setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }), setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }),
})); }));
export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting";
export type HdmiErrorStates = Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">
export interface HdmiState {
ready: boolean;
error?: HdmiErrorStates;
}
export interface VideoState { export interface VideoState {
width: number; width: number;
height: number; height: number;
@ -248,19 +250,13 @@ export interface VideoState {
clientHeight: number; clientHeight: number;
setClientSize: (width: number, height: number) => void; setClientSize: (width: number, height: number) => void;
setSize: (width: number, height: number) => void; setSize: (width: number, height: number) => void;
hdmiState: "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; hdmiState: HdmiStates;
setHdmiState: (state: { setHdmiState: (state: {
ready: boolean; ready: boolean;
error?: Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">; error?: HdmiErrorStates;
}) => void; }) => void;
} }
export interface BacklightSettings {
max_brightness: number;
dim_after: number;
off_after: number;
}
export const useVideoStore = create<VideoState>(set => ({ export const useVideoStore = create<VideoState>(set => ({
width: 0, width: 0,
height: 0, height: 0,
@ -269,13 +265,13 @@ export const useVideoStore = create<VideoState>(set => ({
clientHeight: 0, clientHeight: 0,
// The video element's client size // The video element's client size
setClientSize: (clientWidth, clientHeight) => set({ clientWidth, clientHeight }), setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }),
// Resolution // Resolution
setSize: (width, height) => set({ width, height }), setSize: (width: number, height: number) => set({ width, height }),
hdmiState: "connecting", hdmiState: "connecting",
setHdmiState: state => { setHdmiState: (state: HdmiState) => {
if (!state) return; if (!state) return;
const { ready, error } = state; const { ready, error } = state;
@ -289,9 +285,13 @@ export const useVideoStore = create<VideoState>(set => ({
}, },
})); }));
export type KeyboardLedSync = "auto" | "browser" | "host"; export interface BacklightSettings {
max_brightness: number;
dim_after: number;
off_after: number;
}
interface SettingsState { export interface SettingsState {
isCursorHidden: boolean; isCursorHidden: boolean;
setCursorVisibility: (enabled: boolean) => void; setCursorVisibility: (enabled: boolean) => void;
@ -314,9 +314,6 @@ interface SettingsState {
keyboardLayout: string; keyboardLayout: string;
setKeyboardLayout: (layout: string) => void; setKeyboardLayout: (layout: string) => void;
keyboardLedSync: KeyboardLedSync;
setKeyboardLedSync: (sync: KeyboardLedSync) => void;
scrollThrottling: number; scrollThrottling: number;
setScrollThrottling: (value: number) => void; setScrollThrottling: (value: number) => void;
@ -336,17 +333,17 @@ export const useSettingsStore = create(
persist<SettingsState>( persist<SettingsState>(
set => ({ set => ({
isCursorHidden: false, isCursorHidden: false,
setCursorVisibility: enabled => set({ isCursorHidden: enabled }), setCursorVisibility: (enabled: boolean) => set({ isCursorHidden: enabled }),
mouseMode: "absolute", mouseMode: "absolute",
setMouseMode: mode => set({ mouseMode: mode }), setMouseMode: (mode: string) => set({ mouseMode: mode }),
debugMode: import.meta.env.DEV, debugMode: import.meta.env.DEV,
setDebugMode: enabled => set({ debugMode: enabled }), setDebugMode: (enabled: boolean) => set({ debugMode: enabled }),
// Add developer mode with default value // Add developer mode with default value
developerMode: false, developerMode: false,
setDeveloperMode: enabled => set({ developerMode: enabled }), setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }),
displayRotation: "270", displayRotation: "270",
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }), setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
@ -360,24 +357,21 @@ export const useSettingsStore = create(
set({ backlightSettings: settings }), set({ backlightSettings: settings }),
keyboardLayout: "en-US", keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }), setKeyboardLayout: (layout: string) => set({ keyboardLayout: layout }),
keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
scrollThrottling: 0, scrollThrottling: 0,
setScrollThrottling: value => set({ scrollThrottling: value }), setScrollThrottling: (value: number) => set({ scrollThrottling: value }),
showPressedKeys: true, showPressedKeys: true,
setShowPressedKeys: show => set({ showPressedKeys: show }), setShowPressedKeys: (show: boolean) => set({ showPressedKeys: show }),
// Video enhancement settings with default values (1.0 = normal) // Video enhancement settings with default values (1.0 = normal)
videoSaturation: 1.0, videoSaturation: 1.0,
setVideoSaturation: value => set({ videoSaturation: value }), setVideoSaturation: (value: number) => set({ videoSaturation: value }),
videoBrightness: 1.0, videoBrightness: 1.0,
setVideoBrightness: value => set({ videoBrightness: value }), setVideoBrightness: (value: number) => set({ videoBrightness: value }),
videoContrast: 1.0, videoContrast: 1.0,
setVideoContrast: value => set({ videoContrast: value }), setVideoContrast: (value: number) => set({ videoContrast: value }),
}), }),
{ {
name: "settings", name: "settings",
@ -387,7 +381,7 @@ export const useSettingsStore = create(
); );
export interface RemoteVirtualMediaState { export interface RemoteVirtualMediaState {
source: "WebRTC" | "HTTP" | "Storage" | null; source: "HTTP" | "Storage" | null;
mode: "CDROM" | "Disk" | null; mode: "CDROM" | "Disk" | null;
filename: string | null; filename: string | null;
url: string | null; url: string | null;
@ -396,13 +390,10 @@ export interface RemoteVirtualMediaState {
} }
export interface MountMediaState { export interface MountMediaState {
localFile: File | null;
setLocalFile: (file: MountMediaState["localFile"]) => void;
remoteVirtualMediaState: RemoteVirtualMediaState | null; remoteVirtualMediaState: RemoteVirtualMediaState | null;
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void; setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
modalView: "mode" | "browser" | "url" | "device" | "upload" | "error" | null; modalView: "mode" | "url" | "device" | "upload" | "error" | null;
setModalView: (view: MountMediaState["modalView"]) => void; setModalView: (view: MountMediaState["modalView"]) => void;
isMountMediaDialogOpen: boolean; isMountMediaDialogOpen: boolean;
@ -416,24 +407,21 @@ export interface MountMediaState {
} }
export const useMountMediaStore = create<MountMediaState>(set => ({ export const useMountMediaStore = create<MountMediaState>(set => ({
localFile: null,
setLocalFile: file => set({ localFile: file }),
remoteVirtualMediaState: null, remoteVirtualMediaState: null,
setRemoteVirtualMediaState: state => set({ remoteVirtualMediaState: state }), setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }),
modalView: "mode", modalView: "mode",
setModalView: view => set({ modalView: view }), setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }),
isMountMediaDialogOpen: false, isMountMediaDialogOpen: false,
setIsMountMediaDialogOpen: isOpen => set({ isMountMediaDialogOpen: isOpen }), setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }),
uploadedFiles: [], uploadedFiles: [],
addUploadedFile: file => addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) =>
set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })), set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })),
errorMessage: null, errorMessage: null,
setErrorMessage: message => set({ errorMessage: message }), setErrorMessage: (message: string | null) => set({ errorMessage: message }),
})); }));
export interface KeyboardLedState { export interface KeyboardLedState {
@ -442,41 +430,33 @@ export interface KeyboardLedState {
scroll_lock: boolean; scroll_lock: boolean;
compose: boolean; compose: boolean;
kana: boolean; kana: boolean;
shift: boolean; // Optional, as not all keyboards have a shift LED
}; };
const defaultKeyboardLedState: KeyboardLedState = {
num_lock: false, export const hidKeyBufferSize = 6;
caps_lock: false, export const hidErrorRollOver = 0x01;
scroll_lock: false,
compose: false, export interface KeysDownState {
kana: false, modifier: number;
}; keys: number[];
}
export type USBStates =
| "configured"
| "attached"
| "not attached"
| "suspended"
| "addressed";
export interface HidState { export interface HidState {
activeKeys: number[]; keyboardLedState: KeyboardLedState;
activeModifiers: number[];
updateActiveKeysAndModifiers: (keysAndModifiers: {
keys: number[];
modifiers: number[];
}) => void;
altGrArmed: boolean;
setAltGrArmed: (armed: boolean) => void;
altGrTimer: number | null; // _altGrCtrlTime
setAltGrTimer: (timeout: number | null) => void;
altGrCtrlTime: number; // _altGrCtrlTime
setAltGrCtrlTime: (time: number) => void;
keyboardLedState?: KeyboardLedState;
setKeyboardLedState: (state: KeyboardLedState) => void; setKeyboardLedState: (state: KeyboardLedState) => void;
setIsNumLockActive: (active: boolean) => void;
setIsCapsLockActive: (active: boolean) => void;
setIsScrollLockActive: (active: boolean) => void;
keyboardLedStateSyncAvailable: boolean; keysDownState: KeysDownState;
setKeyboardLedStateSyncAvailable: (available: boolean) => void; setKeysDownState: (state: KeysDownState) => void;
keyPressReportApiAvailable: boolean;
setkeyPressReportApiAvailable: (available: boolean) => void;
isVirtualKeyboardEnabled: boolean; isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void; setVirtualKeyboardEnabled: (enabled: boolean) => void;
@ -484,55 +464,29 @@ export interface HidState {
isPasteModeEnabled: boolean; isPasteModeEnabled: boolean;
setPasteModeEnabled: (enabled: boolean) => void; setPasteModeEnabled: (enabled: boolean) => void;
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed"; usbState: USBStates;
setUsbState: (state: HidState["usbState"]) => void; setUsbState: (state: USBStates) => void;
} }
export const useHidStore = create<HidState>((set, get) => ({ export const useHidStore = create<HidState>(set => ({
activeKeys: [], keyboardLedState: {} as KeyboardLedState,
activeModifiers: [], setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }),
updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
return set({ activeKeys: keys, activeModifiers: modifiers });
},
altGrArmed: false, keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState,
setAltGrArmed: armed => set({ altGrArmed: armed }), setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }),
altGrTimer: 0, keyPressReportApiAvailable: true,
setAltGrTimer: timeout => set({ altGrTimer: timeout }), setkeyPressReportApiAvailable: (available: boolean) => set({ keyPressReportApiAvailable: available }),
altGrCtrlTime: 0,
setAltGrCtrlTime: time => set({ altGrCtrlTime: time }),
setKeyboardLedState: ledState => set({ keyboardLedState: ledState }),
setIsNumLockActive: active => {
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
keyboardLedState.num_lock = active;
set({ keyboardLedState });
},
setIsCapsLockActive: active => {
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
keyboardLedState.caps_lock = active;
set({ keyboardLedState });
},
setIsScrollLockActive: active => {
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
keyboardLedState.scroll_lock = active;
set({ keyboardLedState });
},
keyboardLedStateSyncAvailable: false,
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
isVirtualKeyboardEnabled: false, isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }), setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }),
isPasteModeEnabled: false, isPasteModeEnabled: false,
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }), setPasteModeEnabled: (enabled: boolean): void => set({ isPasteModeEnabled: enabled }),
// Add these new properties for USB state // Add these new properties for USB state
usbState: "not attached", usbState: "not attached",
setUsbState: state => set({ usbState: state }), setUsbState: (state: USBStates) => set({ usbState: state }),
})); }));
export const useUserStore = create<UserState>(set => ({ export const useUserStore = create<UserState>(set => ({
@ -540,11 +494,15 @@ export const useUserStore = create<UserState>(set => ({
setUser: user => set({ user }), setUser: user => set({ user }),
})); }));
export interface UpdateState { export type UpdateModalViews =
isUpdatePending: boolean; | "loading"
setIsUpdatePending: (isPending: boolean) => void; | "updating"
updateDialogHasBeenMinimized: boolean; | "upToDate"
otaState: { | "updateAvailable"
| "updateCompleted"
| "error";
export interface OtaState {
updating: boolean; updating: boolean;
error: string | null; error: string | null;
@ -573,24 +531,24 @@ export interface UpdateState {
systemUpdateProgress: number; systemUpdateProgress: number;
systemUpdatedAt: string | null; systemUpdatedAt: string | null;
}; };
setOtaState: (state: UpdateState["otaState"]) => void;
export interface UpdateState {
isUpdatePending: boolean;
setIsUpdatePending: (isPending: boolean) => void;
updateDialogHasBeenMinimized: boolean;
otaState: OtaState;
setOtaState: (state: OtaState) => void;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
modalView: modalView: UpdateModalViews
| "loading" setModalView: (view: UpdateModalViews) => void;
| "updating"
| "upToDate"
| "updateAvailable"
| "updateCompleted"
| "error";
setModalView: (view: UpdateState["modalView"]) => void;
setUpdateErrorMessage: (errorMessage: string) => void; setUpdateErrorMessage: (errorMessage: string) => void;
updateErrorMessage: string | null; updateErrorMessage: string | null;
} }
export const useUpdateStore = create<UpdateState>(set => ({ export const useUpdateStore = create<UpdateState>(set => ({
isUpdatePending: false, isUpdatePending: false,
setIsUpdatePending: isPending => set({ isUpdatePending: isPending }), setIsUpdatePending: (isPending: boolean) => set({ isUpdatePending: isPending }),
setOtaState: state => set({ otaState: state }), setOtaState: state => set({ otaState: state }),
otaState: { otaState: {
@ -614,18 +572,22 @@ export const useUpdateStore = create<UpdateState>(set => ({
}, },
updateDialogHasBeenMinimized: false, updateDialogHasBeenMinimized: false,
setUpdateDialogHasBeenMinimized: hasBeenMinimized => setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) =>
set({ updateDialogHasBeenMinimized: hasBeenMinimized }), set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
modalView: "loading", modalView: "loading",
setModalView: view => set({ modalView: view }), setModalView: (view: UpdateModalViews) => set({ modalView: view }),
updateErrorMessage: null, updateErrorMessage: null,
setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }), setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }),
})); }));
interface UsbConfigModalState { export type UsbConfigModalViews =
modalView: "updateUsbConfig" | "updateUsbConfigSuccess"; | "updateUsbConfig"
| "updateUsbConfigSuccess";
export interface UsbConfigModalState {
modalView: UsbConfigModalViews ;
errorMessage: string | null; errorMessage: string | null;
setModalView: (view: UsbConfigModalState["modalView"]) => void; setModalView: (view: UsbConfigModalViews) => void;
setErrorMessage: (message: string | null) => void; setErrorMessage: (message: string | null) => void;
} }
@ -640,24 +602,26 @@ export interface UsbConfigState {
export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({ export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
modalView: "updateUsbConfig", modalView: "updateUsbConfig",
errorMessage: null, errorMessage: null,
setModalView: view => set({ modalView: view }), setModalView: (view: UsbConfigModalViews) => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }), setErrorMessage: (message: string | null) => set({ errorMessage: message }),
})); }));
interface LocalAuthModalState { export type LocalAuthModalViews =
modalView: | "createPassword"
| "createPassword" | "deletePassword"
| "deletePassword" | "updatePassword"
| "updatePassword" | "creationSuccess"
| "creationSuccess" | "deleteSuccess"
| "deleteSuccess" | "updateSuccess";
| "updateSuccess";
setModalView: (view: LocalAuthModalState["modalView"]) => void; export interface LocalAuthModalState {
modalView:LocalAuthModalViews;
setModalView: (view:LocalAuthModalViews) => void;
} }
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
modalView: "createPassword", modalView: "createPassword",
setModalView: view => set({ modalView: view }), setModalView: (view: LocalAuthModalViews) => set({ modalView: view }),
})); }));
export interface DeviceState { export interface DeviceState {
@ -672,8 +636,8 @@ export const useDeviceStore = create<DeviceState>(set => ({
appVersion: null, appVersion: null,
systemVersion: null, systemVersion: null,
setAppVersion: version => set({ appVersion: version }), setAppVersion: (version: string) => set({ appVersion: version }),
setSystemVersion: version => set({ systemVersion: version }), setSystemVersion: (version: string) => set({ systemVersion: version }),
})); }));
export interface DhcpLease { export interface DhcpLease {
@ -753,6 +717,7 @@ export type TimeSyncMode =
export interface NetworkSettings { export interface NetworkSettings {
hostname: string; hostname: string;
domain: string; domain: string;
http_proxy: string;
ipv4_mode: IPv4Mode; ipv4_mode: IPv4Mode;
ipv6_mode: IPv6Mode; ipv6_mode: IPv6Mode;
lldp_mode: LLDPMode; lldp_mode: LLDPMode;
@ -838,7 +803,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sendFn("getKeyboardMacros", {}, response => { sendFn("getKeyboardMacros", {}, (response: JsonRpcResponse) => {
if (response.error) { if (response.error) {
console.error("Error loading macros:", response.error); console.error("Error loading macros:", response.error);
reject(new Error(response.error.message)); reject(new Error(response.error.message));
@ -918,7 +883,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
sendFn( sendFn(
"setKeyboardMacros", "setKeyboardMacros",
{ params: { macros: macrosWithSortOrder } }, { params: { macros: macrosWithSortOrder } },
response => { (response: JsonRpcResponse) => {
resolve(response); resolve(response);
}, },
); );
@ -941,5 +906,5 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
} finally { } finally {
set({ loading: false }); set({ loading: false });
} }
}, }
})); }));

View File

@ -33,10 +33,10 @@ const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>(
let requestCounter = 0; let requestCounter = 0;
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const { rpcDataChannel } = useRTCStore();
const send = useCallback( const send = useCallback(
(method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
requestCounter++; requestCounter++;
const payload = { jsonrpc: "2.0", method, params, id: requestCounter }; const payload = { jsonrpc: "2.0", method, params, id: requestCounter };
@ -45,7 +45,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
rpcDataChannel.send(JSON.stringify(payload)); rpcDataChannel.send(JSON.stringify(payload));
}, },
[rpcDataChannel], [rpcDataChannel]
); );
useEffect(() => { useEffect(() => {
@ -61,7 +61,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
return; return;
} }
if ("error" in payload) console.error(payload.error); if ("error" in payload) console.error("RPC error", payload);
if (!payload.id) return; if (!payload.id) return;
const callback = callbackStore.get(payload.id); const callback = callbackStore.get(payload.id);
@ -76,7 +76,8 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
return () => { return () => {
rpcDataChannel.removeEventListener("message", messageHandler); rpcDataChannel.removeEventListener("message", messageHandler);
}; };
}, [rpcDataChannel, onRequest]); },
[rpcDataChannel, onRequest]);
return [send]; return { send };
} }

View File

@ -1,10 +1,30 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useHidStore, useRTCStore } from "@/hooks/stores"; import { KeysDownState, useHidStore, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { keys, modifiers } from "@/keyboardMappings"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
export default function useKeyboard() { export default function useKeyboard() {
const { send } = useJsonRpc();
const { rpcDataChannel } = useRTCStore();
const { keysDownState, setKeysDownState } = useHidStore();
// INTRODUCTION: The earlier version of the JetKVM device shipped with all keyboard state
// being tracked on the browser/client-side. When adding the keyPressReport API to the
// device-side code, we have to still support the situation where the browser/client-side code
// is running on the cloud against a device that has not been updated yet and thus does not
// support the keyPressReport API. In that case, we need to handle the key presses locally
// and send the full state to the device, so it can behave like a real USB HID keyboard.
// This flag indicates whether the keyPressReport API is available on the device which is
// dynamically set when the device responds to the first key press event or reports its
// keysDownState when queried since the keyPressReport was introduced together with the
// getKeysDownState API.
const { keyPressReportApiAvailable, setkeyPressReportApiAvailable} = useHidStore();
// sendKeyboardEvent is used to send the full keyboard state to the device for macro handling
// and resetting keyboard state. It sends the keys currently pressed and the modifier state.
// The device will respond with the keysDownState if it supports the keyPressReport API
// or just accept the state if it does not support (returning no result)
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
@ -15,6 +35,62 @@ export default function useKeyboard() {
); );
const sendKeyboardEvent = useCallback( const sendKeyboardEvent = useCallback(
async (state: KeysDownState) => {
if (rpcDataChannel?.readyState !== "open") return;
console.debug(`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`);
send("keyboardReport", { keys: state.keys, modifier: state.modifier }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error(`Failed to send keyboard report ${state}`, resp.error);
} else {
// If the device supports keyPressReport API, it will (also) return the keysDownState when we send
// the keyboardReport
const keysDownState = resp.result as KeysDownState;
if (keysDownState) {
setKeysDownState(keysDownState); // treat the response as the canonical state
setkeyPressReportApiAvailable(true); // if they returned a keysDownState, we ALSO know they also support keyPressReport
} else {
// older devices versions do not return the keyDownState
// so we just pretend they accepted what we sent
setKeysDownState(state);
setkeyPressReportApiAvailable(false); // we ALSO know they do not support keyPressReport
}
}
});
},
[rpcDataChannel?.readyState, send, setKeysDownState, setkeyPressReportApiAvailable],
);
// sendKeypressEvent is used to send a single key press/release event to the device.
// It sends the key and whether it is pressed or released.
// Older device version will not understand this request and will respond with
// an error with code -32601, which means that the RPC method name was not recognized.
// In that case we will switch to local key handling and update the keysDownState
// in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices.
const sendKeypressEvent = useCallback(
async (key: number, press: boolean) => {
if (rpcDataChannel?.readyState !== "open") return;
console.debug(`Send keypressEvent key: ${key}, press: ${press}`);
send("keypressReport", { key, press }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
// -32601 means the method is not supported because the device is running an older version
if (resp.error.code === -32601) {
console.error("Legacy device does not support keypressReport API, switching to local key down state handling", resp.error);
setkeyPressReportApiAvailable(false);
} else {
console.error(`Failed to send key ${key} press: ${press}`, resp.error);
}
} else {
const keysDownState = resp.result as KeysDownState;
if (keysDownState) {
setKeysDownState(keysDownState);
// we don't need to set keyPressReportApiAvailable here, because it's already true or we never landed here
}
}
});
(keys: number[], modifiers: number[]) => { (keys: number[], modifiers: number[]) => {
const rpcChannelReady = rpcDataChannel?.readyState === "open"; const rpcChannelReady = rpcDataChannel?.readyState === "open";
const hidChannelReady = hidDataChannel?.readyState === "open"; const hidChannelReady = hidDataChannel?.readyState === "open";
@ -39,6 +115,20 @@ export default function useKeyboard() {
// We do this for the info bar to display the currently pressed keys for the user // We do this for the info bar to display the currently pressed keys for the user
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers }); updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers });
}, },
[rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState],
);
// resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers.
// This is useful for macros and when the browser loses focus to ensure that the keyboard state
// is clean.
const resetKeyboardState = useCallback(
async () => {
// Reset the keys buffer to zeros and the modifier state to zero
keysDownState.keys.length = hidKeyBufferSize;
keysDownState.keys.fill(0);
keysDownState.modifier = 0;
sendKeyboardEvent(keysDownState);
}, [keysDownState, sendKeyboardEvent]);
[ [
hidDataChannel?.readyState, hidDataChannel?.readyState,
rpcDataChannel?.readyState, rpcDataChannel?.readyState,
@ -51,14 +141,20 @@ export default function useKeyboard() {
sendKeyboardEvent([], []); sendKeyboardEvent([], []);
}, [sendKeyboardEvent]); }, [sendKeyboardEvent]);
// executeMacro is used to execute a macro consisting of multiple steps.
// Each step can have multiple keys, multiple modifiers and a delay.
// The keys and modifiers are pressed together and held for the delay duration.
// After the delay, the keys and modifiers are released and the next step is executed.
// If a step has no keys or modifiers, it is treated as a delay-only step.
// A small pause is added between steps to ensure that the device can process the events.
const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => { const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => {
for (const [index, step] of steps.entries()) { for (const [index, step] of steps.entries()) {
const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || []; const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean);
const modifierValues = step.modifiers?.map(mod => modifiers[mod]).filter(Boolean) || []; const modifierMask: number = (step.modifiers || []).map(mod => modifiers[mod]).reduce((acc, val) => acc + val, 0);
// If the step has keys and/or modifiers, press them and hold for the delay // If the step has keys and/or modifiers, press them and hold for the delay
if (keyValues.length > 0 || modifierValues.length > 0) { if (keyValues.length > 0 || modifierMask > 0) {
sendKeyboardEvent(keyValues, modifierValues); sendKeyboardEvent({ keys: keyValues, modifier: modifierMask });
await new Promise(resolve => setTimeout(resolve, step.delay || 50)); await new Promise(resolve => setTimeout(resolve, step.delay || 50));
resetKeyboardState(); resetKeyboardState();
@ -74,5 +170,92 @@ export default function useKeyboard() {
} }
}; };
return { sendKeyboardEvent, resetKeyboardState, executeMacro }; // handleKeyPress is used to handle a key press or release event.
// This function handle both key press and key release events.
// It checks if the keyPressReport API is available and sends the key press event.
// If the keyPressReport API is not available, it simulates the device-side key
// handling for legacy devices and updates the keysDownState accordingly.
// It then sends the full keyboard state to the device.
const handleKeyPress = useCallback(
async (key: number, press: boolean) => {
if (rpcDataChannel?.readyState !== "open") return;
if ((key || 0) === 0) return; // ignore zero key presses (they are bad mappings)
if (keyPressReportApiAvailable) {
// if the keyPress api is available, we can just send the key press event
sendKeypressEvent(key, press);
} else {
// if the keyPress api is not available, we need to handle the key locally
const downState = simulateDeviceSideKeyHandlingForLegacyDevices(keysDownState, key, press);
sendKeyboardEvent(downState); // then we send the full state
// if we just sent ErrorRollOver, reset to empty state
if (downState.keys[0] === hidErrorRollOver) {
resetKeyboardState();
}
}
},
[keyPressReportApiAvailable, keysDownState, resetKeyboardState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent],
);
// IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists
function simulateDeviceSideKeyHandlingForLegacyDevices(state: KeysDownState, key: number, press: boolean): KeysDownState {
// IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver
// for handling key presses and releases. It ensures that the USB gadget
// behaves similarly to a real USB HID keyboard. This logic is paralleled
// in the device-side code in hid_keyboard.go so make sure to keep them in sync.
let modifiers = state.modifier;
const keys = state.keys;
const modifierMask = hidKeyToModifierMask[key] || 0;
if (modifierMask !== 0) {
// If the key is a modifier key, we update the keyboardModifier state
// by setting or clearing the corresponding bit in the modifier byte.
// This allows us to track the state of dynamic modifier keys like
// Shift, Control, Alt, and Super.
if (press) {
modifiers |= modifierMask;
} else {
modifiers &= ~modifierMask;
}
} else {
// handle other keys that are not modifier keys by placing or removing them
// from the key buffer since the buffer tracks currently pressed keys
let overrun = true;
for (let i = 0; i < hidKeyBufferSize; i++) {
// If we find the key in the buffer the buffer, we either remove it (if press is false)
// or do nothing (if down is true) because the buffer tracks currently pressed keys
// and if we find a zero byte, we can place the key there (if press is true)
if (keys[i] === key || keys[i] === 0) {
if (press) {
keys[i] = key // overwrites the zero byte or the same key if already pressed
} else {
// we are releasing the key, remove it from the buffer
if (keys[i] !== 0) {
keys.splice(i, 1);
keys.push(0); // add a zero at the end
}
}
overrun = false; // We found a slot for the key
break;
}
}
// If we reach here it means we didn't find an empty slot or the key in the buffer
if (overrun) {
if (press) {
console.warn(`keyboard buffer overflow current keys ${keys}, key: ${key} not added`);
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
keys.length = hidKeyBufferSize;
keys.fill(hidErrorRollOver);
} else {
// If we are releasing a key, and we didn't find it in a slot, who cares?
console.debug(`key ${key} not found in buffer, nothing to release`)
}
}
}
return { modifier: modifiers, keys };
}
return { handleKeyPress, resetKeyboardState, executeMacro };
} }

View File

@ -0,0 +1,35 @@
import { useMemo } from "react";
import { useSettingsStore } from "@/hooks/stores";
import { keyboards } from "@/keyboardLayouts";
export default function useKeyboardLayout() {
const { keyboardLayout } = useSettingsStore();
const keyboardOptions = useMemo(() => {
return keyboards.map((keyboard) => {
return { label: keyboard.name, value: keyboard.isoCode }
});
}, []);
const isoCode = useMemo(() => {
// If we don't have a specific layout, default to "en-US" because that was the original layout
// developed so it is a good fallback. Additionally, we replace "en_US" with "en-US" because
// the original server-side code used "en_US" as the default value, but that's not the correct
// ISO code for English/United State. To ensure we remain backward compatible with devices that
// have not had their Keyboard Layout selected by the user, we want to treat "en_US" as if it was
// "en-US" to match the ISO standard codes now used in the keyboardLayouts.
console.debug("Current keyboard layout from store:", keyboardLayout);
if (keyboardLayout && keyboardLayout.length > 0)
return keyboardLayout.replace("en_US", "en-US");
return "en-US";
}, [keyboardLayout]);
const selectedKeyboard = useMemo(() => {
// fallback to original behaviour of en-US if no isoCode given or matching layout not found
return keyboards.find(keyboard => keyboard.isoCode === isoCode)
?? keyboards.find(keyboard => keyboard.isoCode === "en-US")!;
}, [isoCode]);
return { keyboardOptions, isoCode, selectedKeyboard };
}

View File

@ -315,6 +315,11 @@ video::-webkit-media-controls {
@apply inline-flex h-auto! w-auto! grow-0 py-1 text-xs; @apply inline-flex h-auto! w-auto! grow-0 py-1 text-xs;
} }
.hg-theme-default .hg-row .down-key {
background: rgb(28, 28, 28);
@apply text-white! font-bold!;
}
.hg-theme-default .hg-row .hg-button-container, .hg-theme-default .hg-row .hg-button-container,
.hg-theme-default .hg-row .hg-button:not(:last-child) { .hg-theme-default .hg-row .hg-button:not(:last-child) {
@apply mr-[2px]! md:mr-[5px]!; @apply mr-[2px]! md:mr-[5px]!;

View File

@ -1,45 +1,31 @@
import { chars as chars_fr_BE, name as name_fr_BE } from "@/keyboardLayouts/fr_BE" export interface KeyStroke { modifier: number; keys: number[]; }
import { chars as chars_cs_CZ, name as name_cs_CZ } from "@/keyboardLayouts/cs_CZ" export interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean }
import { chars as chars_en_UK, name as name_en_UK } from "@/keyboardLayouts/en_UK" export interface KeyCombo extends KeyInfo { deadKey?: boolean, accentKey?: KeyInfo }
import { chars as chars_en_US, name as name_en_US } from "@/keyboardLayouts/en_US" export interface KeyboardLayout {
import { chars as chars_fr_FR, name as name_fr_FR } from "@/keyboardLayouts/fr_FR" isoCode: string;
import { chars as chars_de_DE, name as name_de_DE } from "@/keyboardLayouts/de_DE" name: string;
import { chars as chars_it_IT, name as name_it_IT } from "@/keyboardLayouts/it_IT" chars: Record<string, KeyCombo>;
import { chars as chars_nb_NO, name as name_nb_NO } from "@/keyboardLayouts/nb_NO" modifierDisplayMap: Record<string, string>;
import { chars as chars_es_ES, name as name_es_ES } from "@/keyboardLayouts/es_ES" keyDisplayMap: Record<string, string>;
import { chars as chars_sv_SE, name as name_sv_SE } from "@/keyboardLayouts/sv_SE" virtualKeyboard: {
import { chars as chars_fr_CH, name as name_fr_CH } from "@/keyboardLayouts/fr_CH" main: { default: string[], shift: string[] },
import { chars as chars_de_CH, name as name_de_CH } from "@/keyboardLayouts/de_CH" control?: { default: string[], shift?: string[] },
arrows?: { default: string[] }
interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean } };
export type KeyCombo = KeyInfo & { deadKey?: boolean, accentKey?: KeyInfo }
export const layouts: Record<string, string> = {
be_FR: name_fr_BE,
cs_CZ: name_cs_CZ,
en_UK: name_en_UK,
en_US: name_en_US,
fr_FR: name_fr_FR,
de_DE: name_de_DE,
it_IT: name_it_IT,
nb_NO: name_nb_NO,
es_ES: name_es_ES,
sv_SE: name_sv_SE,
fr_CH: name_fr_CH,
de_CH: name_de_CH,
} }
export const chars: Record<string, Record<string, KeyCombo>> = { // To add a new layout, create a file like the above and add it to the list
be_FR: chars_fr_BE, import { cs_CZ } from "@/keyboardLayouts/cs_CZ"
cs_CZ: chars_cs_CZ, import { de_CH } from "@/keyboardLayouts/de_CH"
en_UK: chars_en_UK, import { de_DE } from "@/keyboardLayouts/de_DE"
en_US: chars_en_US, import { en_US } from "@/keyboardLayouts/en_US"
fr_FR: chars_fr_FR, import { en_UK } from "@/keyboardLayouts/en_UK"
de_DE: chars_de_DE, import { es_ES } from "@/keyboardLayouts/es_ES"
it_IT: chars_it_IT, import { fr_BE } from "@/keyboardLayouts/fr_BE"
nb_NO: chars_nb_NO, import { fr_CH } from "@/keyboardLayouts/fr_CH"
es_ES: chars_es_ES, import { fr_FR } from "@/keyboardLayouts/fr_FR"
sv_SE: chars_sv_SE, import { it_IT } from "@/keyboardLayouts/it_IT"
fr_CH: chars_fr_CH, import { nb_NO } from "@/keyboardLayouts/nb_NO"
de_CH: chars_de_CH, import { sv_SE } from "@/keyboardLayouts/sv_SE"
};
export const keyboards: KeyboardLayout[] = [ cs_CZ, de_CH, de_DE, en_UK, en_US, es_ES, fr_BE, fr_CH, fr_FR, it_IT, nb_NO, sv_SE ];

View File

@ -1,19 +1,22 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Čeština"; import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel const name = "Čeština";
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter const isoCode = "cs-CZ";
const keyHat = { key: "Digit3", shift: true, altRight: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyCaron = { key: "Equal", shift: true } // caron or haček (inverted hat), mark ˇ placed above the letter
const keyGrave = { key: "Digit7", shift: true, altRight: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Digit1", shift: true, altRight: true } // tilde, mark ~ placed above the letter
const keyRing = { key: "Backquote", shift: true } // kroužek (little ring), mark ° placed above the letter
const keyOverdot = { key: "Digit8", shift: true, altRight: true } // overdot (dot above), mark ˙ placed above the letter
const keyHook = { key: "Digit6", shift: true, altRight: true } // ogonoek (little hook), mark ˛ placed beneath a letter
const keyCedille = { key: "Equal", shift: true, altRight: true } // accent cedille (cedilla), mark ¸ placed beneath a letter
export const chars = { const keyTrema: KeyCombo = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
const keyAcute: KeyCombo = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat: KeyCombo = { key: "Digit3", shift: true, altRight: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyCaron: KeyCombo = { key: "Equal", shift: true } // caron or haček (inverted hat), mark ˇ placed above the letter
const keyGrave: KeyCombo = { key: "Digit7", shift: true, altRight: true } // accent grave, mark ` placed above the letter
const keyTilde: KeyCombo = { key: "Digit1", shift: true, altRight: true } // tilde, mark ~ placed above the letter
const keyRing: KeyCombo = { key: "Backquote", shift: true } // kroužek (little ring), mark ° placed above the letter
const keyOverdot: KeyCombo = { key: "Digit8", shift: true, altRight: true } // overdot (dot above), mark ˙ placed above the letter
const keyHook: KeyCombo = { key: "Digit6", shift: true, altRight: true } // ogonoek (little hook), mark ˛ placed beneath a letter
const keyCedille: KeyCombo = { key: "Equal", shift: true, altRight: true } // accent cedille (cedilla), mark ¸ placed beneath a letter
const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema }, "Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute }, "Á": { key: "KeyA", shift: true, accentKey: keyAcute },
@ -242,3 +245,13 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const cs_CZ: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,14 +1,17 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Schwiizerdütsch"; import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel const name = "Schwiizerdütsch";
const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter const isoCode = "de-CH";
const keyHat = { key: "Equal" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Equal", altRight: true } // tilde, mark ~ placed above the letter
export const chars = { const keyTrema: KeyCombo = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute: KeyCombo = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat: KeyCombo = { key: "Equal" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde: KeyCombo = { key: "Equal", altRight: true } // tilde, mark ~ placed above the letter
const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema }, "Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute }, "Á": { key: "KeyA", shift: true, accentKey: keyAcute },
@ -163,3 +166,23 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
const keyDisplayMap = {
...en_US.keyDisplayMap,
BracketLeft: "è",
"(BracketLeft)": "ü",
Semicolon: "é",
"(Semicolon)": "ö",
Quote: "à",
"(Quote)": "ä",
} as Record<string, string>;
export const de_CH: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
keyDisplayMap: keyDisplayMap,
// TODO need to localize these maps and layouts
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,113 +1,146 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Deutsch"; import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter const name = "Deutsch";
const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter const isoCode = "de-DE";
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
export const chars = { const keyAcute: KeyCombo = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat: KeyCombo = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const chars = {
a: { key: "KeyA" },
"á": { key: "KeyA", accentKey: keyAcute },
"â": { key: "KeyA", accentKey: keyHat },
"à": { key: "KeyA", accentKey: keyGrave },
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute }, "Á": { key: "KeyA", shift: true, accentKey: keyAcute },
"Â": { key: "KeyA", shift: true, accentKey: keyHat }, "Â": { key: "KeyA", shift: true, accentKey: keyHat },
"À": { key: "KeyA", shift: true, accentKey: keyGrave }, "À": { key: "KeyA", shift: true, accentKey: keyGrave },
"☺": { key: "KeyA", altRight: true }, // white smiling face ☺
b: { key: "KeyB" },
B: { key: "KeyB", shift: true }, B: { key: "KeyB", shift: true },
"": { key: "KeyB", altRight: true }, // single left-pointing angle quotation mark,
c: { key: "KeyC" },
C: { key: "KeyC", shift: true }, C: { key: "KeyC", shift: true },
"\u202f": { key: "KeyC", altRight: true }, // narrow no-break space
d: { key: "KeyD" },
D: { key: "KeyD", shift: true }, D: { key: "KeyD", shift: true },
"": { key: "KeyD", altRight: true }, // prime, mark placed above the letter
e: { key: "KeyE" },
"é": { key: "KeyE", accentKey: keyAcute },
"ê": { key: "KeyE", accentKey: keyHat },
"è": { key: "KeyE", accentKey: keyGrave },
"€": { key: "KeyE", altRight: true },
E: { key: "KeyE", shift: true }, E: { key: "KeyE", shift: true },
"É": { key: "KeyE", shift: true, accentKey: keyAcute }, "É": { key: "KeyE", shift: true, accentKey: keyAcute },
"Ê": { key: "KeyE", shift: true, accentKey: keyHat }, "Ê": { key: "KeyE", shift: true, accentKey: keyHat },
"È": { key: "KeyE", shift: true, accentKey: keyGrave }, "È": { key: "KeyE", shift: true, accentKey: keyGrave },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyZ", shift: true },
Z: { key: "KeyY", shift: true },
a: { key: "KeyA" },
"á": { key: "KeyA", accentKey: keyAcute },
"â": { key: "KeyA", accentKey: keyHat },
"à": { key: "KeyA", accentKey: keyGrave},
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
"é": { key: "KeyE", accentKey: keyAcute},
"ê": { key: "KeyE", accentKey: keyHat },
"è": { key: "KeyE", accentKey: keyGrave },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" }, f: { key: "KeyF" },
F: { key: "KeyF", shift: true },
"˟": { key: "KeyF", deadKey: true, altRight: true }, // modifier letter cross accent, ˟
G: { key: "KeyG", shift: true },
g: { key: "KeyG" }, g: { key: "KeyG" },
"ẞ": { key: "KeyG", altRight: true }, // capital sharp S, ẞ
h: { key: "KeyH" }, h: { key: "KeyH" },
H: { key: "KeyH", shift: true },
"ˍ": { key: "KeyH", deadKey: true, altRight: true }, // modifier letter low macron, ˍ
i: { key: "KeyI" }, i: { key: "KeyI" },
"í": { key: "KeyI", accentKey: keyAcute }, "í": { key: "KeyI", accentKey: keyAcute },
"î": { key: "KeyI", accentKey: keyHat }, "î": { key: "KeyI", accentKey: keyHat },
"ì": { key: "KeyI", accentKey: keyGrave }, "ì": { key: "KeyI", accentKey: keyGrave },
I: { key: "KeyI", shift: true },
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
"˜": { key: "KeyI", deadKey: true, altRight: true }, // tilde accent, mark ˜ placed above the letter
j: { key: "KeyJ" }, j: { key: "KeyJ" },
J: { key: "KeyJ", shift: true },
"¸": { key: "KeyJ", deadKey: true, altRight: true }, // cedilla accent, mark ¸ placed below the letter
k: { key: "KeyK" }, k: { key: "KeyK" },
K: { key: "KeyK", shift: true },
l: { key: "KeyL" }, l: { key: "KeyL" },
L: { key: "KeyL", shift: true },
"ˏ": { key: "KeyL", deadKey: true, altRight: true }, // modifier letter reversed comma, ˏ
m: { key: "KeyM" }, m: { key: "KeyM" },
M: { key: "KeyM", shift: true },
"µ": { key: "KeyM", altRight: true }, "µ": { key: "KeyM", altRight: true },
n: { key: "KeyN" }, n: { key: "KeyN" },
N: { key: "KeyN", shift: true },
"": { key: "KeyN", altRight: true }, // en dash,
o: { key: "KeyO" }, o: { key: "KeyO" },
"ó": { key: "KeyO", accentKey: keyAcute }, "ó": { key: "KeyO", accentKey: keyAcute },
"ô": { key: "KeyO", accentKey: keyHat }, "ô": { key: "KeyO", accentKey: keyHat },
"ò": { key: "KeyO", accentKey: keyGrave }, "ò": { key: "KeyO", accentKey: keyGrave },
O: { key: "KeyO", shift: true },
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
"˚": { key: "KeyO", deadKey: true, altRight: true }, // ring above, ˚
p: { key: "KeyP" }, p: { key: "KeyP" },
P: { key: "KeyP", shift: true },
"ˀ": { key: "KeyP", deadKey: true, altRight: true }, // modifier letter apostrophe, ʾ
q: { key: "KeyQ" }, q: { key: "KeyQ" },
Q: { key: "KeyQ", shift: true },
"@": { key: "KeyQ", altRight: true }, "@": { key: "KeyQ", altRight: true },
R: { key: "KeyR", shift: true },
r: { key: "KeyR" }, r: { key: "KeyR" },
"˝": { key: "KeyR", deadKey: true, altRight: true }, // double acute accent, mark ˝ placed above the letter
S: { key: "KeyS", shift: true },
s: { key: "KeyS" }, s: { key: "KeyS" },
"″": { key: "KeyS", altRight: true }, // double prime, mark ″ placed above the letter
T: { key: "KeyT", shift: true },
t: { key: "KeyT" }, t: { key: "KeyT" },
"ˇ": { key: "KeyT", deadKey: true, altRight: true }, // caron/hacek accent, mark ˇ placed above the letter
u: { key: "KeyU" }, u: { key: "KeyU" },
"ú": { key: "KeyU", accentKey: keyAcute }, "ú": { key: "KeyU", accentKey: keyAcute },
"û": { key: "KeyU", accentKey: keyHat }, "û": { key: "KeyU", accentKey: keyHat },
"ù": { key: "KeyU", accentKey: keyGrave }, "ù": { key: "KeyU", accentKey: keyGrave },
U: { key: "KeyU", shift: true },
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
"˘": { key: "KeyU", deadKey: true, altRight: true }, // breve accent, ˘ placed above the letter
v: { key: "KeyV" }, v: { key: "KeyV" },
V: { key: "KeyV", shift: true },
"«": { key: "KeyV", altRight: true }, // left-pointing double angle quotation mark, «
w: { key: "KeyW" }, w: { key: "KeyW" },
W: { key: "KeyW", shift: true },
"¯": { key: "KeyW", deadKey: true, altRight: true }, // macron accent, mark ¯ placed above the letter
x: { key: "KeyX" }, x: { key: "KeyX" },
X: { key: "KeyX", shift: true },
"»": { key: "KeyX", altRight: true },
// cross key between shift and y (aka OEM 102 key)
y: { key: "KeyZ" }, y: { key: "KeyZ" },
Y: { key: "KeyZ", shift: true },
"": { key: "KeyZ", altRight: true }, // single right-pointing angle quotation mark,
z: { key: "KeyY" }, z: { key: "KeyY" },
Z: { key: "KeyY", shift: true },
"¨": { key: "KeyY", deadKey: true, altRight: true }, // diaeresis accent, mark ¨ placed above the letter
"°": { key: "Backquote", shift: true }, "°": { key: "Backquote", shift: true },
"^": { key: "Backquote", deadKey: true }, "^": { key: "Backquote", deadKey: true },
"|": { key: "Backquote", altRight: true },
1: { key: "Digit1" }, 1: { key: "Digit1" },
"!": { key: "Digit1", shift: true }, "!": { key: "Digit1", shift: true },
"": { key: "Digit1", altRight: true }, // single quote, mark placed above the letter
2: { key: "Digit2" }, 2: { key: "Digit2" },
"\"": { key: "Digit2", shift: true }, "\"": { key: "Digit2", shift: true },
"²": { key: "Digit2", altRight: true }, "²": { key: "Digit2", altRight: true },
"<": { key: "Digit2", altRight: true }, // non-US < and >
3: { key: "Digit3" }, 3: { key: "Digit3" },
"§": { key: "Digit3", shift: true }, "§": { key: "Digit3", shift: true },
"³": { key: "Digit3", altRight: true }, "³": { key: "Digit3", altRight: true },
">": { key: "Digit3", altRight: true }, // non-US < and >
4: { key: "Digit4" }, 4: { key: "Digit4" },
"$": { key: "Digit4", shift: true }, "$": { key: "Digit4", shift: true },
"—": { key: "Digit4", altRight: true }, // em dash, —
5: { key: "Digit5" }, 5: { key: "Digit5" },
"%": { key: "Digit5", shift: true }, "%": { key: "Digit5", shift: true },
"¡": { key: "Digit5", altRight: true }, // inverted exclamation mark, ¡
6: { key: "Digit6" }, 6: { key: "Digit6" },
"&": { key: "Digit6", shift: true }, "&": { key: "Digit6", shift: true },
"¿": { key: "Digit6", altRight: true }, // inverted question mark, ¿
7: { key: "Digit7" }, 7: { key: "Digit7" },
"/": { key: "Digit7", shift: true }, "/": { key: "Digit7", shift: true },
"{": { key: "Digit7", altRight: true }, "{": { key: "Digit7", altRight: true },
@ -123,30 +156,192 @@ export const chars = {
"ß": { key: "Minus" }, "ß": { key: "Minus" },
"?": { key: "Minus", shift: true }, "?": { key: "Minus", shift: true },
"\\": { key: "Minus", altRight: true }, "\\": { key: "Minus", altRight: true },
"´": { key: "Equal", deadKey: true }, "´": { key: "Equal", deadKey: true }, // accent acute, mark ´ placed above the letter
"`": { key: "Equal", shift: true, deadKey: true }, "`": { key: "Equal", shift: true, deadKey: true }, // accent grave, mark ` placed above the letter
"˙": { key: "Equal", control: true, altRight: true, deadKey: true }, // acute accent, mark ˙ placed above the letter
"ü": { key: "BracketLeft" }, "ü": { key: "BracketLeft" },
"Ü": { key: "BracketLeft", shift: true }, "Ü": { key: "BracketLeft", shift: true },
Escape: { key: "BracketLeft", control: true },
"ʼ": { key: "BracketLeft", altRight: true }, // modifier letter apostrophe, ʼ
"+": { key: "BracketRight" }, "+": { key: "BracketRight" },
"*": { key: "BracketRight", shift: true }, "*": { key: "BracketRight", shift: true },
Control: { key: "BracketRight", control: true },
"~": { key: "BracketRight", altRight: true }, "~": { key: "BracketRight", altRight: true },
"ö": { key: "Semicolon" }, "ö": { key: "Semicolon" },
"Ö": { key: "Semicolon", shift: true }, "Ö": { key: "Semicolon", shift: true },
"ˌ": { key: "Semicolon", deadkey: true, altRight: true }, // modifier letter low vertical line, ˌ
"ä": { key: "Quote" }, "ä": { key: "Quote" },
"Ä": { key: "Quote", shift: true }, "Ä": { key: "Quote", shift: true },
"˗": { key: "Quote", deadKey: true, altRight: true }, // modifier letter minus sign, ˗
"#": { key: "Backslash" }, "#": { key: "Backslash" },
"'": { key: "Backslash", shift: true }, "'": { key: "Backslash", shift: true },
"": { key: "Backslash", altRight: true }, // minus sign,
",": { key: "Comma" }, ",": { key: "Comma" },
";": { key: "Comma", shift: true }, ";": { key: "Comma", shift: true },
"\u2011": { key: "Comma", altRight: true }, // non-breaking hyphen,
".": { key: "Period" }, ".": { key: "Period" },
":": { key: "Period", shift: true }, ":": { key: "Period", shift: true },
"·": { key: "Period", altRight: true }, // middle dot, ·
"-": { key: "Slash" }, "-": { key: "Slash" },
"_": { key: "Slash", shift: true }, "_": { key: "Slash", shift: true },
"<": { key: "IntlBackslash" }, "\u00ad": { key: "Slash", altRight: true }, // soft hyphen, ­
">": { key: "IntlBackslash", shift: true },
"|": { key: "IntlBackslash", altRight: true },
" ": { key: "Space" }, " ": { key: "Space" },
"\n": { key: "Enter" }, "\n": { key: "Enter" },
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const keyDisplayMap: Record<string, string> = {
...en_US.keyDisplayMap,
// now override the English keyDisplayMap with German specific keys
// Combination keys
CtrlAltDelete: "Strg + Alt + Entf",
CtrlAltBackspace: "Strg + Alt + ←",
// German action keys
AltLeft: "Alt",
AltRight: "AltGr",
Backspace: "Rücktaste",
"(Backspace)": "Rücktaste",
CapsLock: "Feststelltaste",
Clear: "Entf",
ControlLeft: "Strg",
ControlRight: "Strg",
Delete: "Entf",
End: "Ende",
Enter: "Eingabe",
Escape: "Esc",
Home: "Pos1",
Insert: "Einfg",
Menu: "Menü",
MetaLeft: "Meta",
MetaRight: "Meta",
PageDown: "Bild ↓",
PageUp: "Bild ↑",
ShiftLeft: "Umschalt",
ShiftRight: "Umschalt",
// German umlauts and ß
BracketLeft: "ü",
"(BracketLeft)": "Ü",
Semicolon: "ö",
"(Semicolon)": "Ö",
Quote: "ä",
"(Quote)": "Ä",
Minus: "ß",
"(Minus)": "?",
Equal: "´",
"(Equal)": "`",
Backslash: "#",
"(Backslash)": "'",
// Shifted Numbers
"(Digit2)": "\"",
"(Digit3)": "§",
"(Digit6)": "&",
"(Digit7)": "/",
"(Digit8)": "(",
"(Digit9)": ")",
"(Digit0)": "=",
// Additional German symbols
Backquote: "^",
"(Backquote)": "°",
Comma: ",",
"(Comma)": ";",
Period: ".",
"(Period)": ":",
Slash: "-",
"(Slash)": "_",
// Numpad
NumpadDecimal: "Num ,",
NumpadEnter: "Num Eingabe",
NumpadInsert: "Einfg",
NumpadDelete: "Entf",
// Modals
PrintScreen: "Druck",
ScrollLock: "Rollen",
"(Pause)": "Unterbr",
}
export const modifierDisplayMap: Record<string, string> = {
ShiftLeft: "Umschalt (links)",
ShiftRight: "Umschalt (rechts)",
ControlLeft: "Strg (links)",
ControlRight: "Strg (rechts)",
AltLeft: "Alt",
AltRight: "AltGr",
MetaLeft: "Meta (links)",
MetaRight: "Meta (rechts)",
AltGr: "AltGr",
} as Record<string, string>;
export const virtualKeyboard = {
main: {
default: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"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",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Backslash Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
],
shift: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"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 MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
]
},
control: {
default: [
"PrintScreen ScrollLock Pause",
"Insert Home PageUp",
"Delete End PageDown"
],
shift: [
"(PrintScreen) ScrollLock (Pause)",
"Insert Home PageUp",
"Delete End PageDown"
],
},
arrows: {
default: [
" ArrowUp ",
"ArrowLeft ArrowDown ArrowRight"],
},
numpad: {
numlocked: [
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
"Numpad7 Numpad8 Numpad9 NumpadAdd",
"Numpad4 Numpad5 Numpad6",
"Numpad1 Numpad2 Numpad3 NumpadEnter",
"Numpad0 NumpadDecimal",
],
default: [
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
"Home ArrowUp PageUp NumpadAdd",
"ArrowLeft Clear ArrowRight",
"End ArrowDown PageDown NumpadEnter",
"NumpadInsert NumpadDelete",
],
}
}
export const de_DE: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
keyDisplayMap: keyDisplayMap,
modifierDisplayMap: modifierDisplayMap,
virtualKeyboard: virtualKeyboard
};

View File

@ -1,8 +1,11 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "English (UK)"; import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
export const chars = { const name = "English (UK)";
const isoCode = "en-UK";
const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
B: { key: "KeyB", shift: true }, B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true }, C: { key: "KeyC", shift: true },
@ -105,3 +108,13 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo> } as Record<string, KeyCombo>
export const en_UK: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,6 +1,16 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "English (US)"; const name = "English (US)";
const isoCode = "en-US";
// dead keys for "international" 101 keyboards TODO
/*
const keyAcute = { key: "Quote", control: true, menu: true, mark: "´" } // acute accent
const keyCedilla = { key: ".", shift: true, alt: true, mark: "¸" } // cedilla accent
const keyComma = { key: "BracketRight", shift: true, altRight: true, mark: "," } // comma accent
const keyDiaeresis = { key: "Quote", shift: true, control: true, menu: true, mark: "¨" } // diaeresis accent
const keyDegree = { key: "Semicolon", shift: true, control: true, menu: true, mark: "°" } // degree accent
*/
export const chars = { export const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
@ -89,25 +99,213 @@ export const chars = {
">": { key: "Period", shift: true }, ">": { key: "Period", shift: true },
";": { key: "Semicolon" }, ";": { key: "Semicolon" },
":": { key: "Semicolon", shift: true }, ":": { key: "Semicolon", shift: true },
"¶": { key: "Semicolon", altRight: true }, // pilcrow sign
"[": { key: "BracketLeft" }, "[": { key: "BracketLeft" },
"{": { key: "BracketLeft", shift: true }, "{": { key: "BracketLeft", shift: true },
"«": { key: "BracketLeft", altRight: true }, // double left quote sign
"]": { key: "BracketRight" }, "]": { key: "BracketRight" },
"}": { key: "BracketRight", shift: true }, "}": { key: "BracketRight", shift: true },
"»": { key: "BracketRight", altRight: true }, // double right quote sign
"\\": { key: "Backslash" }, "\\": { key: "Backslash" },
"|": { key: "Backslash", shift: true }, "|": { key: "Backslash", shift: true },
"¬": { key: "Backslash", altRight: true }, // not sign
"`": { key: "Backquote" }, "`": { key: "Backquote" },
"~": { key: "Backquote", shift: true }, "~": { key: "Backquote", shift: true },
"§": { key: "IntlBackslash" }, "§": { key: "IntlBackslash" },
"±": { key: "IntlBackslash", shift: true }, "±": { key: "IntlBackslash", shift: true },
" ": { key: "Space", shift: false }, " ": { key: "Space" },
"\n": { key: "Enter", shift: false }, "\n": { key: "Enter" },
Enter: { key: "Enter", shift: false }, Enter: { key: "Enter" },
Tab: { key: "Tab", shift: false }, Escape: { key: "Escape" },
PrintScreen: { key: "Prt Sc", shift: false }, Tab: { key: "Tab" },
PrintScreen: { key: "Prt Sc" },
SystemRequest: { key: "Prt Sc", shift: true }, SystemRequest: { key: "Prt Sc", shift: true },
ScrollLock: { key: "ScrollLock", shift: false}, ScrollLock: { key: "ScrollLock" },
Pause: { key: "Pause", shift: false }, Pause: { key: "Pause" },
Break: { key: "Pause", shift: true }, Break: { key: "Pause", shift: true },
Insert: { key: "Insert", shift: false }, Insert: { key: "Insert" },
Delete: { key: "Delete", shift: false }, Delete: { key: "Delete" },
} as Record<string, KeyCombo> } as Record<string, KeyCombo>
export const modifierDisplayMap: Record<string, string> = {
ControlLeft: "Left Ctrl",
ControlRight: "Right Ctrl",
ShiftLeft: "Left Shift",
ShiftRight: "Right Shift",
AltLeft: "Left Alt",
AltRight: "Right Alt",
MetaLeft: "Left Meta",
MetaRight: "Right Meta",
AltGr: "AltGr",
} as Record<string, string>;
export const keyDisplayMap: Record<string, string> = {
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
CtrlAltBackspace: "Ctrl + Alt + Backspace",
AltGr: "AltGr",
AltLeft: "Alt",
AltRight: "Alt",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
Backspace: "Backspace",
"(Backspace)": "Backspace",
CapsLock: "Caps Lock",
Clear: "Clear",
ControlLeft: "Ctrl",
ControlRight: "Ctrl",
Delete: "Delete",
End: "End",
Enter: "Enter",
Escape: "Esc",
Home: "Home",
Insert: "Insert",
Menu: "Menu",
MetaLeft: "Meta",
MetaRight: "Meta",
PageDown: "PgDn",
PageUp: "PgUp",
ShiftLeft: "Shift",
ShiftRight: "Shift",
Space: " ",
Tab: "Tab",
// Letters
KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e",
KeyF: "f", KeyG: "g", KeyH: "h", KeyI: "i", KeyJ: "j",
KeyK: "k", KeyL: "l", KeyM: "m", KeyN: "n", KeyO: "o",
KeyP: "p", KeyQ: "q", KeyR: "r", KeyS: "s", KeyT: "t",
KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y",
KeyZ: "z",
// Capital letters
"(KeyA)": "A", "(KeyB)": "B", "(KeyC)": "C", "(KeyD)": "D", "(KeyE)": "E",
"(KeyF)": "F", "(KeyG)": "G", "(KeyH)": "H", "(KeyI)": "I", "(KeyJ)": "J",
"(KeyK)": "K", "(KeyL)": "L", "(KeyM)": "M", "(KeyN)": "N", "(KeyO)": "O",
"(KeyP)": "P", "(KeyQ)": "Q", "(KeyR)": "R", "(KeyS)": "S", "(KeyT)": "T",
"(KeyU)": "U", "(KeyV)": "V", "(KeyW)": "W", "(KeyX)": "X", "(KeyY)": "Y",
"(KeyZ)": "Z",
// Numbers
Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5",
Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0",
// Shifted Numbers
"(Digit1)": "!", "(Digit2)": "@", "(Digit3)": "#", "(Digit4)": "$", "(Digit5)": "%",
"(Digit6)": "^", "(Digit7)": "&", "(Digit8)": "*", "(Digit9)": "(", "(Digit0)": ")",
// Symbols
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
"(BracketLeft)": "{",
BracketRight: "]",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": "\"",
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
// Function keys
F1: "F1", F2: "F2", F3: "F3", F4: "F4",
F5: "F5", F6: "F6", F7: "F7", F8: "F8",
F9: "F9", F10: "F10", F11: "F11", F12: "F12",
// Numpad
Numpad0: "Num 0", Numpad1: "Num 1", Numpad2: "Num 2",
Numpad3: "Num 3", Numpad4: "Num 4", Numpad5: "Num 5",
Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8",
Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -",
NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .",
NumpadEqual: "Num =", NumpadEnter: "Num Enter", NumpadInsert: "Ins",
NumpadDelete: "Del", NumLock: "Num Lock",
// Modals
PrintScreen: "Prt Sc", ScrollLock: "Scr Lk", Pause: "Pause",
"(PrintScreen)": "Sys Rq", "(Pause)": "Break",
SystemRequest: "Sys Rq", Break: "Break"
};
export const virtualKeyboard = {
main: {
default: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"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 MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
],
shift: [
"CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"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 MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight",
]
},
control: {
default: [
"PrintScreen ScrollLock Pause",
"Insert Home PageUp",
"Delete End PageDown"
],
shift: [
"(PrintScreen) ScrollLock (Pause)",
"Insert Home PageUp",
"Delete End PageDown"
],
},
arrows: {
default: [
"ArrowUp",
"ArrowLeft ArrowDown ArrowRight"],
},
numpad: {
numlocked: [
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
"Numpad7 Numpad8 Numpad9 NumpadAdd",
"Numpad4 Numpad5 Numpad6",
"Numpad1 Numpad2 Numpad3 NumpadEnter",
"Numpad0 NumpadDecimal",
],
default: [
"NumLock NumpadDivide NumpadMultiply NumpadSubtract",
"Home ArrowUp PageUp NumpadAdd",
"ArrowLeft Clear ArrowRight",
"End ArrowDown PageDown NumpadEnter",
"NumpadInsert NumpadDelete",
],
}
}
export const en_US: KeyboardLayout = {
isoCode,
name,
chars,
keyDisplayMap,
modifierDisplayMap,
virtualKeyboard
};

View File

@ -1,14 +1,17 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Español"; import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel const name = "Español";
const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter const isoCode = "es-ES";
const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "BracketRight" } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Key4", altRight: true } // tilde, mark ~ placed above the letter
export const chars = { const keyTrema: KeyCombo = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyAcute: KeyCombo = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat: KeyCombo = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave: KeyCombo = { key: "BracketRight" } // accent grave, mark ` placed above the letter
const keyTilde: KeyCombo = { key: "Key4", altRight: true } // tilde, mark ~ placed above the letter
const chars = {
A: { key: "KeyA", shift: true }, A: { key: "KeyA", shift: true },
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema }, "Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute }, "Á": { key: "KeyA", shift: true, accentKey: keyAcute },
@ -166,3 +169,13 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const es_ES: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,14 +1,17 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Belgisch Nederlands"; import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel const name = "Belgisch Nederlands";
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter const isoCode = "nl-BE";
const keyAcute = { key: "Semicolon", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyGrave = { key: "Quote", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Slash", altRight: true } // tilde, mark ~ placed above the letter
export const chars = { const keyTrema: KeyCombo = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat: KeyCombo = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyAcute: KeyCombo = { key: "Semicolon", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyGrave: KeyCombo = { key: "Quote", shift: true } // accent grave, mark ` placed above the letter
const keyTilde: KeyCombo = { key: "Slash", altRight: true } // tilde, mark ~ placed above the letter
const chars = {
A: { key: "KeyQ", shift: true }, A: { key: "KeyQ", shift: true },
"Ä": { key: "KeyQ", shift: true, accentKey: keyTrema }, "Ä": { key: "KeyQ", shift: true, accentKey: keyTrema },
"Â": { key: "KeyQ", shift: true, accentKey: keyHat }, "Â": { key: "KeyQ", shift: true, accentKey: keyHat },
@ -165,3 +168,13 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const fr_BE: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

View File

@ -1,11 +1,12 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
import { chars as chars_de_CH } from "./de_CH" import { de_CH } from "./de_CH"
export const name = "Français de Suisse"; const name = "Français de Suisse";
const isoCode = "fr-CH";
export const chars = { const chars = {
...chars_de_CH, ...de_CH.chars,
"è": { key: "BracketLeft" }, "è": { key: "BracketLeft" },
"ü": { key: "BracketLeft", shift: true }, "ü": { key: "BracketLeft", shift: true },
"é": { key: "Semicolon" }, "é": { key: "Semicolon" },
@ -13,3 +14,23 @@ export const chars = {
"à": { key: "Quote" }, "à": { key: "Quote" },
"ä": { key: "Quote", shift: true }, "ä": { key: "Quote", shift: true },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
const keyDisplayMap = {
...de_CH.keyDisplayMap,
"BracketLeft": "è",
"BracketLeftShift": "ü",
"Semicolon": "é",
"SemicolonShift": "ö",
"Quote": "à",
"QuoteShift": "ä",
} as Record<string, string>;
export const fr_CH: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
keyDisplayMap: keyDisplayMap,
// TODO need to localize these maps and layouts
modifierDisplayMap: de_CH.modifierDisplayMap,
virtualKeyboard: de_CH.virtualKeyboard
};

View File

@ -1,11 +1,14 @@
import { KeyCombo } from "../keyboardLayouts" import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
export const name = "Français"; import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel const name = "Français";
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter const isoCode = "fr-FR";
export const chars = { const keyTrema: KeyCombo = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat: KeyCombo = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
const chars = {
A: { key: "KeyQ", shift: true }, A: { key: "KeyQ", shift: true },
"Ä": { key: "KeyQ", shift: true, accentKey: keyTrema }, "Ä": { key: "KeyQ", shift: true, accentKey: keyTrema },
"Â": { key: "KeyQ", shift: true, accentKey: keyHat }, "Â": { key: "KeyQ", shift: true, accentKey: keyHat },
@ -137,3 +140,13 @@ export const chars = {
Enter: { key: "Enter" }, Enter: { key: "Enter" },
Tab: { key: "Tab" }, Tab: { key: "Tab" },
} as Record<string, KeyCombo>; } as Record<string, KeyCombo>;
export const fr_FR: KeyboardLayout = {
isoCode: isoCode,
name: name,
chars: chars,
// TODO need to localize these maps and layouts
keyDisplayMap: en_US.keyDisplayMap,
modifierDisplayMap: en_US.modifierDisplayMap,
virtualKeyboard: en_US.virtualKeyboard
};

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