Compare commits

..

1 Commits

Author SHA1 Message Date
Siyuan Miao ae775aeeee refactor: use less data to transfer keyboard and mouse events [WIP DO NOT MERGE] 2025-08-12 17:24:08 +02:00
125 changed files with 2997 additions and 5546 deletions

View File

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

View File

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

View File

@ -1,46 +0,0 @@
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,9 +23,6 @@ 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

View File

@ -1,355 +0,0 @@
<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 -tags timetzdata GO_BUILD_ARGS := -tags netgo
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,9 +37,7 @@ 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 comprehensive development information, including setup, testing, debugging, and contribution guidelines, see **[DEVELOPMENT.md](DEVELOPMENT.md)**. 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 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,12 +22,25 @@ 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()
_, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, 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,27 +1,9 @@
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,8 +9,6 @@ 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 {
@ -82,7 +80,6 @@ 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"`
@ -114,18 +111,11 @@ 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
// This is the "Standard" jiggler option in the UI TLSMode: "",
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
@ -148,21 +138,6 @@ 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()
@ -178,8 +153,6 @@ 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()
@ -188,7 +161,6 @@ 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
} }
@ -209,9 +181,6 @@ 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")
} }

View File

@ -1,53 +0,0 @@
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,7 +28,6 @@ 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:"
@ -44,7 +43,6 @@ 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
@ -74,10 +72,6 @@ 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
@ -145,36 +139,28 @@ EOF
fi fi
fi fi
if [ "$INSTALL_APP" = true ] msg_info "▶ Building go binary"
then make build_dev
msg_info "▶ Building release binary"
make build_release msg_info "▶ Killing any existing instances of the application"
# Kill any existing instances of the application
# Copy the binary to the remote host as if we were the OTA updater. ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
msg_info "▶ Copying binary to remote host"
# Reboot the device, the new app will be deployed by the startup process. # Copy the binary to the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot" ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
else
msg_info "▶ Building development binary" if [ "$RESET_USB_HID_DEVICE" = true ]; then
make build_dev 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"
msg_info "▶ Killing any existing instances of the application" # Remove the old USB gadget configuration
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true" 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"
msg_info "▶ Copying binary to remote host" fi
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"
if [ "$RESET_USB_HID_DEVICE" = true ]; then # Deploy and run the application on the remote host
msg_info "▶ Resetting USB HID device" ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
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
@ -191,8 +177,7 @@ 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} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_debug.log PION_LOG_TRACE=${LOG_TRACE_SCOPES} GODEBUG=netdns=1 ./jetkvm_app_debug
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]any{"obj": screen}) _, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"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]any{"obj": objName, "state": state}) return CallCtrlAction("lv_obj_set_state", map[string]interface{}{"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]any{"obj": objName, "flag": flag}) return CallCtrlAction("lv_obj_add_flag", map[string]interface{}{"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]any{"obj": objName, "flag": flag}) return CallCtrlAction("lv_obj_clear_flag", map[string]interface{}{"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]any{"obj": objName, "opa": opacity}) return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]interface{}{"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]any{"obj": objName, "time": duration}) return CallCtrlAction("lv_obj_fade_in", map[string]interface{}{"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]any{"obj": objName, "time": duration}) return CallCtrlAction("lv_obj_fade_out", map[string]interface{}{"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]any{"obj": objName, "text": text}) return CallCtrlAction("lv_label_set_text", map[string]interface{}{"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]any{"obj": objName, "src": src}) return CallCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src})
} }
func lvDispSetRotation(rotation string) (*CtrlResponse, error) { func lvDispSetRotation(rotation string) (*CtrlResponse, error) {
return CallCtrlAction("lv_disp_set_rotation", map[string]any{"rotation": rotation}) return CallCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation})
} }
func updateLabelIfChanged(objName string, newText string) { func updateLabelIfChanged(objName string, newText string) {

114
fuse.go Normal file
View File

@ -0,0 +1,114 @@
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,7 +11,6 @@ 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
@ -29,9 +28,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.40.0 golang.org/x/crypto v0.39.0
golang.org/x/net v0.41.0 golang.org/x/net v0.41.0
golang.org/x/sys v0.34.0 golang.org/x/sys v0.33.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
@ -51,7 +50,6 @@ 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
@ -77,7 +75,6 @@ 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
@ -85,7 +82,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.27.0 // indirect golang.org/x/text v0.26.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,8 +38,6 @@ 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=
@ -64,8 +62,6 @@ 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=
@ -150,8 +146,6 @@ 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=
@ -181,12 +175,10 @@ 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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
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=
@ -196,10 +188,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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
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,7 +3,6 @@ package confparser
import ( import (
"fmt" "fmt"
"net" "net"
"net/url"
"reflect" "reflect"
"slices" "slices"
"strconv" "strconv"
@ -16,22 +15,22 @@ import (
type FieldConfig struct { type FieldConfig struct {
Name string Name string
Required bool Required bool
RequiredIf map[string]any RequiredIf map[string]interface{}
OneOf []string OneOf []string
ValidateTypes []string ValidateTypes []string
Defaults any Defaults interface{}
IsEmpty bool IsEmpty bool
CurrentValue any CurrentValue interface{}
TypeString string TypeString string
Delegated bool Delegated bool
shouldUpdateValue bool shouldUpdateValue bool
} }
func SetDefaultsAndValidate(config any) error { func SetDefaultsAndValidate(config interface{}) error {
return setDefaultsAndValidate(config, true) return setDefaultsAndValidate(config, true)
} }
func setDefaultsAndValidate(config any, isRoot bool) error { func setDefaultsAndValidate(config interface{}, 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")
@ -55,7 +54,7 @@ func setDefaultsAndValidate(config any, 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]any), RequiredIf: make(map[string]interface{}),
CurrentValue: fieldValue.Interface(), CurrentValue: fieldValue.Interface(),
IsEmpty: false, IsEmpty: false,
TypeString: fieldType, TypeString: fieldType,
@ -142,8 +141,8 @@ func setDefaultsAndValidate(config any, 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.SplitSeq(requiredIf, ",") requiredIfParts := strings.Split(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)
@ -168,7 +167,7 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
return nil return nil
} }
func validateFields(config any, fields map[string]FieldConfig) error { func validateFields(config interface{}, 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 {
@ -215,7 +214,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error {
return nil return nil
} }
func (f *FieldConfig) populate(config any) { func (f *FieldConfig) populate(config interface{}) {
// update the field if it's not empty // update the field if it's not empty
if !f.shouldUpdateValue { if !f.shouldUpdateValue {
return return
@ -373,10 +372,6 @@ 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,11 +43,9 @@ 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,http_user_provided" default:"ntp,http"` TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" 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 any) (string, error) { func toString(v interface{}) (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 any, name string) string { FormatPartValueByName: func(value interface{}, 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.SplitSeq(strings.ToLower(env), ",") scopes := strings.Split(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 ...any) { func (c pionLogger) Tracef(format string, args ...interface{}) {
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 ...any) { func (c pionLogger) Debugf(format string, args ...interface{}) {
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 ...any) { func (c pionLogger) Infof(format string, args ...interface{}) {
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 ...any) { func (c pionLogger) Warnf(format string, args ...interface{}) {
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 ...any) { func (c pionLogger) Errorf(format string, args ...interface{}) {
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 ...any) error { func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) 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,8 +3,6 @@ package network
import ( import (
"fmt" "fmt"
"net" "net"
"net/http"
"net/url"
"time" "time"
"github.com/guregu/null/v6" "github.com/guregu/null/v6"
@ -34,9 +32,8 @@ 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"`
HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"` Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
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"`
@ -48,11 +45,9 @@ 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,http_user_provided" default:"ntp,http"` TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" 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 {
@ -74,18 +69,6 @@ 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.SplitSeq(string(lines), "\n") { for _, line := range strings.Split(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,7 +21,6 @@ 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
@ -77,7 +76,6 @@ 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
@ -91,7 +89,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)
@ -137,27 +135,6 @@ 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
} }
@ -341,25 +318,6 @@ 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 any) bool { func IsSame(a, b interface{}) bool {
aJSON, err := json.Marshal(a) aJSON, err := json.Marshal(a)
if err != nil { if err != nil {
return false return false

View File

@ -5,7 +5,6 @@ import (
"errors" "errors"
"math/rand" "math/rand"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"time" "time"
) )
@ -20,9 +19,9 @@ var defaultHTTPUrls = []string{
// "http://www.msftconnecttest.com/connecttest.txt", // "http://www.msftconnecttest.com/connecttest.txt",
} }
func (t *TimeSync) queryAllHttpTime(httpUrls []string) (now *time.Time) { func (t *TimeSync) queryAllHttpTime() (now *time.Time) {
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4)) chunkSize := 4
t.l.Info().Strs("httpUrls", httpUrls).Int("chunkSize", chunkSize).Msg("querying HTTP URLs") httpUrls := t.httpUrls
// 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] })
@ -58,7 +57,6 @@ 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)
@ -124,16 +122,10 @@ 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{
Transport: transport, Timeout: timeout,
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,7 +73,6 @@ 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,7 +1,6 @@
package timesync package timesync
import ( import (
"context"
"math/rand/v2" "math/rand/v2"
"strconv" "strconv"
"time" "time"
@ -22,9 +21,9 @@ var defaultNTPServers = []string{
"3.pool.ntp.org", "3.pool.ntp.org",
} }
func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) { func (t *TimeSync) queryNetworkTime() (now *time.Time, offset *time.Duration) {
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4)) chunkSize := 4
t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP servers") ntpServers := t.ntpServers
// 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] })
@ -47,10 +46,6 @@ 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().
@ -71,25 +66,15 @@ 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(rtt) ).Set(float64(response.RTT.Milliseconds()))
// set the RTT histogram // set the RTT histogram
metricNtpServerRttHistogram.WithLabelValues( metricNtpServerRttHistogram.WithLabelValues(
server, server,
).Observe(rtt) ).Observe(float64(response.RTT.Milliseconds()))
// set the server info // set the server info
metricNtpServerInfo.WithLabelValues( metricNtpServerInfo.WithLabelValues(
@ -106,13 +91,10 @@ 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()).
Float64("rtt", rtt). Str("rtt", response.RTT.String()).
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,8 +28,9 @@ type TimeSync struct {
syncLock *sync.Mutex syncLock *sync.Mutex
l *zerolog.Logger l *zerolog.Logger
networkConfig *network.NetworkConfig ntpServers []string
dhcpNtpAddresses []string httpUrls []string
networkConfig *network.NetworkConfig
rtcDevicePath string rtcDevicePath string
rtcDevice *os.File //nolint:unused rtcDevice *os.File //nolint:unused
@ -63,13 +64,14 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
} }
t := &TimeSync{ t := &TimeSync{
syncLock: &sync.Mutex{}, syncLock: &sync.Mutex{},
l: opts.Logger, l: opts.Logger,
dhcpNtpAddresses: []string{}, rtcDevicePath: rtcDevice,
rtcDevicePath: rtcDevice, rtcLock: &sync.Mutex{},
rtcLock: &sync.Mutex{}, preCheckFunc: opts.PreCheckFunc,
preCheckFunc: opts.PreCheckFunc, ntpServers: defaultNTPServers,
networkConfig: opts.NetworkConfig, httpUrls: defaultHTTPUrls,
networkConfig: opts.NetworkConfig,
} }
if t.rtcDevicePath != "" { if t.rtcDevicePath != "" {
@ -80,42 +82,34 @@ 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 {
switch t.networkConfig.TimeSyncMode.String { syncModeString = 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
}
} }
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") switch syncModeString {
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 {
@ -160,61 +154,16 @@ func (t *TimeSync) Sync() error {
offset *time.Duration offset *time.Duration
) )
metricTimeSyncCount.Inc()
syncMode := t.getSyncMode() syncMode := t.getSyncMode()
Orders: metricTimeSyncCount.Inc()
for _, mode := range syncMode.Ordering {
switch mode { if syncMode.Ntp {
case "ntp_user_provided": now, offset = t.queryNetworkTime()
if syncMode.Ntp { }
t.l.Info().Msg("using NTP custom servers")
now, offset = t.queryNetworkTime(t.networkConfig.TimeSyncNTPServers) if syncMode.Http && now == nil {
if now != nil { now = t.queryAllHttpTime()
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 {

View File

@ -1,72 +0,0 @@
//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)
}
}

View File

@ -1,602 +0,0 @@
// 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.SplitSeq(str, "\n") { for _, line := range strings.Split(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.FieldsSeq(value) { for _, ipStr := range strings.Fields(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]any) watchPaths := make(map[string]interface{})
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,10 +14,9 @@ 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,
} }
@ -61,8 +60,6 @@ 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
@ -70,9 +67,7 @@ const (
KeyboardLedMaskScrollLock = 1 << 2 KeyboardLedMaskScrollLock = 1 << 2
KeyboardLedMaskCompose = 1 << 3 KeyboardLedMaskCompose = 1 << 3
KeyboardLedMaskKana = 1 << 4 KeyboardLedMaskKana = 1 << 4
// power on/off LED is 5 ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana
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,
@ -85,7 +80,6 @@ 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 {
@ -96,27 +90,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(state byte) { func (u *UsbGadget) updateKeyboardState(b byte) {
u.keyboardStateLock.Lock() u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock() defer u.keyboardStateLock.Unlock()
if state&^ValidKeyboardLedMasks != 0 { if b&^ValidKeyboardLedMasks != 0 {
u.log.Warn().Uint8("state", state).Msg("ignoring invalid bits") u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring")
return return
} }
if u.keyboardState == state { newState := getKeyboardState(b)
if reflect.DeepEqual(u.keyboardState, newState) {
return return
} }
u.log.Trace().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated") u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated")
u.keyboardState = state u.keyboardState = newState
if u.onKeyboardStateChange != nil { if u.onKeyboardStateChange != nil {
(*u.onKeyboardStateChange)(getKeyboardState(state)) (*u.onKeyboardStateChange)(newState)
} }
} }
@ -128,35 +122,7 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
u.keyboardStateLock.Lock() u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock() defer u.keyboardStateLock.Unlock()
return getKeyboardState(u.keyboardState) return 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() {
@ -175,7 +141,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 for LED state changes") l.Trace().Msg("reading from keyboard")
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
@ -192,7 +158,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
} }
u.resetLogSuppressionCounter("keyboardHidFileRead") u.resetLogSuppressionCounter("keyboardHidFileRead")
l.Trace().Int("n", n).Uints8("buf", buf).Msg("got data from keyboard") l.Trace().Int("n", n).Bytes("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
@ -228,12 +194,12 @@ func (u *UsbGadget) OpenKeyboardHidFile() error {
return u.openKeyboardHidFile() return u.openKeyboardHidFile()
} }
func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error { func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
if err := u.openKeyboardHidFile(); err != nil { if err := u.openKeyboardHidFile(); err != nil {
return err return err
} }
_, err := u.keyboardHidFile.Write(append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...)) _, err := u.keyboardHidFile.Write(data)
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()
@ -244,147 +210,24 @@ func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error {
return nil return nil
} }
func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState { func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
// 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) > hidKeyBufferSize { if len(keys) > 6 {
keys = keys[:hidKeyBufferSize] keys = keys[:6]
} }
if len(keys) < hidKeyBufferSize { if len(keys) < 6 {
keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...) keys = append(keys, make([]uint8, 6-len(keys))...)
} }
err := u.keyboardWriteHidFile(modifier, keys) err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]})
if err != nil { if err != nil {
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0") return err
} }
return u.UpdateKeysDown(modifier, keys), err u.resetUserInputTime()
} 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,10 +11,9 @@ 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,
} }
@ -85,19 +84,19 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
return nil return nil
} }
func (u *UsbGadget) AbsMouseReport(x int, y int, buttons uint8) error { func (u *UsbGadget) AbsMouseReport(x, 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
byte(x), // X Low Byte uint8(x), // X Low Byte
byte(x >> 8), // X High Byte uint8(x >> 8), // X High Byte
byte(y), // Y Low Byte uint8(y), // Y Low Byte
byte(y >> 8), // Y High Byte uint8(y >> 8), // Y High Byte
}) })
if err != nil { if err != nil {
return err return err

View File

@ -11,10 +11,9 @@ 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,
} }
@ -75,15 +74,15 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
return nil return nil
} }
func (u *UsbGadget) RelMouseReport(mx int8, my int8, buttons uint8) error { func (u *UsbGadget) RelMouseReport(mx, 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
byte(mx), // X uint8(mx), // X
byte(my), // Y uint8(my), // Y
0, // Wheel 0, // Wheel
}) })
if err != nil { if err != nil {
return err return err

View File

@ -41,11 +41,6 @@ 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
@ -65,9 +60,7 @@ type UsbGadget struct {
relMouseHidFile *os.File relMouseHidFile *os.File
relMouseLock sync.Mutex relMouseLock sync.Mutex
keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana) keyboardState KeyboardState
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
@ -84,7 +77,6 @@ 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
@ -130,8 +122,7 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
txLock: sync.Mutex{}, txLock: sync.Mutex{},
keyboardStateCtx: keyboardCtx, keyboardStateCtx: keyboardCtx,
keyboardStateCancel: keyboardCancel, keyboardStateCancel: keyboardCancel,
keyboardState: 0, keyboardState: KeyboardState{},
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,7 +2,6 @@ package usbgadget
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strconv" "strconv"
@ -11,31 +10,6 @@ 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...)
@ -107,7 +81,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 ...any) { func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) {
u.logSuppressionLock.Lock() u.logSuppressionLock.Lock()
defer u.logSuppressionLock.Unlock() defer u.logSuppressionLock.Unlock()

View File

@ -1,156 +1,39 @@
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"
) )
type JigglerConfig struct { var lastUserInput = time.Now()
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() {
ensureConfigLoaded() go runJiggler()
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() {
if jigglerEnabled { for {
if config.JigglerConfig.JitterPercentage != 0 { if jigglerEnabled {
jitter := calculateJitterDuration(jobDelta) if time.Since(lastUserInput) > 20*time.Second {
time.Sleep(jitter) //TODO: change to rel mouse
} err := rpcAbsMouseReport(1, 1, 0)
inactivitySeconds := config.JigglerConfig.InactivityLimitSeconds if err != nil {
timeSinceLastInput := time.Since(gadget.GetLastUserInputTime()) logger.Warn().Err(err).Msg("Failed to jiggle mouse")
logger.Debug().Msgf("Time since last user input %v", timeSinceLastInput) }
if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second { err = rpcAbsMouseReport(0, 0, 0)
logger.Debug().Msg("Jiggling mouse...") if err != nil {
//TODO: change to rel mouse logger.Warn().Err(err).Msg("Failed to reset mouse position")
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,30 +13,29 @@ 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]any `json:"params,omitempty"` Params map[string]interface{} `json:"params,omitempty"`
ID any `json:"id,omitempty"` ID interface{} `json:"id,omitempty"`
} }
type JSONRPCResponse struct { type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
Result any `json:"result,omitempty"` Result interface{} `json:"result,omitempty"`
Error any `json:"error,omitempty"` Error interface{} `json:"error,omitempty"`
ID any `json:"id"` ID interface{} `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 any `json:"params,omitempty"` Params interface{} `json:"params,omitempty"`
} }
type DisplayRotationSettings struct { type DisplayRotationSettings struct {
@ -62,7 +61,7 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
} }
} }
func writeJSONRPCEvent(event string, params any, session *Session) { func writeJSONRPCEvent(event string, params interface{}, session *Session) {
request := JSONRPCEvent{ request := JSONRPCEvent{
JSONRPC: "2.0", JSONRPC: "2.0",
Method: event, Method: event,
@ -103,7 +102,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
errorResponse := JSONRPCResponse{ errorResponse := JSONRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
Error: map[string]any{ Error: map[string]interface{}{
"code": -32700, "code": -32700,
"message": "Parse error", "message": "Parse error",
}, },
@ -124,7 +123,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]any{ Error: map[string]interface{}{
"code": -32601, "code": -32601,
"message": "Method not found", "message": "Method not found",
}, },
@ -134,12 +133,13 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return return
} }
result, err := callRPCHandler(scopedLogger, handler, request.Params) scopedLogger.Trace().Msg("Calling RPC handler")
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]any{ Error: map[string]interface{}{
"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]any{"quality_factor": factor}) var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"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]any{"edid": edid}) _, err := CallCtrlAction("set_edid", map[string]interface{}{"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 any Func interface{}
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(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) { func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result interface{}, 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(logger zerolog.Logger, handler RPCHandler, params map[string
}() }()
// Call the handler // Call the handler
result, err = riskyCallRPCHandler(logger, handler, params) result, err = riskyCallRPCHandler(handler, params)
return result, err // do not combine these two lines into one, as it breaks the above defer function's setting of err return result, err
} }
func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (any, error) { func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
handlerValue := reflect.ValueOf(handler.Func) handlerValue := reflect.ValueOf(handler.Func)
handlerType := handlerValue.Type() handlerType := handlerValue.Type()
@ -499,24 +499,20 @@ func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[s
} }
numParams := handlerType.NumIn() numParams := handlerType.NumIn()
paramNames := handler.Params // Get the parameter names from the RPCHandler args := make([]reflect.Value, numParams)
// Get the parameter names from the RPCHandler
paramNames := handler.Params
if len(paramNames) != numParams { if len(paramNames) != numParams {
err := fmt.Errorf("mismatch between handler parameters (%d) and defined parameter names (%d)", numParams, len(paramNames)) return nil, errors.New("mismatch between handler parameters and defined parameter names")
logger.Error().Strs("paramNames", paramNames).Err(err).Msg("Cannot call RPC handler")
return nil, err
} }
args := make([]reflect.Value, numParams) for i := 0; i < numParams; i++ {
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 {
err := fmt.Errorf("missing parameter: %s", paramName) return nil, errors.New("missing parameter: " + paramName)
logger.Error().Err(err).Msg("Cannot marshal arguments for RPC handler")
return nil, err
} }
convertedValue := reflect.ValueOf(paramValue) convertedValue := reflect.ValueOf(paramValue)
@ -533,7 +529,7 @@ func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[s
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 for parameter %s", intValue, paramName) return nil, fmt.Errorf("value out of range for uint8: %v", intValue)
} }
newSlice.Index(j).SetUint(uint64(intValue)) newSlice.Index(j).SetUint(uint64(intValue))
} else { } else {
@ -549,12 +545,12 @@ func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[s
} 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 for parameter %s", err, paramName) return nil, fmt.Errorf("failed to marshal map to JSON: %v", err)
} }
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 for parameter %s", err, paramName) return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err)
} }
args[i] = reflect.ValueOf(newStruct).Elem() args[i] = reflect.ValueOf(newStruct).Elem()
} else { } else {
@ -565,7 +561,6 @@ func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[s
} }
} }
logger.Trace().Msg("Calling RPC handler")
results := handlerValue.Call(args) results := handlerValue.Call(args)
if len(results) == 0 { if len(results) == 0 {
@ -573,32 +568,23 @@ func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[s
} }
if len(results) == 1 { if len(results) == 1 {
if ok, err := asError(results[0]); ok { if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
return nil, err if !results[0].IsNil() {
} 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
} }
return nil, fmt.Errorf("too many return values from handler: %d", len(results)) if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
} if !results[1].IsNil() {
return nil, results[1].Interface().(error)
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 results[0].Interface(), nil
} }
return false, nil
return nil, errors.New("unexpected return values from handler")
} }
func rpcSetMassStorageMode(mode string) (string, error) { func rpcSetMassStorageMode(mode string) (string, error) {
@ -937,7 +923,7 @@ func rpcSetKeyboardLayout(layout string) error {
return nil return nil
} }
func getKeyboardMacros() (any, error) { func getKeyboardMacros() (interface{}, error) {
macros := make([]KeyboardMacro, len(config.KeyboardMacros)) macros := make([]KeyboardMacro, len(config.KeyboardMacros))
copy(macros, config.KeyboardMacros) copy(macros, config.KeyboardMacros)
@ -945,10 +931,10 @@ func getKeyboardMacros() (any, error) {
} }
type KeyboardMacrosParams struct { type KeyboardMacrosParams struct {
Macros []any `json:"macros"` Macros []interface{} `json:"macros"`
} }
func setKeyboardMacros(params KeyboardMacrosParams) (any, error) { func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, 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")
} }
@ -956,7 +942,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (any, 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]any) macroMap, ok := item.(map[string]interface{})
if !ok { if !ok {
return nil, fmt.Errorf("invalid macro at index %d", i) return nil, fmt.Errorf("invalid macro at index %d", i)
} }
@ -974,16 +960,16 @@ func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
} }
steps := []KeyboardMacroStep{} steps := []KeyboardMacroStep{}
if stepsArray, ok := macroMap["steps"].([]any); ok { if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
for _, stepItem := range stepsArray { for _, stepItem := range stepsArray {
stepMap, ok := stepItem.(map[string]any) stepMap, ok := stepItem.(map[string]interface{})
if !ok { if !ok {
continue continue
} }
step := KeyboardMacroStep{} step := KeyboardMacroStep{}
if keysArray, ok := stepMap["keys"].([]any); ok { if keysArray, ok := stepMap["keys"].([]interface{}); 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)
@ -991,7 +977,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
} }
} }
if modsArray, ok := stepMap["modifiers"].([]any); ok { if modsArray, ok := stepMap["modifiers"].([]interface{}); 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)
@ -1061,8 +1047,6 @@ 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"}},
@ -1072,9 +1056,6 @@ 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"}},
@ -1103,6 +1084,7 @@ 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 ...any) error { func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
return logging.ErrorfL(l, format, err, args...) return logging.ErrorfL(l, format, err, args...)
} }

View File

@ -9,7 +9,6 @@ import (
"net" "net"
"os" "os"
"os/exec" "os/exec"
"strings"
"sync" "sync"
"time" "time"
@ -21,18 +20,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]any `json:"params,omitempty"` Params map[string]interface{} `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]any `json:"result,omitempty"` Result map[string]interface{} `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)
@ -48,7 +47,7 @@ var (
nativeCmdLock = &sync.Mutex{} nativeCmdLock = &sync.Mutex{}
) )
func CallCtrlAction(action string, params map[string]any) (*CtrlResponse, error) { func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
ctrlAction := CtrlAction{ ctrlAction := CtrlAction{
@ -367,22 +366,6 @@ 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 {
@ -390,7 +373,7 @@ func ensureBinaryUpdated(destPath string) error {
} }
defer srcFile.Close() defer srcFile.Close()
srcHash, err := getNativeSha256() srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
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
@ -429,7 +412,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]any{"edid": config.EdidString}) _, err := CallCtrlAction("set_edid", map[string]interface{}{"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,16 +19,6 @@ 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,10 +50,6 @@ 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 {
@ -93,14 +89,7 @@ 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)
} }
transport := http.DefaultTransport.(*http.Transport).Clone() resp, err := http.DefaultClient.Do(req)
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)
} }
@ -146,7 +135,6 @@ 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(),

65
remote_mount.go Normal file
View File

@ -0,0 +1,65 @@
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,7 +128,6 @@ 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
} }
@ -207,9 +206,6 @@ 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,10 +66,6 @@ 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,14 +27,7 @@
/> />
<title>JetKVM</title> <title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" /> <link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> <link rel="icon" href="/favicon.png" />
<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(

1546
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": "2025.08.25.2300", "version": "0.0.0",
"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.7", "@headlessui/react": "^2.2.3",
"@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.1.0", "@vitejs/plugin-basic-ssl": "^2.0.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.4", "cva": "^1.0.0-beta.3",
"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.4", "focus-trap-react": "^11.0.3",
"framer-motion": "^12.23.12", "framer-motion": "^12.11.4",
"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.1", "react": "^19.1.0",
"react-animate-height": "^3.2.3", "react-animate-height": "^3.2.3",
"react-dom": "^19.1.1", "react-dom": "^19.1.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.5.2",
"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.115", "react-simple-keyboard": "^3.8.72",
"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.1", "tailwind-merge": "^3.3.0",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"validator": "^13.15.15", "validator": "^13.15.0",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.3.2", "@eslint/compat": "^1.2.9",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.34.0", "@eslint/js": "^9.26.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.12", "@tailwindcss/postcss": "^4.1.7",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.12", "@tailwindcss/vite": "^4.1.7",
"@types/react": "^19.1.11", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.8", "@types/react-dom": "^19.1.5",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/validator": "^13.15.2", "@types/validator": "^13.15.0",
"@typescript-eslint/eslint-plugin": "^8.41.0", "@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.41.0", "@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react-swc": "^3.10.2", "@vitejs/plugin-react-swc": "^3.9.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.34.0", "eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.31.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.3.0", "globals": "^16.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.3",
"prettier": "^3.6.2", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.1.12", "tailwindcss": "^4.1.7",
"typescript": "^5.9.2", "typescript": "^5.8.3",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,21 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -26,13 +26,17 @@ export default function Actionbar({
requestFullscreen: () => Promise<void>; requestFullscreen: () => Promise<void>;
}) { }) {
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
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(); const developerMode = useSettingsStore(state => state.developerMode);
// 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
@ -43,13 +47,13 @@ export default function Actionbar({
isOpen.current = open; isOpen.current = open;
if (!open) { if (!open) {
setTimeout(() => { setTimeout(() => {
setDisableVideoFocusTrap(false); setDisableFocusTrap(false);
console.debug("Popover is closing. Returning focus trap to video"); console.log("Popover is closing. Returning focus trap to video");
}, 0); }, 0);
} }
} }
}, },
[setDisableVideoFocusTrap], [setDisableFocusTrap],
); );
return ( return (
@ -77,7 +81,7 @@ export default function Actionbar({
text="Paste text" text="Paste text"
LeadingIcon={MdOutlineContentPasteGo} LeadingIcon={MdOutlineContentPasteGo}
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableFocusTrap(true);
}} }}
/> />
</PopoverButton> </PopoverButton>
@ -119,7 +123,7 @@ export default function Actionbar({
); );
}} }}
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableFocusTrap(true);
}} }}
/> />
</PopoverButton> </PopoverButton>
@ -150,7 +154,7 @@ export default function Actionbar({
theme="light" theme="light"
text="Wake on LAN" text="Wake on LAN"
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableFocusTrap(true);
}} }}
LeadingIcon={({ className }) => ( LeadingIcon={({ className }) => (
<svg <svg
@ -200,7 +204,7 @@ export default function Actionbar({
theme="light" theme="light"
text="Virtual Keyboard" text="Virtual Keyboard"
LeadingIcon={FaKeyboard} LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/> />
</div> </div>
</div> </div>
@ -214,7 +218,7 @@ export default function Actionbar({
text="Extension" text="Extension"
LeadingIcon={LuCable} LeadingIcon={LuCable}
onClick={() => { onClick={() => {
setDisableVideoFocusTrap(true); setDisableFocusTrap(true);
}} }}
/> />
</PopoverButton> </PopoverButton>
@ -239,7 +243,7 @@ export default function Actionbar({
theme="light" theme="light"
text="Virtual Keyboard" text="Virtual Keyboard"
LeadingIcon={FaKeyboard} LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/> />
</div> </div>
<div className="hidden md:block"> <div className="hidden md:block">
@ -264,10 +268,7 @@ export default function Actionbar({
theme="light" theme="light"
text="Settings" text="Settings"
LeadingIcon={LuSettings} LeadingIcon={LuSettings}
onClick={() => { onClick={() => navigateTo("/settings")}
setDisableVideoFocusTrap(true);
navigateTo("/settings")
}}
/> />
</div> </div>

View File

@ -27,7 +27,7 @@ export default function FieldLabel({
> >
{label} {label}
{description && ( {description && (
<span className="mb-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400"> <span className="my-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-semibold leading-snug text-black dark:text-white"> <span className="font-display text-[13px] font-medium leading-snug text-black dark:text-white">
{label} {label}
</span> </span>
{description && ( {description && (
<span className="mb-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400"> <span className="my-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(); const usbState = useHidStore(state => state.usbState);
// for testing // for testing
//userEmail = "user@example.org"; //userEmail = "user@example.org";

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { import {
@ -7,68 +7,65 @@ 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 { keysDownState } = useHidStore(); const activeKeys = useHidStore(state => state.activeKeys);
const { mouseX, mouseY, mouseMove } = useMouseStore(); const activeModifiers = useHidStore(state => state.activeModifiers);
const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY);
const mouseMove = useMouseStore(state => state.mouseMove);
const videoClientSize = useVideoStore( const videoClientSize = useVideoStore(
(state: VideoState) => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
); );
const videoSize = useVideoStore( const videoSize = useVideoStore(
(state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`, state => `${Math.round(state.width)}x${Math.round(state.height)}`,
); );
const { rpcDataChannel } = useRTCStore(); const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
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: Event) => rpcDataChannel.onerror = e =>
console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]); }, [rpcDataChannel]);
const { keyboardLedState, usbState } = useHidStore(); const keyboardLedState = useHidStore(state => state.keyboardLedState);
const { isTurnServerInUse } = useRTCStore(); const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const { hdmiState } = useVideoStore(); const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const displayKeys = useMemo(() => { const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
if (!showPressedKeys)
return "";
const activeModifierMask = keysDownState.modifier || 0; const usbState = useHidStore(state => state.usbState);
const keysDown = keysDownState.keys || []; const hdmiState = useVideoStore(state => state.hdmiState);
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">
{debugMode ? ( {settings.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}
{debugMode ? ( {settings.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}
{(debugMode && mouseMode == "absolute") ? ( {(settings.debugMode && settings.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">
@ -77,7 +74,7 @@ export default function InfoBar() {
</div> </div>
) : null} ) : null}
{(debugMode && mouseMode == "relative") ? ( {(settings.debugMode && settings.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">
@ -88,13 +85,13 @@ export default function InfoBar() {
</div> </div>
) : null} ) : null}
{debugMode && ( {settings.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>
)} )}
{debugMode && ( {settings.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>
@ -105,7 +102,14 @@ 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>
)} )}
@ -118,10 +122,23 @@ 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",
)} )}
@ -131,7 +148,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",
)} )}
@ -141,28 +158,23 @@ 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?: React.ReactNode | string | null; description?: string | null;
}; };
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField( const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(

View File

@ -1,176 +0,0 @@
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,18 +1,17 @@
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 FieldLabel from "@/components/FieldLabel";
import Fieldset from "@/components/Fieldset";
import { InputFieldWithLabel, FieldError } from "@/components/InputField"; import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import Fieldset from "@/components/Fieldset";
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 { KeySequence } from "@/hooks/stores"; import FieldLabel from "@/components/FieldLabel";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
interface ValidationErrors { interface ValidationErrors {
name?: string; name?: string;
@ -45,7 +44,6 @@ 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);
@ -236,7 +234,6 @@ 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,18 +1,23 @@
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 FieldLabel from "@/components/FieldLabel"; import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros"; import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
import { KeyboardLayout } from "@/keyboardLayouts"; import FieldLabel from "@/components/FieldLabel";
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"),
@ -62,7 +67,6 @@ 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[] => {
@ -80,22 +84,9 @@ export function MacroStepCard({
keyQuery, keyQuery,
onModifierChange, onModifierChange,
onDelayChange, onDelayChange,
isLastStep, isLastStep
keyboard
}: MacroStepCardProps) { }: MacroStepCardProps) {
const { keyDisplayMap } = keyboard; const getFilteredKeys = () => {
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));
@ -104,7 +95,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">
@ -213,7 +204,7 @@ export function MacroStepCard({
}} }}
displayValue={() => keyQuery} displayValue={() => keyQuery}
onInputChange={onKeyQueryChange} onInputChange={onKeyQueryChange}
options={() => filteredKeys} options={getFilteredKeys}
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-[36px] pl-3 pr-8 text-[13px]", SM: "h-[32px] 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} />} {label && <FieldLabel label={label} id={id} as="span" />}
<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

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

View File

@ -4,7 +4,9 @@ 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 { USBStates } from "@/hooks/stores"; import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"];
type StatusProps = Record< type StatusProps = Record<
USBStates, USBStates,
@ -65,7 +67,7 @@ export default function USBStateStatus({
}; };
const props = StatusCardProps[state]; const props = StatusCardProps[state];
if (!props) { if (!props) {
console.warn("Unsupported USB state: ", state); console.log("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 { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc"; import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications"; import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings"; import { SettingsItem } from "../routes/devices.$id.settings";
@ -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: JsonRpcResponse) => { send("getUsbDevices", {}, resp => {
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: JsonRpcResponse) => { send("setUsbDevices", { devices }, async resp => {
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(
(e: React.ChangeEvent<HTMLSelectElement>) => { async (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 { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc"; import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications"; import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings"; import { SettingsItem } from "../routes/devices.$id.settings";
@ -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: JsonRpcResponse) => { send("getUsbConfig", {}, resp => {
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: JsonRpcResponse) => { send("setUsbConfig", { usbConfig }, async resp => {
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", {}, (resp: JsonRpcResponse) => { send("getDeviceID", {}, async resp => {
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: JsonRpcResponse) => { send("getUsbConfig", {}, resp => {
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,3 +1,4 @@
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";
@ -12,10 +13,9 @@ 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, useUiStore } from "@/hooks/stores"; import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import useKeyboardLayout from "@/hooks/useKeyboardLayout"; import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
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,47 +26,34 @@ 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 { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = useUiStore(); const showAttachedVirtualKeyboard = useUiStore(
const { keysDownState, /* keyboardLedState,*/ isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); state => state.isAttachedVirtualKeyboardVisible,
const { handleKeyPress, executeMacro } = useKeyboard(); );
const { selectedKeyboard } = useKeyboardLayout(); const setShowAttachedVirtualKeyboard = useUiStore(
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 keyDisplayMap = useMemo(() => { const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
return selectedKeyboard.keyDisplayMap;
}, [selectedKeyboard]);
const virtualKeyboard = useMemo(() => { // HID related states
return selectedKeyboard.virtualKeyboard; const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
}, [selectedKeyboard]); const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = useMemo(() =>
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
[keyboardLedSync, keyboardLedStateSyncAvailable],
);
//const isCapsLockActive = useMemo(() => { const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
// 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;
@ -136,69 +123,94 @@ 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(
async (key: string, e: MouseEvent | undefined) => { (key: string) => {
e?.preventDefault(); const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
e?.stopPropagation(); const isKeyCaps = key === "CapsLock";
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") {
await executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return; return;
} }
if (key === "AltMetaEscape") { if (key === "AltMetaEscape") {
await executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]); sendKeyboardEvent(
[keys["Escape"]],
[modifiers["MetaLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return; return;
} }
if (key === "CtrlAltBackspace") { if (key === "CtrlAltBackspace") {
await executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); sendKeyboardEvent(
[keys["Backspace"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return; return;
} }
// if they press any of the latching keys, we send a keypress down event and the release it automatically (on timer) if (isKeyShift || isKeyCaps) {
if (latchingKeys.includes(key)) { toggleLayout();
console.debug(`Latching key pressed: ${key} sending down and delayed up pair`);
handleKeyPress(keys[key], true) if (isCapsLockActive) {
setTimeout(() => handleKeyPress(keys[key], false), 100); if (!isKeyboardLedManagedByHost) {
return; setIsCapsLockActive(false);
}
sendKeyboardEvent([keys["CapsLock"]], []);
return;
}
} }
// 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 // Handle caps lock state change
if (Object.keys(modifiers).includes(key)) { if (isKeyCaps && !isKeyboardLedManagedByHost) {
const currentlyDown = keyNamesForDownKeys.includes(key); setIsCapsLockActive(!isCapsLockActive);
console.debug(`Dynamic key pressed: ${key} was currently down: ${currentlyDown}, toggling state`);
handleKeyPress(keys[key], !currentlyDown)
return;
} }
// otherwise, just treat it as a down+up pair // Collect new active keys and modifiers
const cleanKey = key.replace(/[()]/g, ""); const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
console.debug(`Regular key pressed: ${cleanKey} sending down and up pair`); const newModifiers =
handleKeyPress(keys[cleanKey], true); keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
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);
}, },
[executeMacro, handleKeyPress, keyNamesForDownKeys], [isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
); );
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: isVirtualKeyboardEnabled ? "0px" : `-${350}px`, marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
}} }}
> >
<AnimatePresence> <AnimatePresence>
{isVirtualKeyboardEnabled && ( {virtualKeyboard && (
<motion.div <motion.div
initial={{ opacity: 0, y: "100%" }} initial={{ opacity: 0, y: "100%" }}
animate={{ opacity: 1, y: "0%" }} animate={{ opacity: 1, y: "0%" }}
@ -210,30 +222,30 @@ function KeyboardWrapper() {
> >
<div <div
className={cx( className={cx(
!isAttachedVirtualKeyboardVisible !showAttachedVirtualKeyboard
? "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={{
...(!isAttachedVirtualKeyboardVisible ...(!showAttachedVirtualKeyboard
? { 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": isAttachedVirtualKeyboardVisible, "rounded-none": showAttachedVirtualKeyboard,
})} })}
> >
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800"> <div className="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">
{isAttachedVirtualKeyboardVisible ? ( {showAttachedVirtualKeyboard ? (
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Detach" text="Detach"
onClick={() => setAttachedVirtualKeyboardVisibility(false)} onClick={() => setShowAttachedVirtualKeyboard(false)}
/> />
) : ( ) : (
<Button <Button
@ -241,7 +253,7 @@ function KeyboardWrapper() {
theme="light" theme="light"
text="Attach" text="Attach"
LeadingIcon={AttachIcon} LeadingIcon={AttachIcon}
onClick={() => setAttachedVirtualKeyboardVisibility(true)} onClick={() => setShowAttachedVirtualKeyboard(true)}
/> />
)} )}
</div> </div>
@ -254,7 +266,7 @@ function KeyboardWrapper() {
theme="light" theme="light"
text="Hide" text="Hide"
LeadingIcon={ChevronDownIcon} LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboardEnabled(false)} onClick={() => setVirtualKeyboard(false)}
/> />
</div> </div>
</div> </div>
@ -263,61 +275,66 @@ 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={mainLayoutName} layoutName={layoutName}
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={virtualKeyboard.main} layout={{
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}
enableLayoutCandidates={false} syncInstanceInputs={true}
preventMouseDownDefault={true} debug={false}
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="default" layoutName={layoutName}
onKeyPress={onKeyDown} onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
display={keyDisplayMap} display={keyDisplayMap}
layout={virtualKeyboard.control} layout={{
disableButtonHold={true} default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
enableLayoutCandidates={false} shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
preventMouseDownDefault={true} }}
preventMouseUpDefault={true} syncInstanceInputs={true}
stopMouseDownPropagation={true} debug={false}
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={virtualKeyboard.arrows} layout={{
disableButtonHold={true} default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
enableLayoutCandidates={false} }}
preventMouseDownDefault={true} syncInstanceInputs={true}
preventMouseUpDefault={true} debug={false}
stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
/> />
</div> </div>
{ /* TODO add optional number pad */ }
</div> </div>
</div> </div>
</Card> </Card>

View File

@ -9,8 +9,9 @@ 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 } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import { import {
useHidStore,
useMouseStore, useMouseStore,
useRTCStore, useRTCStore,
useSettingsStore, useSettingsStore,
@ -27,14 +28,15 @@ 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, peerConnectionState } = useRTCStore(); const mediaStream = useRTCStore(state => state.mediaStream);
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 { handleKeyPress, resetKeyboardState } = useKeyboard(); const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const { setMousePosition, setMouseMove } = useMouseStore(); const setMousePosition = useMouseStore(state => state.setMousePosition);
const setMouseMove = useMouseStore(state => state.setMouseMove);
const { const {
setClientSize: setVideoClientSize, setClientSize: setVideoClientSize,
setSize: setVideoSize, setSize: setVideoSize,
@ -42,39 +44,49 @@ 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, videoBrightness, videoContrast } = useSettingsStore(); const videoSaturation = useSettingsStore(state => state.videoSaturation);
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(); const peerConnection = useRTCStore(state => state.peerConnection);
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: handleResize, onResize: ({ width, height }) => {
// 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(
@ -95,15 +107,15 @@ export default function WebRTCVideo() {
function updateVideoSizeOnMount() { function updateVideoSizeOnMount() {
if (videoElm.current) updateVideoSizeStore(videoElm.current); if (videoElm.current) updateVideoSizeStore(videoElm.current);
}, },
[updateVideoSizeStore], [setVideoClientSize, updateVideoSizeStore, setVideoSize],
); );
// 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 || !navigator.permissions || !navigator.permissions.query) { if (!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
} }
@ -137,31 +149,29 @@ 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 && navigator && "keyboard" in navigator) { if (isKeyboardLockGranted && "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, setIsKeyboardLockActive]); }, [checkNavigatorPermissions]);
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 (navigator && "keyboard" in navigator) { if ("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;
@ -187,7 +197,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
@ -344,58 +354,153 @@ export default function WebRTCVideo() {
sendAbsMouseMovement(0, 0, 0); sendAbsMouseMovement(0, 0, 0);
}, [sendAbsMouseMovement]); }, [sendAbsMouseMovement]);
const keyDownHandler = useCallback( // Keyboard-related
(e: KeyboardEvent) => { const handleModifierKeys = useCallback(
e.preventDefault(); (e: KeyboardEvent, activeModifiers: number[]) => {
const code = getAdjustedKeyCode(e); const { shiftKey, ctrlKey, altKey, metaKey } = e;
const hidKey = keys[code];
if (hidKey === undefined) { const filteredModifiers = activeModifiers.filter(Boolean);
console.warn(`Key down not mapped: ${code}`);
return; // 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(
async (e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
let code = e.code;
const key = e.key;
if (!isKeyboardLedManagedByHost) {
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
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 && hidKey < 0xE0) { if (e.metaKey) {
setTimeout(() => { setTimeout(() => {
console.debug(`Forcing the meta key release of associated key: ${hidKey}`); const prev = useHidStore.getState();
handleKeyPress(hidKey, false); sendKeyboardEvent([], newModifiers || prev.activeModifiers);
}, 10); }, 10);
} }
console.debug(`Key down: ${hidKey}`);
handleKeyPress(hidKey, true); sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
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(
async (e: KeyboardEvent) => { (e: KeyboardEvent) => {
e.preventDefault(); e.preventDefault();
const code = getAdjustedKeyCode(e); const prev = useHidStore.getState();
const hidKey = keys[code];
if (hidKey === undefined) { if (!isKeyboardLedManagedByHost) {
console.warn(`Key up not mapped: ${code}`); setIsNumLockActive(e.getModifierState("NumLock"));
return; setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
} }
console.debug(`Key up: ${hidKey}`); // Filtering out the key that was just released (keys[e.code])
handleKeyPress(hidKey, false); const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
// 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) => {
@ -406,7 +511,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.debug("Force playing video"); console.log("Force playing video");
videoElm.current.play(); videoElm.current.play();
} }
} }
@ -449,7 +554,13 @@ 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
@ -505,7 +616,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,
@ -556,28 +667,6 @@ 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">
@ -610,48 +699,50 @@ 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 autoPlay={true}
controls={false} controls={false}
onPlaying={onVideoPlaying} onPlaying={onVideoPlaying}
onPlay={onVideoPlaying} onPlay={onVideoPlaying}
muted muted={true}
playsInline playsInline
disablePictureInPicture disablePictureInPicture
controlsList="nofullscreen" controlsList="nofullscreen"
style={videoStyle} style={{
className={cx( filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`,
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000", }}
{ className={cx(
"cursor-none": settings.isCursorHidden, "max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
"opacity-0": {
isVideoLoading || "cursor-none": settings.isCursorHidden,
hdmiError || "opacity-0":
peerConnectionState !== "connected", isVideoLoading ||
"opacity-60!": showPointerLockBar, hdmiError ||
"animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20": peerConnectionState !== "connected",
isPlaying, "opacity-60!": showPointerLockBar,
}, "animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":
)} isPlaying,
/> },
{peerConnection?.connectionState == "connected" && ( )}
<div />
style={{ animationDuration: "500ms" }} {peerConnection?.connectionState == "connected" && (
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center" <div
> style={{ animationDuration: "500ms" }}
<div className="relative h-full w-full rounded-md"> className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"
<LoadingVideoOverlay show={isVideoLoading} /> >
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} /> <div className="relative h-full w-full rounded-md">
<NoAutoplayPermissionsOverlay <LoadingVideoOverlay show={isVideoLoading} />
show={hasNoAutoPlayPermissions} <HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
onPlayClick={() => { <NoAutoplayPermissionsOverlay
videoElm.current?.play(); show={hasNoAutoPlayPermissions}
}} onPlayClick={() => {
/> videoElm.current?.play();
}}
/>
</div>
</div> </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 { JsonRpcResponse, useJsonRpc } from "../../hooks/useJsonRpc"; import { 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: JsonRpcResponse) => { send("getATXState", {}, resp => {
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: JsonRpcResponse) => { send("setATXPowerAction", { action: "power-long" }, resp => {
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: JsonRpcResponse) => { send("setATXPowerAction", { action: "power-short" }, resp => {
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: JsonRpcResponse) => { send("setATXPowerAction", { action: "reset" }, resp => {
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 { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { 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: JsonRpcResponse) => { send("getDCPowerState", {}, resp => {
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: JsonRpcResponse) => { send("setDCPowerState", { enabled }, resp => {
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: JsonRpcResponse) => { send("setDCRestoreState", { state }, resp => {
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 { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { 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: JsonRpcResponse) => { send("getSerialSettings", {}, resp => {
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: JsonRpcResponse) => { send("setSerialSettings", { settings: newSettings }, resp => {
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(); const setTerminalType = useUiStore(state => state.setTerminalType);
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 { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { 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: JsonRpcResponse) => { send("getActiveExtension", {}, resp => {
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: JsonRpcResponse) => { send("setActiveExtension", { extensionId: extension?.id || "" }, resp => {
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,6 +1,9 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { PlusCircleIcon } from "@heroicons/react/20/solid"; import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { forwardRef, useEffect, useCallback } from "react"; import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { import {
LuArrowUpFromLine,
LuCheckCheck,
LuLink, LuLink,
LuPlus, LuPlus,
LuRadioReceiver, LuRadioReceiver,
@ -11,19 +14,40 @@ 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 } from "@/hooks/stores"; import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { 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 { send } = useJsonRpc(); const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
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: JsonRpcResponse) => { send("getVirtualMediaState", {}, response => {
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}`,
@ -35,7 +59,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
}, [send, setRemoteVirtualMediaState]); }, [send, setRemoteVirtualMediaState]);
const handleUnmount = () => { const handleUnmount = () => {
send("unmountImage", {}, (response: JsonRpcResponse) => { send("unmountImage", {}, response => {
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 {
@ -70,6 +94,42 @@ 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="">
@ -142,7 +202,18 @@ 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."
/> />
<div {remoteVirtualMediaState?.source === "WebRTC" ? (
<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, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, 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,102 +7,104 @@ 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 { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { 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 { KeyStroke } from "@/keyboardLayouts"; import { layouts, chars } from "@/keyboardLayouts";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
import notifications from "@/notifications"; import notifications from "@/notifications";
const hidKeyboardPayload = (modifier: number, keys: number[]) => { const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { modifier, keys }; return { keys, modifier };
}; };
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 { setPasteModeEnabled } = useHidStore(); const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
const { setDisableVideoFocusTrap } = useUiStore(); const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { send } = useJsonRpc(); const [send] = useJsonRpc();
const { rpcDataChannel } = useRTCStore(); const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [invalidChars, setInvalidChars] = useState<string[]>([]); const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose(); const close = useClose();
const { setKeyboardLayout } = useSettingsStore(); const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const { selectedKeyboard } = useKeyboardLayout(); const setKeyboardLayout = useSettingsStore(
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: JsonRpcResponse) => { send("getKeyboardLayout", {}, resp => {
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(() => {
setPasteModeEnabled(false); setPasteMode(false);
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
setInvalidChars([]); setInvalidChars([]);
}, [setDisableVideoFocusTrap, setPasteModeEnabled]); }, [setDisableVideoFocusTrap, setPasteMode]);
const onConfirmPaste = useCallback(async () => { const onConfirmPaste = useCallback(async () => {
setPasteModeEnabled(false); setPasteMode(false);
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return; if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
if (!selectedKeyboard) return; if (!safeKeyboardLayout) 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 keyprops = selectedKeyboard.chars[char]; const { key, shift, altRight, deadKey, accentKey } = chars[safeKeyboardLayout][char]
if (!keyprops) continue;
const { key, shift, altRight, deadKey, accentKey } = keyprops;
if (!key) continue; if (!key) continue;
// if this is an accented character, we need to send that accent FIRST const keyz = [ keys[key] ];
if (accentKey) { const modz = [ modifierCode(shift, altRight) ];
await sendKeystroke({modifier: modifierCode(accentKey.shift, accentKey.altRight), keys: [ keys[accentKey.key] ] })
}
// now send the actual key
await sendKeystroke({ modifier: modifierCode(shift, altRight), keys: [ keys[key] ]});
// if what was requested was a dead key, we need to send an unmodified space to emit
// just the accent character
if (deadKey) { if (deadKey) {
await sendKeystroke({ modifier: noModifier, keys: [ keys["Space"] ] }); keyz.push(keys["Space"]);
modz.push(noModifier);
}
if (accentKey) {
keyz.unshift(keys[accentKey.key])
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
} }
// now send a message with no keys down to "release" the keys for (const [index, kei] of keyz.entries()) {
await sendKeystroke({ modifier: 0, keys: [] }); await new Promise<void>((resolve, reject) => {
send(
"keyboardReport",
hidKeyboardPayload([kei], modz[index]),
params => {
if ("error" in params) return reject(params.error);
send("keyboardReport", hidKeyboardPayload([], 0), params => {
if ("error" in params) return reject(params.error);
resolve();
});
},
);
});
}
} }
} catch (error) { } catch (error) {
console.error("Failed to paste text:", error); console.error(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) {
@ -152,7 +154,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 => !selectedKeyboard.chars[char]), .filter(char => !chars[safeKeyboardLayout][char]),
), ),
]; ];
@ -173,7 +175,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: {selectedKeyboard.isoCode}-{selectedKeyboard.name} Sending text using keyboard layout: {layouts[safeKeyboardLayout]}
</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 { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores"; import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
@ -14,24 +14,26 @@ 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 { setDisableVideoFocusTrap } = useUiStore(); const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { rpcDataChannel } = useRTCStore();
const { send } = useJsonRpc(); const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
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();
}, [close, setDisableVideoFocusTrap]); setDisableFocusTrap(false);
}, [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: JsonRpcResponse) => { send("sendWOLMagicPacket", { macAddress }, resp => {
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) {
@ -41,16 +43,16 @@ export default function WakeOnLanModal() {
} }
} else { } else {
notifications.success("Magic Packet sent successfully"); notifications.success("Magic Packet sent successfully");
setDisableVideoFocusTrap(false); setDisableFocusTrap(false);
close(); close();
} }
}); });
}, },
[close, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap], [close, rpcDataChannel?.readyState, send, setDisableFocusTrap],
); );
const syncStoredDevices = useCallback(() => { const syncStoredDevices = useCallback(() => {
send("getWakeOnLanDevices", {}, (resp: JsonRpcResponse) => { send("getWakeOnLanDevices", {}, resp => {
if ("result" in resp) { if ("result" in resp) {
setStoredDevices(resp.result as StoredDevice[]); setStoredDevices(resp.result as StoredDevice[]);
} else { } else {
@ -68,7 +70,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: JsonRpcResponse) => { send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
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 {
@ -76,7 +78,7 @@ export default function WakeOnLanModal() {
} }
}); });
}, },
[send, storedDevices, syncStoredDevices], [storedDevices, send, syncStoredDevices],
); );
const onAddDevice = useCallback( const onAddDevice = useCallback(
@ -84,7 +86,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: JsonRpcResponse) => { send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
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,18 +37,10 @@ function createChartArray<T, K extends keyof T>(
} }
export default function ConnectionStatsSidebar() { export default function ConnectionStatsSidebar() {
const { sidebarView, setSidebarView } = useUiStore(); const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
const {
mediaStream, const candidatePairStats = useRTCStore(state => state.candidatePairStats);
peerConnection, const setSidebarView = useUiStore(state => state.setSidebarView);
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>,
@ -57,6 +49,20 @@ 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;
@ -74,7 +80,8 @@ 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;
} }
export interface UserState { interface UserState {
user: User | null; user: User | null;
setUser: (user: User | null) => void; setUser: (user: User | null) => void;
} }
export interface UIState { interface UIState {
sidebarView: AvailableSidebarViews | null; sidebarView: AvailableSidebarViews | null;
setSidebarView: (view: AvailableSidebarViews | null) => void; setSidebarView: (view: AvailableSidebarViews | null) => void;
@ -68,21 +68,21 @@ export interface UIState {
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void; setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
terminalType: AvailableTerminalTypes; terminalType: AvailableTerminalTypes;
setTerminalType: (type: UIState["terminalType"]) => void; setTerminalType: (enabled: UIState["terminalType"]) => void;
} }
export const useUiStore = create<UIState>(set => ({ export const useUiStore = create<UIState>(set => ({
terminalType: "none", terminalType: "none",
setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }), setTerminalType: type => set({ terminalType: type }),
sidebarView: null, sidebarView: null,
setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }), setSidebarView: view => set({ sidebarView: view }),
disableVideoFocusTrap: false, disableVideoFocusTrap: false,
setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }), setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
isWakeOnLanModalVisible: false, isWakeOnLanModalVisible: false,
setWakeOnLanModalVisibility: (enabled: boolean) => set({ isWakeOnLanModalVisible: enabled }), setWakeOnLanModalVisibility: enabled => set({ isWakeOnLanModalVisible: enabled }),
toggleSidebarView: view => toggleSidebarView: view =>
set(state => { set(state => {
@ -94,17 +94,20 @@ export const useUiStore = create<UIState>(set => ({
}), }),
isAttachedVirtualKeyboardVisible: true, isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => setAttachedVirtualKeyboardVisibility: enabled =>
set({ isAttachedVirtualKeyboardVisible: enabled }), set({ isAttachedVirtualKeyboardVisible: enabled }),
})); }));
export interface RTCState { 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;
@ -115,18 +118,18 @@ export interface RTCState {
setMediaStream: (stream: MediaStream) => void; setMediaStream: (stream: MediaStream) => void;
videoStreamStats: RTCInboundRtpStreamStats | null; videoStreamStats: RTCInboundRtpStreamStats | null;
appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => void; appendVideoStreamStats: (state: 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: (stats: RTCInboundRtpStreamStats) => void; appendInboundRtpStats: (state: RTCInboundRtpStreamStats) => void;
clearInboundRtpStats: () => void; clearInboundRtpStats: () => void;
candidatePairStats: Map<number, RTCIceCandidatePairStats>; candidatePairStats: Map<number, RTCIceCandidatePairStats>;
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => void; appendCandidatePairStats: (pair: 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
@ -138,7 +141,7 @@ export 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: (stats: RTCDataChannelStats) => void; appendDiskDataChannelStats: (stat: RTCDataChannelStats) => void;
terminalChannel: RTCDataChannel | null; terminalChannel: RTCDataChannel | null;
setTerminalChannel: (channel: RTCDataChannel) => void; setTerminalChannel: (channel: RTCDataChannel) => void;
@ -149,78 +152,81 @@ export interface RTCState {
export const useRTCStore = create<RTCState>(set => ({ export const useRTCStore = create<RTCState>(set => ({
peerConnection: null, peerConnection: null,
setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }), setPeerConnection: pc => set({ peerConnection: pc }),
rpcDataChannel: null, rpcDataChannel: null,
setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }), setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
hidDataChannel: null, hidDataChannel: null,
setHidDataChannel: channel => set({ hidDataChannel: channel }), setHidDataChannel: channel => set({ hidDataChannel: channel }),
transceiver: null, transceiver: null,
setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }), setTransceiver: transceiver => set({ transceiver }),
peerConnectionState: null, peerConnectionState: null,
setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }), setPeerConnectionState: state => set({ peerConnectionState: state }),
diskChannel: null,
setDiskChannel: channel => set({ diskChannel: channel }),
mediaStream: null, mediaStream: null,
setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }), setMediaStream: stream => set({ mediaStream: stream }),
videoStreamStats: null, videoStreamStats: null,
appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }), appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
videoStreamStatsHistory: new Map(), videoStreamStatsHistory: new Map(),
isTurnServerInUse: false, isTurnServerInUse: false,
setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }), setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
inboundRtpStats: new Map(), inboundRtpStats: new Map(),
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => { appendInboundRtpStats: newStat => {
set(prevState => ({ set(prevState => ({
inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats), inboundRtpStats: appendStatToMap(newStat, prevState.inboundRtpStats),
})); }));
}, },
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }), clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
candidatePairStats: new Map(), candidatePairStats: new Map(),
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => { appendCandidatePairStats: newStat => {
set(prevState => ({ set(prevState => ({
candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats), candidatePairStats: appendStatToMap(newStat, prevState.candidatePairStats),
})); }));
}, },
clearCandidatePairStats: () => set({ candidatePairStats: new Map() }), clearCandidatePairStats: () => set({ candidatePairStats: new Map() }),
localCandidateStats: new Map(), localCandidateStats: new Map(),
appendLocalCandidateStats: (stats: RTCIceCandidateStats) => { appendLocalCandidateStats: newStat => {
set(prevState => ({ set(prevState => ({
localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats), localCandidateStats: appendStatToMap(newStat, prevState.localCandidateStats),
})); }));
}, },
remoteCandidateStats: new Map(), remoteCandidateStats: new Map(),
appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => { appendRemoteCandidateStats: newStat => {
set(prevState => ({ set(prevState => ({
remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats), remoteCandidateStats: appendStatToMap(newStat, prevState.remoteCandidateStats),
})); }));
}, },
diskDataChannelStats: new Map(), diskDataChannelStats: new Map(),
appendDiskDataChannelStats: (stats: RTCDataChannelStats) => { appendDiskDataChannelStats: newStat => {
set(prevState => ({ set(prevState => ({
diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats), diskDataChannelStats: appendStatToMap(newStat, prevState.diskDataChannelStats),
})); }));
}, },
// Add these new properties to the store implementation // Add these new properties to the store implementation
terminalChannel: null, terminalChannel: null,
setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }), setTerminalChannel: channel => set({ terminalChannel: channel }),
})); }));
export interface MouseMove { interface MouseMove {
x: number; x: number;
y: number; y: number;
buttons: number; buttons: number;
} }
export interface MouseState { interface MouseState {
mouseX: number; mouseX: number;
mouseY: number; mouseY: number;
mouseMove?: MouseMove; mouseMove?: MouseMove;
@ -232,17 +238,9 @@ 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: number, y: number) => set({ mouseX: x, mouseY: y }), setMousePosition: (x, y) => 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;
@ -250,13 +248,19 @@ 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: HdmiStates; hdmiState: "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting";
setHdmiState: (state: { setHdmiState: (state: {
ready: boolean; ready: boolean;
error?: HdmiErrorStates; error?: Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">;
}) => 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,
@ -265,13 +269,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: number, clientHeight: number) => set({ clientWidth, clientHeight }), setClientSize: (clientWidth, clientHeight) => set({ clientWidth, clientHeight }),
// Resolution // Resolution
setSize: (width: number, height: number) => set({ width, height }), setSize: (width, height) => set({ width, height }),
hdmiState: "connecting", hdmiState: "connecting",
setHdmiState: (state: HdmiState) => { setHdmiState: state => {
if (!state) return; if (!state) return;
const { ready, error } = state; const { ready, error } = state;
@ -285,13 +289,9 @@ export const useVideoStore = create<VideoState>(set => ({
}, },
})); }));
export interface BacklightSettings { export type KeyboardLedSync = "auto" | "browser" | "host";
max_brightness: number;
dim_after: number;
off_after: number;
}
export interface SettingsState { interface SettingsState {
isCursorHidden: boolean; isCursorHidden: boolean;
setCursorVisibility: (enabled: boolean) => void; setCursorVisibility: (enabled: boolean) => void;
@ -314,6 +314,9 @@ export 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;
@ -333,17 +336,17 @@ export const useSettingsStore = create(
persist<SettingsState>( persist<SettingsState>(
set => ({ set => ({
isCursorHidden: false, isCursorHidden: false,
setCursorVisibility: (enabled: boolean) => set({ isCursorHidden: enabled }), setCursorVisibility: enabled => set({ isCursorHidden: enabled }),
mouseMode: "absolute", mouseMode: "absolute",
setMouseMode: (mode: string) => set({ mouseMode: mode }), setMouseMode: mode => set({ mouseMode: mode }),
debugMode: import.meta.env.DEV, debugMode: import.meta.env.DEV,
setDebugMode: (enabled: boolean) => set({ debugMode: enabled }), setDebugMode: enabled => set({ debugMode: enabled }),
// Add developer mode with default value // Add developer mode with default value
developerMode: false, developerMode: false,
setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }), setDeveloperMode: enabled => set({ developerMode: enabled }),
displayRotation: "270", displayRotation: "270",
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }), setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
@ -357,21 +360,24 @@ export const useSettingsStore = create(
set({ backlightSettings: settings }), set({ backlightSettings: settings }),
keyboardLayout: "en-US", keyboardLayout: "en-US",
setKeyboardLayout: (layout: string) => set({ keyboardLayout: layout }), setKeyboardLayout: layout => set({ keyboardLayout: layout }),
keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
scrollThrottling: 0, scrollThrottling: 0,
setScrollThrottling: (value: number) => set({ scrollThrottling: value }), setScrollThrottling: value => set({ scrollThrottling: value }),
showPressedKeys: true, showPressedKeys: true,
setShowPressedKeys: (show: boolean) => set({ showPressedKeys: show }), setShowPressedKeys: show => 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: number) => set({ videoSaturation: value }), setVideoSaturation: value => set({ videoSaturation: value }),
videoBrightness: 1.0, videoBrightness: 1.0,
setVideoBrightness: (value: number) => set({ videoBrightness: value }), setVideoBrightness: value => set({ videoBrightness: value }),
videoContrast: 1.0, videoContrast: 1.0,
setVideoContrast: (value: number) => set({ videoContrast: value }), setVideoContrast: value => set({ videoContrast: value }),
}), }),
{ {
name: "settings", name: "settings",
@ -381,7 +387,7 @@ export const useSettingsStore = create(
); );
export interface RemoteVirtualMediaState { export interface RemoteVirtualMediaState {
source: "HTTP" | "Storage" | null; source: "WebRTC" | "HTTP" | "Storage" | null;
mode: "CDROM" | "Disk" | null; mode: "CDROM" | "Disk" | null;
filename: string | null; filename: string | null;
url: string | null; url: string | null;
@ -390,10 +396,13 @@ 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" | "url" | "device" | "upload" | "error" | null; modalView: "mode" | "browser" | "url" | "device" | "upload" | "error" | null;
setModalView: (view: MountMediaState["modalView"]) => void; setModalView: (view: MountMediaState["modalView"]) => void;
isMountMediaDialogOpen: boolean; isMountMediaDialogOpen: boolean;
@ -407,21 +416,24 @@ 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: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }), setRemoteVirtualMediaState: state => set({ remoteVirtualMediaState: state }),
modalView: "mode", modalView: "mode",
setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }), setModalView: view => set({ modalView: view }),
isMountMediaDialogOpen: false, isMountMediaDialogOpen: false,
setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }), setIsMountMediaDialogOpen: isOpen => set({ isMountMediaDialogOpen: isOpen }),
uploadedFiles: [], uploadedFiles: [],
addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) => addUploadedFile: file =>
set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })), set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })),
errorMessage: null, errorMessage: null,
setErrorMessage: (message: string | null) => set({ errorMessage: message }), setErrorMessage: message => set({ errorMessage: message }),
})); }));
export interface KeyboardLedState { export interface KeyboardLedState {
@ -430,33 +442,41 @@ 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,
caps_lock: false,
scroll_lock: false,
compose: false,
kana: false,
}; };
export const hidKeyBufferSize = 6;
export const hidErrorRollOver = 0x01;
export interface KeysDownState {
modifier: number;
keys: number[];
}
export type USBStates =
| "configured"
| "attached"
| "not attached"
| "suspended"
| "addressed";
export interface HidState { export interface HidState {
keyboardLedState: KeyboardLedState; activeKeys: number[];
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;
keysDownState: KeysDownState; keyboardLedStateSyncAvailable: boolean;
setKeysDownState: (state: KeysDownState) => void; setKeyboardLedStateSyncAvailable: (available: boolean) => void;
keyPressReportApiAvailable: boolean;
setkeyPressReportApiAvailable: (available: boolean) => void;
isVirtualKeyboardEnabled: boolean; isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void; setVirtualKeyboardEnabled: (enabled: boolean) => void;
@ -464,29 +484,55 @@ export interface HidState {
isPasteModeEnabled: boolean; isPasteModeEnabled: boolean;
setPasteModeEnabled: (enabled: boolean) => void; setPasteModeEnabled: (enabled: boolean) => void;
usbState: USBStates; usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed";
setUsbState: (state: USBStates) => void; setUsbState: (state: HidState["usbState"]) => void;
} }
export const useHidStore = create<HidState>(set => ({ export const useHidStore = create<HidState>((set, get) => ({
keyboardLedState: {} as KeyboardLedState, activeKeys: [],
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }), activeModifiers: [],
updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
return set({ activeKeys: keys, activeModifiers: modifiers });
},
keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState, altGrArmed: false,
setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), setAltGrArmed: armed => set({ altGrArmed: armed }),
keyPressReportApiAvailable: true, altGrTimer: 0,
setkeyPressReportApiAvailable: (available: boolean) => set({ keyPressReportApiAvailable: available }), setAltGrTimer: timeout => set({ altGrTimer: timeout }),
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: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
isPasteModeEnabled: false, isPasteModeEnabled: false,
setPasteModeEnabled: (enabled: boolean): void => set({ isPasteModeEnabled: enabled }), setPasteModeEnabled: enabled => 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: USBStates) => set({ usbState: state }), setUsbState: state => set({ usbState: state }),
})); }));
export const useUserStore = create<UserState>(set => ({ export const useUserStore = create<UserState>(set => ({
@ -494,15 +540,11 @@ export const useUserStore = create<UserState>(set => ({
setUser: user => set({ user }), setUser: user => set({ user }),
})); }));
export type UpdateModalViews = export interface UpdateState {
| "loading" isUpdatePending: boolean;
| "updating" setIsUpdatePending: (isPending: boolean) => void;
| "upToDate" updateDialogHasBeenMinimized: boolean;
| "updateAvailable" otaState: {
| "updateCompleted"
| "error";
export interface OtaState {
updating: boolean; updating: boolean;
error: string | null; error: string | null;
@ -531,24 +573,24 @@ export interface OtaState {
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: UpdateModalViews modalView:
setModalView: (view: UpdateModalViews) => void; | "loading"
| "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: boolean) => set({ isUpdatePending: isPending }), setIsUpdatePending: isPending => set({ isUpdatePending: isPending }),
setOtaState: state => set({ otaState: state }), setOtaState: state => set({ otaState: state }),
otaState: { otaState: {
@ -572,22 +614,18 @@ export const useUpdateStore = create<UpdateState>(set => ({
}, },
updateDialogHasBeenMinimized: false, updateDialogHasBeenMinimized: false,
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => setUpdateDialogHasBeenMinimized: hasBeenMinimized =>
set({ updateDialogHasBeenMinimized: hasBeenMinimized }), set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
modalView: "loading", modalView: "loading",
setModalView: (view: UpdateModalViews) => set({ modalView: view }), setModalView: view => set({ modalView: view }),
updateErrorMessage: null, updateErrorMessage: null,
setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }), setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }),
})); }));
export type UsbConfigModalViews = interface UsbConfigModalState {
| "updateUsbConfig" modalView: "updateUsbConfig" | "updateUsbConfigSuccess";
| "updateUsbConfigSuccess";
export interface UsbConfigModalState {
modalView: UsbConfigModalViews ;
errorMessage: string | null; errorMessage: string | null;
setModalView: (view: UsbConfigModalViews) => void; setModalView: (view: UsbConfigModalState["modalView"]) => void;
setErrorMessage: (message: string | null) => void; setErrorMessage: (message: string | null) => void;
} }
@ -602,26 +640,24 @@ 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: UsbConfigModalViews) => set({ modalView: view }), setModalView: view => set({ modalView: view }),
setErrorMessage: (message: string | null) => set({ errorMessage: message }), setErrorMessage: message => set({ errorMessage: message }),
})); }));
export type LocalAuthModalViews = interface LocalAuthModalState {
| "createPassword" modalView:
| "deletePassword" | "createPassword"
| "updatePassword" | "deletePassword"
| "creationSuccess" | "updatePassword"
| "deleteSuccess" | "creationSuccess"
| "updateSuccess"; | "deleteSuccess"
| "updateSuccess";
export interface LocalAuthModalState { setModalView: (view: LocalAuthModalState["modalView"]) => void;
modalView:LocalAuthModalViews;
setModalView: (view:LocalAuthModalViews) => void;
} }
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
modalView: "createPassword", modalView: "createPassword",
setModalView: (view: LocalAuthModalViews) => set({ modalView: view }), setModalView: view => set({ modalView: view }),
})); }));
export interface DeviceState { export interface DeviceState {
@ -636,8 +672,8 @@ export const useDeviceStore = create<DeviceState>(set => ({
appVersion: null, appVersion: null,
systemVersion: null, systemVersion: null,
setAppVersion: (version: string) => set({ appVersion: version }), setAppVersion: version => set({ appVersion: version }),
setSystemVersion: (version: string) => set({ systemVersion: version }), setSystemVersion: version => set({ systemVersion: version }),
})); }));
export interface DhcpLease { export interface DhcpLease {
@ -717,7 +753,6 @@ 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;
@ -803,7 +838,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: JsonRpcResponse) => { sendFn("getKeyboardMacros", {}, response => {
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));
@ -883,7 +918,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
sendFn( sendFn(
"setKeyboardMacros", "setKeyboardMacros",
{ params: { macros: macrosWithSortOrder } }, { params: { macros: macrosWithSortOrder } },
(response: JsonRpcResponse) => { response => {
resolve(response); resolve(response);
}, },
); );
@ -906,5 +941,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(); const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const send = useCallback( const send = useCallback(
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { (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("RPC error", payload); if ("error" in payload) console.error(payload.error);
if (!payload.id) return; if (!payload.id) return;
const callback = callbackStore.get(payload.id); const callback = callbackStore.get(payload.id);
@ -76,8 +76,7 @@ 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,30 +1,10 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { KeysDownState, useHidStore, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores"; import { useHidStore, useRTCStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; import { 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);
@ -35,62 +15,6 @@ 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";
@ -115,20 +39,6 @@ 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,
@ -141,20 +51,14 @@ 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 modifierMask: number = (step.modifiers || []).map(mod => modifiers[mod]).reduce((acc, val) => acc + val, 0); const modifierValues = step.modifiers?.map(mod => modifiers[mod]).filter(Boolean) || [];
// 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 || modifierMask > 0) { if (keyValues.length > 0 || modifierValues.length > 0) {
sendKeyboardEvent({ keys: keyValues, modifier: modifierMask }); sendKeyboardEvent(keyValues, modifierValues);
await new Promise(resolve => setTimeout(resolve, step.delay || 50)); await new Promise(resolve => setTimeout(resolve, step.delay || 50));
resetKeyboardState(); resetKeyboardState();
@ -170,92 +74,5 @@ export default function useKeyboard() {
} }
}; };
// handleKeyPress is used to handle a key press or release event. return { sendKeyboardEvent, resetKeyboardState, executeMacro };
// 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

@ -1,35 +0,0 @@
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,11 +315,6 @@ 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,31 +1,45 @@
export interface KeyStroke { modifier: number; keys: number[]; } import { chars as chars_fr_BE, name as name_fr_BE } from "@/keyboardLayouts/fr_BE"
export interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean } import { chars as chars_cs_CZ, name as name_cs_CZ } from "@/keyboardLayouts/cs_CZ"
export interface KeyCombo extends KeyInfo { deadKey?: boolean, accentKey?: KeyInfo } import { chars as chars_en_UK, name as name_en_UK } from "@/keyboardLayouts/en_UK"
export interface KeyboardLayout { import { chars as chars_en_US, name as name_en_US } from "@/keyboardLayouts/en_US"
isoCode: string; import { chars as chars_fr_FR, name as name_fr_FR } from "@/keyboardLayouts/fr_FR"
name: string; import { chars as chars_de_DE, name as name_de_DE } from "@/keyboardLayouts/de_DE"
chars: Record<string, KeyCombo>; import { chars as chars_it_IT, name as name_it_IT } from "@/keyboardLayouts/it_IT"
modifierDisplayMap: Record<string, string>; import { chars as chars_nb_NO, name as name_nb_NO } from "@/keyboardLayouts/nb_NO"
keyDisplayMap: Record<string, string>; import { chars as chars_es_ES, name as name_es_ES } from "@/keyboardLayouts/es_ES"
virtualKeyboard: { import { chars as chars_sv_SE, name as name_sv_SE } from "@/keyboardLayouts/sv_SE"
main: { default: string[], shift: string[] }, import { chars as chars_fr_CH, name as name_fr_CH } from "@/keyboardLayouts/fr_CH"
control?: { default: string[], shift?: string[] }, import { chars as chars_de_CH, name as name_de_CH } from "@/keyboardLayouts/de_CH"
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,
} }
// To add a new layout, create a file like the above and add it to the list export const chars: Record<string, Record<string, KeyCombo>> = {
import { cs_CZ } from "@/keyboardLayouts/cs_CZ" be_FR: chars_fr_BE,
import { de_CH } from "@/keyboardLayouts/de_CH" cs_CZ: chars_cs_CZ,
import { de_DE } from "@/keyboardLayouts/de_DE" en_UK: chars_en_UK,
import { en_US } from "@/keyboardLayouts/en_US" en_US: chars_en_US,
import { en_UK } from "@/keyboardLayouts/en_UK" fr_FR: chars_fr_FR,
import { es_ES } from "@/keyboardLayouts/es_ES" de_DE: chars_de_DE,
import { fr_BE } from "@/keyboardLayouts/fr_BE" it_IT: chars_it_IT,
import { fr_CH } from "@/keyboardLayouts/fr_CH" nb_NO: chars_nb_NO,
import { fr_FR } from "@/keyboardLayouts/fr_FR" es_ES: chars_es_ES,
import { it_IT } from "@/keyboardLayouts/it_IT" sv_SE: chars_sv_SE,
import { nb_NO } from "@/keyboardLayouts/nb_NO" fr_CH: chars_fr_CH,
import { sv_SE } from "@/keyboardLayouts/sv_SE" de_CH: chars_de_CH,
};
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,22 +1,19 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard export const name = "Čeština";
const name = "Čeština"; const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
const isoCode = "cs-CZ"; const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
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
const keyTrema: KeyCombo = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel export const chars = {
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 },
@ -245,13 +242,3 @@ 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,17 +1,14 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard export const name = "Schwiizerdütsch";
const name = "Schwiizerdütsch"; const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const isoCode = "de-CH"; const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
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
const keyTrema: KeyCombo = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel export const chars = {
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 },
@ -166,23 +163,3 @@ 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,146 +1,113 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard export const name = "Deutsch";
const name = "Deutsch"; const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const isoCode = "de-DE"; const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyAcute: KeyCombo = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter export const chars = {
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" },
F: { key: "KeyF", shift: true }, F: { key: "KeyF", shift: true },
"˟": { key: "KeyF", deadKey: true, altRight: true }, // modifier letter cross accent, ˟
G: { key: "KeyG", shift: true }, G: { key: "KeyG", shift: true },
g: { key: "KeyG" },
"ẞ": { key: "KeyG", altRight: true }, // capital sharp S, ẞ
h: { key: "KeyH" },
H: { key: "KeyH", shift: true }, H: { key: "KeyH", shift: true },
"ˍ": { key: "KeyH", deadKey: true, altRight: true }, // modifier letter low macron, ˍ
i: { key: "KeyI" },
"í": { key: "KeyI", accentKey: keyAcute },
"î": { key: "KeyI", accentKey: keyHat },
"ì": { key: "KeyI", accentKey: keyGrave },
I: { key: "KeyI", shift: true }, I: { key: "KeyI", shift: true },
"Í": { key: "KeyI", shift: true, accentKey: keyAcute }, "Í": { key: "KeyI", shift: true, accentKey: keyAcute },
"Î": { key: "KeyI", shift: true, accentKey: keyHat }, "Î": { key: "KeyI", shift: true, accentKey: keyHat },
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave }, "Ì": { 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", shift: true }, J: { key: "KeyJ", shift: true },
"¸": { key: "KeyJ", deadKey: true, altRight: true }, // cedilla accent, mark ¸ placed below the letter
k: { key: "KeyK" },
K: { key: "KeyK", shift: true }, K: { key: "KeyK", shift: true },
l: { key: "KeyL" },
L: { key: "KeyL", shift: true }, L: { key: "KeyL", shift: true },
"ˏ": { key: "KeyL", deadKey: true, altRight: true }, // modifier letter reversed comma, ˏ
m: { key: "KeyM" },
M: { key: "KeyM", shift: true }, M: { key: "KeyM", shift: true },
"µ": { key: "KeyM", altRight: true },
n: { key: "KeyN" },
N: { key: "KeyN", shift: true }, N: { key: "KeyN", shift: true },
"": { key: "KeyN", altRight: true }, // en dash,
o: { key: "KeyO" },
"ó": { key: "KeyO", accentKey: keyAcute },
"ô": { key: "KeyO", accentKey: keyHat },
"ò": { key: "KeyO", accentKey: keyGrave },
O: { key: "KeyO", shift: true }, O: { key: "KeyO", shift: true },
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute }, "Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat }, "Ô": { key: "KeyO", shift: true, accentKey: keyHat },
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave }, "Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
"˚": { key: "KeyO", deadKey: true, altRight: true }, // ring above, ˚
p: { key: "KeyP" },
P: { key: "KeyP", shift: true }, P: { key: "KeyP", shift: true },
"ˀ": { key: "KeyP", deadKey: true, altRight: true }, // modifier letter apostrophe, ʾ
q: { key: "KeyQ" },
Q: { key: "KeyQ", shift: true }, Q: { key: "KeyQ", shift: true },
"@": { key: "KeyQ", altRight: true },
R: { key: "KeyR", shift: true }, R: { key: "KeyR", shift: true },
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", shift: true },
s: { key: "KeyS" },
"″": { key: "KeyS", altRight: true }, // double prime, mark ″ placed above the letter
T: { key: "KeyT", shift: true }, T: { key: "KeyT", shift: true },
t: { key: "KeyT" },
"ˇ": { key: "KeyT", deadKey: true, altRight: true }, // caron/hacek accent, mark ˇ placed above the letter
u: { key: "KeyU" },
"ú": { key: "KeyU", accentKey: keyAcute },
"û": { key: "KeyU", accentKey: keyHat },
"ù": { key: "KeyU", accentKey: keyGrave },
U: { key: "KeyU", shift: true }, U: { key: "KeyU", shift: true },
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute }, "Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
"Û": { key: "KeyU", shift: true, accentKey: keyHat }, "Û": { key: "KeyU", shift: true, accentKey: keyHat },
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave }, "Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
"˘": { key: "KeyU", deadKey: true, altRight: true }, // breve accent, ˘ placed above the letter
v: { key: "KeyV" },
V: { key: "KeyV", shift: true }, V: { key: "KeyV", shift: true },
"«": { key: "KeyV", altRight: true }, // left-pointing double angle quotation mark, «
w: { key: "KeyW" },
W: { key: "KeyW", shift: true }, W: { key: "KeyW", shift: true },
"¯": { key: "KeyW", deadKey: true, altRight: true }, // macron accent, mark ¯ placed above the letter
x: { key: "KeyX" },
X: { key: "KeyX", shift: true }, 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", shift: true }, Y: { key: "KeyZ", shift: true },
"": { key: "KeyZ", altRight: true }, // single right-pointing angle quotation mark,
z: { key: "KeyY" },
Z: { key: "KeyY", shift: true }, Z: { key: "KeyY", shift: true },
"¨": { key: "KeyY", deadKey: true, altRight: true }, // diaeresis accent, mark ¨ placed above the letter 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" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
"í": { key: "KeyI", accentKey: keyAcute },
"î": { key: "KeyI", accentKey: keyHat },
"ì": { key: "KeyI", accentKey: keyGrave },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
"µ": { key: "KeyM", altRight: true },
n: { key: "KeyN" },
o: { key: "KeyO" },
"ó": { key: "KeyO", accentKey: keyAcute },
"ô": { key: "KeyO", accentKey: keyHat },
"ò": { key: "KeyO", accentKey: keyGrave },
p: { key: "KeyP" },
q: { key: "KeyQ" },
"@": { key: "KeyQ", altRight: true },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
"ú": { key: "KeyU", accentKey: keyAcute },
"û": { key: "KeyU", accentKey: keyHat },
"ù": { key: "KeyU", accentKey: keyGrave },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyZ" },
z: { key: "KeyY" },
"°": { 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 },
@ -156,192 +123,30 @@ 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 }, // accent acute, mark ´ placed above the letter "´": { key: "Equal", deadKey: true },
"`": { key: "Equal", shift: true, deadKey: true }, // accent grave, mark ` placed above the letter "`": { key: "Equal", shift: true, deadKey: true },
"˙": { 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 },
"\u00ad": { key: "Slash", altRight: true }, // soft hyphen, ­ "<": { key: "IntlBackslash" },
">": { 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,11 +1,8 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard export const name = "English (UK)";
const name = "English (UK)"; export const chars = {
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 },
@ -108,13 +105,3 @@ 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,16 +1,6 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
const name = "English (US)"; export 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 },
@ -99,213 +89,25 @@ 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" }, " ": { key: "Space", shift: false },
"\n": { key: "Enter" }, "\n": { key: "Enter", shift: false },
Enter: { key: "Enter" }, Enter: { key: "Enter", shift: false },
Escape: { key: "Escape" }, Tab: { key: "Tab", shift: false },
Tab: { key: "Tab" }, PrintScreen: { key: "Prt Sc", shift: false },
PrintScreen: { key: "Prt Sc" },
SystemRequest: { key: "Prt Sc", shift: true }, SystemRequest: { key: "Prt Sc", shift: true },
ScrollLock: { key: "ScrollLock" }, ScrollLock: { key: "ScrollLock", shift: false},
Pause: { key: "Pause" }, Pause: { key: "Pause", shift: false },
Break: { key: "Pause", shift: true }, Break: { key: "Pause", shift: true },
Insert: { key: "Insert" }, Insert: { key: "Insert", shift: false },
Delete: { key: "Delete" }, Delete: { key: "Delete", shift: false },
} 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,17 +1,14 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard export const name = "Español";
const name = "Español"; const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
const isoCode = "es-ES"; const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
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
const keyTrema: KeyCombo = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel export const chars = {
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 },
@ -169,13 +166,3 @@ 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,17 +1,14 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard export const name = "Belgisch Nederlands";
const name = "Belgisch Nederlands"; const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const isoCode = "nl-BE"; const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
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
const keyTrema: KeyCombo = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel export const chars = {
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 },
@ -168,13 +165,3 @@ 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,12 +1,11 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { de_CH } from "./de_CH" import { chars as chars_de_CH } from "./de_CH"
const name = "Français de Suisse"; export const name = "Français de Suisse";
const isoCode = "fr-CH";
const chars = { export const chars = {
...de_CH.chars, ...chars_de_CH,
"è": { key: "BracketLeft" }, "è": { key: "BracketLeft" },
"ü": { key: "BracketLeft", shift: true }, "ü": { key: "BracketLeft", shift: true },
"é": { key: "Semicolon" }, "é": { key: "Semicolon" },
@ -14,23 +13,3 @@ 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,14 +1,11 @@
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { KeyCombo } from "../keyboardLayouts"
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard export const name = "Français";
const name = "Français"; const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const isoCode = "fr-FR"; const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyTrema: KeyCombo = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel export const chars = {
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 },
@ -140,13 +137,3 @@ 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