Compare commits
29 Commits
ae775aeeee
...
e24f3c95cd
Author | SHA1 | Date |
---|---|---|
|
e24f3c95cd | |
|
94521ef6db | |
|
66cccfe9e1 | |
|
a42384fed6 | |
|
3ec243255b | |
|
05bf61152b | |
|
d952480c2a | |
|
8e27cd6b60 | |
|
bb87fb5a1a | |
|
8527b1eff1 | |
|
9f573200b1 | |
|
608f69db13 | |
|
f7b8efde7c | |
|
33ac9fe0b6 | |
|
55fbd6c359 | |
|
cff3ddad29 | |
|
b4525b8760 | |
|
5a3ce2d6ec | |
|
f1953fddbc | |
|
9ba97ebe67 | |
|
5fb8d866ba | |
|
3359f8fca4 | |
|
ef95643a86 | |
|
1fc603b553 | |
|
aada3d95e0 | |
|
d704fcc6c7 | |
|
ab3dda6dee | |
|
4a23f22a55 | |
|
11a095c0f6 |
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
name: Bug report
|
||||
description: 🐛 Let us know about an unexpected error, a crash, or an unexpected behavior.
|
||||
type: 'Bug'
|
||||
labels:
|
||||
- 'type: bug'
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Disclaimer
|
||||
description: |
|
||||
For support questions, please use the [discussions][] or [Discord][] instead. Before
|
||||
opening a bug report, ensure you have read the [documentation][],
|
||||
[Troubleshooting][] and [Device FAQs][]. Only use bug reports for actual
|
||||
bugs.
|
||||
|
||||
[documentation]: https://jetkvm.com/docs
|
||||
[Troubleshooting]: https://jetkvm.com/docs/getting-started/troubleshooting
|
||||
[Device FAQs]: https://jetkvm.com/docs/getting-started/faq
|
||||
[discussions]: https://github.com/jetkvm/kvm/discussions
|
||||
[Discord]: https://jetkvm.com/discord
|
||||
options:
|
||||
- label: I have read and understood the disclaimer.
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Application version
|
||||
description: |
|
||||
Provide the application version (can be found in General settings)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: System version
|
||||
description: |
|
||||
Provide the system version (can be found in General settings)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Device model
|
||||
description: Provide the device model
|
||||
options:
|
||||
- JetKVM
|
||||
- JetKVM (POE)
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Extension model
|
||||
description: Provide the extension model (if the bug is related to the extension)
|
||||
options:
|
||||
- ATX Power Control
|
||||
- DC Power Control
|
||||
- Serial Console
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
attributes:
|
||||
label: Remote device Hardware
|
||||
description: If the bug is related to a remote device, please provide its hardware information e.g. Raspberry Pi 5
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
attributes:
|
||||
label: Remote device OS
|
||||
description: If the bug is related to a remote device, please provide its OS information as detailed as possible e.g. Debian 12.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Bug description
|
||||
description: |
|
||||
Provide a description of the problem: steps to reproduce it, what you are expecting and what you got.
|
||||
validations:
|
||||
required: true
|
|
@ -0,0 +1,10 @@
|
|||
blank_issues_enabled: true
|
||||
|
||||
contact_links:
|
||||
- name: Hardware Issues
|
||||
url: https://jetkvm.com/contact
|
||||
about: If your hardware is not powering on or is not working, please contact us.
|
||||
|
||||
- name: Discord
|
||||
url: https://jetkvm.com/discord
|
||||
about: Engage with the JetKVM team and other community members.
|
|
@ -0,0 +1,46 @@
|
|||
name: Feature
|
||||
type: 'Feature'
|
||||
description: 🚀 Request a new feature.
|
||||
labels:
|
||||
- 'type: feature'
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: A note for the community
|
||||
value: |
|
||||
> [!NOTE]
|
||||
> Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request.
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Disclaimer
|
||||
description: |
|
||||
Before requesting a feature, check it does not already exist in the [documentation](https://jetkvm.com/docs) or our [roadmap](https://jetkvm.com/roadmap).
|
||||
You are quite welcome opening a feature request before spending time to implement it yourself.
|
||||
options:
|
||||
- label: I have read and understood the disclaimer.
|
||||
required: true
|
||||
- label: I plan to implement the feature myself.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Subsystem
|
||||
description: Provide the subsystem of the feature you request, you can choose multiple if you think it fits in multiple areas.
|
||||
options:
|
||||
- Hardware
|
||||
- Device Compatibility
|
||||
- Keyboard
|
||||
- Mouse
|
||||
- Power
|
||||
- UI: Screen
|
||||
- UI: Application
|
||||
- UI: Cloud
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Feature description
|
||||
description: |
|
||||
Provide a description of the feature you request.
|
||||
validations:
|
||||
required: true
|
|
@ -23,6 +23,9 @@ linters:
|
|||
- linters:
|
||||
- errcheck
|
||||
path: _test.go
|
||||
- linters:
|
||||
- forbidigo
|
||||
path: cmd/main.go
|
||||
- linters:
|
||||
- gochecknoinits
|
||||
path: internal/logging/sse.go
|
||||
|
|
|
@ -0,0 +1,355 @@
|
|||
<div align="center">
|
||||
<img alt="JetKVM logo" src="https://jetkvm.com/logo-blue.png" height="28">
|
||||
|
||||
### Development Guide
|
||||
|
||||
[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/cloud-api/issues) | [Docs](https://jetkvm.com/docs)
|
||||
|
||||
[](https://twitter.com/jetkvm)
|
||||
|
||||
[](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).
|
2
Makefile
|
@ -8,7 +8,7 @@ VERSION ?= 0.4.6
|
|||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||
KVM_PKG_NAME := github.com/jetkvm/kvm
|
||||
|
||||
GO_BUILD_ARGS := -tags netgo
|
||||
GO_BUILD_ARGS := -tags netgo -tags timetzdata
|
||||
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
||||
GO_LDFLAGS := \
|
||||
-s -w \
|
||||
|
|
|
@ -37,7 +37,9 @@ JetKVM is written in Go & TypeScript. with some bits and pieces written in C. An
|
|||
|
||||
The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud.
|
||||
|
||||
For most of local device development, all you need is to use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information.
|
||||
For comprehensive development information, including setup, testing, debugging, and contribution guidelines, see **[DEVELOPMENT.md](DEVELOPMENT.md)**.
|
||||
|
||||
For quick device development, use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information.
|
||||
|
||||
## Backend
|
||||
|
||||
|
|
|
@ -22,25 +22,12 @@ func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
|
|||
return 0, errors.New("image not mounted")
|
||||
}
|
||||
source := currentVirtualMediaState.Source
|
||||
mountedImageSize := currentVirtualMediaState.Size
|
||||
virtualMediaStateMutex.RUnlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
readLen := int64(len(p))
|
||||
if off+readLen > mountedImageSize {
|
||||
readLen = mountedImageSize - off
|
||||
}
|
||||
var data []byte
|
||||
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:
|
||||
return httpRangeReader.ReadAt(p, off)
|
||||
default:
|
||||
|
|
18
cmd/main.go
|
@ -1,9 +1,27 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/jetkvm/kvm"
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
35
config.go
|
@ -9,6 +9,8 @@ import (
|
|||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/jetkvm/kvm/internal/network"
|
||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
type WakeOnLanDevice struct {
|
||||
|
@ -80,6 +82,7 @@ type Config struct {
|
|||
CloudToken string `json:"cloud_token"`
|
||||
GoogleIdentity string `json:"google_identity"`
|
||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||
JigglerConfig *JigglerConfig `json:"jiggler_config"`
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||
IncludePreRelease bool `json:"include_pre_release"`
|
||||
HashedPassword string `json:"hashed_password"`
|
||||
|
@ -111,11 +114,18 @@ var defaultConfig = &Config{
|
|||
ActiveExtension: "",
|
||||
KeyboardMacros: []KeyboardMacro{},
|
||||
DisplayRotation: "270",
|
||||
KeyboardLayout: "en_US",
|
||||
KeyboardLayout: "en-US",
|
||||
DisplayMaxBrightness: 64,
|
||||
DisplayDimAfterSec: 120, // 2 minutes
|
||||
DisplayOffAfterSec: 1800, // 30 minutes
|
||||
TLSMode: "",
|
||||
// This is the "Standard" jiggler option in the UI
|
||||
JigglerConfig: &JigglerConfig{
|
||||
InactivityLimitSeconds: 60,
|
||||
JitterPercentage: 25,
|
||||
ScheduleCronTab: "0 * * * * *",
|
||||
Timezone: "UTC",
|
||||
},
|
||||
TLSMode: "",
|
||||
UsbConfig: &usbgadget.Config{
|
||||
VendorId: "0x1d6b", //The Linux Foundation
|
||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||
|
@ -138,6 +148,21 @@ var (
|
|||
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() {
|
||||
configLock.Lock()
|
||||
defer configLock.Unlock()
|
||||
|
@ -153,6 +178,8 @@ func LoadConfig() {
|
|||
file, err := os.Open(configPath)
|
||||
if err != nil {
|
||||
logger.Debug().Msg("default config file doesn't exist, using default")
|
||||
configSuccess.Set(1.0)
|
||||
configSuccessTime.SetToCurrentTime()
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
@ -161,6 +188,7 @@ func LoadConfig() {
|
|||
loadedConfig := *defaultConfig
|
||||
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
||||
logger.Warn().Err(err).Msg("config file JSON parsing failed")
|
||||
configSuccess.Set(0.0)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -181,6 +209,9 @@ func LoadConfig() {
|
|||
|
||||
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
|
||||
|
||||
configSuccess.Set(1.0)
|
||||
configSuccessTime.SetToCurrentTime()
|
||||
|
||||
logger.Info().Str("path", configPath).Msg("config loaded")
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
dcCurrentGauge = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "jetkvm_dc_current_amperes",
|
||||
Help: "Current DC power consumption in amperes",
|
||||
})
|
||||
|
||||
dcPowerGauge = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "jetkvm_dc_power_watts",
|
||||
Help: "DC power consumption in watts",
|
||||
})
|
||||
|
||||
dcVoltageGauge = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "jetkvm_dc_voltage_volts",
|
||||
Help: "DC voltage in volts",
|
||||
})
|
||||
|
||||
dcStateGauge = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "jetkvm_dc_power_state",
|
||||
Help: "DC power state (1 = on, 0 = off)",
|
||||
})
|
||||
|
||||
dcMetricsRegistered sync.Once
|
||||
)
|
||||
|
||||
// registerDCMetrics registers the DC power metrics with Prometheus (called once when DC control is mounted)
|
||||
func registerDCMetrics() {
|
||||
dcMetricsRegistered.Do(func() {
|
||||
prometheus.MustRegister(dcCurrentGauge)
|
||||
prometheus.MustRegister(dcPowerGauge)
|
||||
prometheus.MustRegister(dcVoltageGauge)
|
||||
prometheus.MustRegister(dcStateGauge)
|
||||
})
|
||||
}
|
||||
|
||||
// updateDCMetrics updates the Prometheus metrics with current DC power state values
|
||||
func updateDCMetrics(state DCPowerState) {
|
||||
dcCurrentGauge.Set(state.Current)
|
||||
dcPowerGauge.Set(state.Power)
|
||||
dcVoltageGauge.Set(state.Voltage)
|
||||
if state.IsOn {
|
||||
dcStateGauge.Set(1)
|
||||
} else {
|
||||
dcStateGauge.Set(0)
|
||||
}
|
||||
}
|
|
@ -28,6 +28,7 @@ show_help() {
|
|||
echo " --run-go-tests Run go tests"
|
||||
echo " --run-go-tests-only Run go tests and exit"
|
||||
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
|
||||
echo "Example:"
|
||||
|
@ -43,6 +44,7 @@ RESET_USB_HID_DEVICE=false
|
|||
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
|
||||
RUN_GO_TESTS=false
|
||||
RUN_GO_TESTS_ONLY=false
|
||||
INSTALL_APP=false
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
@ -72,6 +74,10 @@ while [[ $# -gt 0 ]]; do
|
|||
RUN_GO_TESTS=true
|
||||
shift
|
||||
;;
|
||||
-i|--install)
|
||||
INSTALL_APP=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help
|
||||
exit 0
|
||||
|
@ -139,25 +145,36 @@ EOF
|
|||
fi
|
||||
fi
|
||||
|
||||
msg_info "▶ Building go binary"
|
||||
make build_dev
|
||||
|
||||
# Kill any existing instances of the application
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
|
||||
|
||||
# Copy the binary to the remote host
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
|
||||
|
||||
if [ "$RESET_USB_HID_DEVICE" = true ]; then
|
||||
msg_info "▶ Resetting USB HID device"
|
||||
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
|
||||
# Remove the old USB gadget configuration
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
|
||||
fi
|
||||
|
||||
# Deploy and run the application on the remote host
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
|
||||
if [ "$INSTALL_APP" = true ]
|
||||
then
|
||||
msg_info "▶ Building release binary"
|
||||
make build_release
|
||||
|
||||
# Copy the binary to the remote host as if we were the OTA updater.
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
|
||||
|
||||
# Reboot the device, the new app will be deployed by the startup process.
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
|
||||
else
|
||||
msg_info "▶ Building development binary"
|
||||
make build_dev
|
||||
|
||||
msg_info "▶ Killing any existing instances of the application"
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
|
||||
|
||||
msg_info "▶ Copying binary to remote host"
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
|
||||
|
||||
if [ "$RESET_USB_HID_DEVICE" = true ]; then
|
||||
msg_info "▶ Resetting USB HID device"
|
||||
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
|
||||
# Remove the old USB gadget configuration
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
|
||||
fi
|
||||
|
||||
msg_info "▶ Deploying and running the application on the remote host"
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
|
||||
set -e
|
||||
|
||||
# Set the library path to include the directory where librockit.so is located
|
||||
|
@ -174,7 +191,8 @@ cd "${REMOTE_PATH}"
|
|||
chmod +x jetkvm_app_debug
|
||||
|
||||
# Run the application in the background
|
||||
PION_LOG_TRACE=${LOG_TRACE_SCOPES} GODEBUG=netdns=1 ./jetkvm_app_debug
|
||||
PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_debug.log
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Deployment complete."
|
||||
echo "Deployment complete."
|
||||
|
|
20
display.go
|
@ -30,7 +30,7 @@ const (
|
|||
// do not call this function directly, use switchToScreenIfDifferent instead
|
||||
// this function is not thread safe
|
||||
func switchToScreen(screen string) {
|
||||
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
|
||||
_, err := CallCtrlAction("lv_scr_load", map[string]any{"obj": screen})
|
||||
if err != nil {
|
||||
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
|
||||
return
|
||||
|
@ -39,15 +39,15 @@ func switchToScreen(screen string) {
|
|||
}
|
||||
|
||||
func lvObjSetState(objName string, state string) (*CtrlResponse, error) {
|
||||
return CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": objName, "state": state})
|
||||
return CallCtrlAction("lv_obj_set_state", map[string]any{"obj": objName, "state": state})
|
||||
}
|
||||
|
||||
func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) {
|
||||
return CallCtrlAction("lv_obj_add_flag", map[string]interface{}{"obj": objName, "flag": flag})
|
||||
return CallCtrlAction("lv_obj_add_flag", map[string]any{"obj": objName, "flag": flag})
|
||||
}
|
||||
|
||||
func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) {
|
||||
return CallCtrlAction("lv_obj_clear_flag", map[string]interface{}{"obj": objName, "flag": flag})
|
||||
return CallCtrlAction("lv_obj_clear_flag", map[string]any{"obj": objName, "flag": flag})
|
||||
}
|
||||
|
||||
func lvObjHide(objName string) (*CtrlResponse, error) {
|
||||
|
@ -59,27 +59,27 @@ func lvObjShow(objName string) (*CtrlResponse, error) {
|
|||
}
|
||||
|
||||
func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused
|
||||
return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]interface{}{"obj": objName, "opa": opacity})
|
||||
return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]any{"obj": objName, "opa": opacity})
|
||||
}
|
||||
|
||||
func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) {
|
||||
return CallCtrlAction("lv_obj_fade_in", map[string]interface{}{"obj": objName, "time": duration})
|
||||
return CallCtrlAction("lv_obj_fade_in", map[string]any{"obj": objName, "time": duration})
|
||||
}
|
||||
|
||||
func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) {
|
||||
return CallCtrlAction("lv_obj_fade_out", map[string]interface{}{"obj": objName, "time": duration})
|
||||
return CallCtrlAction("lv_obj_fade_out", map[string]any{"obj": objName, "time": duration})
|
||||
}
|
||||
|
||||
func lvLabelSetText(objName string, text string) (*CtrlResponse, error) {
|
||||
return CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": text})
|
||||
return CallCtrlAction("lv_label_set_text", map[string]any{"obj": objName, "text": text})
|
||||
}
|
||||
|
||||
func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) {
|
||||
return CallCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src})
|
||||
return CallCtrlAction("lv_img_set_src", map[string]any{"obj": objName, "src": src})
|
||||
}
|
||||
|
||||
func lvDispSetRotation(rotation string) (*CtrlResponse, error) {
|
||||
return CallCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation})
|
||||
return CallCtrlAction("lv_disp_set_rotation", map[string]any{"rotation": rotation})
|
||||
}
|
||||
|
||||
func updateLabelIfChanged(objName string, newText string) {
|
||||
|
|
114
fuse.go
|
@ -1,114 +0,0 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type WebRTCStreamFile struct {
|
||||
fs.Inode
|
||||
mu sync.Mutex
|
||||
Attr fuse.Attr
|
||||
size uint64
|
||||
}
|
||||
|
||||
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
|
||||
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
|
||||
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
|
||||
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
|
||||
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
|
||||
|
||||
func (f *WebRTCStreamFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
|
||||
}
|
||||
|
||||
func (f *WebRTCStreamFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
|
||||
return 0, syscall.EROFS
|
||||
}
|
||||
|
||||
var _ = (fs.NodeGetattrer)((*WebRTCStreamFile)(nil))
|
||||
|
||||
func (f *WebRTCStreamFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out.Attr = f.Attr
|
||||
out.Size = f.size
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (f *WebRTCStreamFile) Setattr(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out.Attr = f.Attr
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (f *WebRTCStreamFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
type DiskReadRequest struct {
|
||||
Start uint64 `json:"start"`
|
||||
End uint64 `json:"end"`
|
||||
}
|
||||
|
||||
var diskReadChan = make(chan []byte, 1)
|
||||
|
||||
func (f *WebRTCStreamFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
||||
buf, err := webRTCDiskReader.Read(ctx, off, int64(len(dest)))
|
||||
if err != nil {
|
||||
return nil, syscall.EIO
|
||||
}
|
||||
return fuse.ReadResultData(buf), fs.OK
|
||||
}
|
||||
|
||||
func (f *WebRTCStreamFile) SetSize(size uint64) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.size = size
|
||||
}
|
||||
|
||||
type FuseRoot struct {
|
||||
fs.Inode
|
||||
}
|
||||
|
||||
var webRTCStreamFile = &WebRTCStreamFile{}
|
||||
|
||||
func (r *FuseRoot) OnAdd(ctx context.Context) {
|
||||
ch := r.NewPersistentInode(ctx, webRTCStreamFile, fs.StableAttr{Ino: 2})
|
||||
r.AddChild("disk", ch, false)
|
||||
}
|
||||
|
||||
func (r *FuseRoot) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
out.Mode = 0755
|
||||
return 0
|
||||
}
|
||||
|
||||
var _ = (fs.NodeGetattrer)((*FuseRoot)(nil))
|
||||
var _ = (fs.NodeOnAdder)((*FuseRoot)(nil))
|
||||
|
||||
const fuseMountPoint = "/mnt/webrtc"
|
||||
|
||||
var fuseServer *fuse.Server
|
||||
|
||||
func RunFuseServer() {
|
||||
opts := &fs.Options{}
|
||||
opts.DirectMountStrict = true
|
||||
_ = os.Mkdir(fuseMountPoint, 0755)
|
||||
var err error
|
||||
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to mount fuse")
|
||||
}
|
||||
fuseServer.Wait()
|
||||
}
|
||||
|
||||
type WebRTCImage struct {
|
||||
Size uint64 `json:"size"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
9
go.mod
|
@ -11,6 +11,7 @@ require (
|
|||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gin-contrib/logger v1.2.6
|
||||
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/guregu/null/v6 v6.0.0
|
||||
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341
|
||||
|
@ -28,9 +29,9 @@ require (
|
|||
github.com/stretchr/testify v1.10.0
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
go.bug.st/serial v1.6.4
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/net v0.41.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/sys v0.34.0
|
||||
)
|
||||
|
||||
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
|
||||
|
@ -50,6 +51,7 @@ require (
|
|||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // 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/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
|
@ -75,6 +77,7 @@ require (
|
|||
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
|
@ -82,7 +85,7 @@ require (
|
|||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/arch v0.18.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
20
go.sum
|
@ -38,6 +38,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
|||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
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/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
|
@ -62,6 +64,8 @@ github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 h1:zPrkLSKi7kKJoN
|
|||
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
||||
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
|
||||
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
|
@ -146,6 +150,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
|
|||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
|
||||
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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
|
@ -175,10 +181,12 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
|||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||
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/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
|
@ -188,10 +196,10 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type HidPayloadType uint8
|
||||
|
||||
const (
|
||||
HidPayloadTypeKeyboardReport HidPayloadType = 1
|
||||
HidPayloadTypeKeyboardReportNoModifier HidPayloadType = 2
|
||||
HidPayloadTypeKeyboardReportNothing HidPayloadType = 3
|
||||
HidPayloadTypeAbsMouseReport HidPayloadType = 8
|
||||
HidPayloadTypeRelMouseReport HidPayloadType = 9
|
||||
)
|
||||
|
||||
func handleHidMessage(msg webrtc.DataChannelMessage) {
|
||||
if msg.IsString {
|
||||
webrtcLogger.Info().Interface("msg", msg.Data).Msg("Hid message is a string, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
payloadType := HidPayloadType(msg.Data[0])
|
||||
switch payloadType {
|
||||
case HidPayloadTypeKeyboardReport:
|
||||
if len(msg.Data) < 3 {
|
||||
return
|
||||
}
|
||||
modifier := msg.Data[1]
|
||||
keys := msg.Data[2:]
|
||||
_ = gadget.KeyboardReport(modifier, keys)
|
||||
case HidPayloadTypeKeyboardReportNoModifier:
|
||||
if len(msg.Data) < 2 {
|
||||
return
|
||||
}
|
||||
keys := msg.Data[1:]
|
||||
_ = gadget.KeyboardReport(0, keys)
|
||||
case HidPayloadTypeKeyboardReportNothing:
|
||||
_ = gadget.KeyboardReport(0, []byte{})
|
||||
case HidPayloadTypeAbsMouseReport:
|
||||
if len(msg.Data) < 6 {
|
||||
return
|
||||
}
|
||||
x := binary.LittleEndian.Uint16(msg.Data[1:3])
|
||||
y := binary.LittleEndian.Uint16(msg.Data[3:5])
|
||||
buttons := msg.Data[5]
|
||||
webrtcLogger.Info().Uint16("x", x).Uint16("y", y).Uint8("buttons", buttons).Msg("Absolute mouse report")
|
||||
_ = gadget.AbsMouseReport(int(x), int(y), buttons)
|
||||
case HidPayloadTypeRelMouseReport:
|
||||
if len(msg.Data) < 4 {
|
||||
return
|
||||
}
|
||||
dx := int8(msg.Data[1])
|
||||
dy := int8(msg.Data[2])
|
||||
buttons := msg.Data[3]
|
||||
webrtcLogger.Info().Int8("dx", dx).Int8("dy", dy).Uint8("buttons", buttons).Msg("Relative mouse report")
|
||||
_ = gadget.RelMouseReport(dx, dy, buttons)
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ package confparser
|
|||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
@ -15,22 +16,22 @@ import (
|
|||
type FieldConfig struct {
|
||||
Name string
|
||||
Required bool
|
||||
RequiredIf map[string]interface{}
|
||||
RequiredIf map[string]any
|
||||
OneOf []string
|
||||
ValidateTypes []string
|
||||
Defaults interface{}
|
||||
Defaults any
|
||||
IsEmpty bool
|
||||
CurrentValue interface{}
|
||||
CurrentValue any
|
||||
TypeString string
|
||||
Delegated bool
|
||||
shouldUpdateValue bool
|
||||
}
|
||||
|
||||
func SetDefaultsAndValidate(config interface{}) error {
|
||||
func SetDefaultsAndValidate(config any) error {
|
||||
return setDefaultsAndValidate(config, true)
|
||||
}
|
||||
|
||||
func setDefaultsAndValidate(config interface{}, isRoot bool) error {
|
||||
func setDefaultsAndValidate(config any, isRoot bool) error {
|
||||
// first we need to check if the config is a pointer
|
||||
if reflect.TypeOf(config).Kind() != reflect.Ptr {
|
||||
return fmt.Errorf("config is not a pointer")
|
||||
|
@ -54,7 +55,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
|
|||
Name: field.Name,
|
||||
OneOf: splitString(field.Tag.Get("one_of")),
|
||||
ValidateTypes: splitString(field.Tag.Get("validate_type")),
|
||||
RequiredIf: make(map[string]interface{}),
|
||||
RequiredIf: make(map[string]any),
|
||||
CurrentValue: fieldValue.Interface(),
|
||||
IsEmpty: false,
|
||||
TypeString: fieldType,
|
||||
|
@ -141,8 +142,8 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
|
|||
// now check if the field has required_if
|
||||
requiredIf := field.Tag.Get("required_if")
|
||||
if requiredIf != "" {
|
||||
requiredIfParts := strings.Split(requiredIf, ",")
|
||||
for _, part := range requiredIfParts {
|
||||
requiredIfParts := strings.SplitSeq(requiredIf, ",")
|
||||
for part := range requiredIfParts {
|
||||
partVal := strings.SplitN(part, "=", 2)
|
||||
if len(partVal) != 2 {
|
||||
return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf)
|
||||
|
@ -167,7 +168,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateFields(config interface{}, fields map[string]FieldConfig) error {
|
||||
func validateFields(config any, fields map[string]FieldConfig) error {
|
||||
// now we can start to validate the fields
|
||||
for _, fieldConfig := range fields {
|
||||
if err := fieldConfig.validate(fields); err != nil {
|
||||
|
@ -214,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (f *FieldConfig) populate(config interface{}) {
|
||||
func (f *FieldConfig) populate(config any) {
|
||||
// update the field if it's not empty
|
||||
if !f.shouldUpdateValue {
|
||||
return
|
||||
|
@ -372,6 +373,10 @@ func (f *FieldConfig) validateField() error {
|
|||
if _, err := idna.Lookup.ToASCII(val); err != nil {
|
||||
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:
|
||||
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
|
||||
}
|
||||
|
|
|
@ -43,9 +43,11 @@ type testNetworkConfig struct {
|
|||
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
|
||||
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"`
|
||||
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"`
|
||||
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
|
||||
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
||||
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) {
|
||||
|
|
|
@ -16,7 +16,7 @@ func splitString(s string) []string {
|
|||
return strings.Split(s, ",")
|
||||
}
|
||||
|
||||
func toString(v interface{}) (string, error) {
|
||||
func toString(v any) (string, error) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
|
|
|
@ -50,7 +50,7 @@ var (
|
|||
TimeFormat: time.RFC3339,
|
||||
PartsOrder: []string{"time", "level", "scope", "component", "message"},
|
||||
FieldsExclude: []string{"scope", "component"},
|
||||
FormatPartValueByName: func(value interface{}, name string) string {
|
||||
FormatPartValueByName: func(value any, name string) string {
|
||||
val := fmt.Sprintf("%s", value)
|
||||
if name == "component" {
|
||||
if value == nil {
|
||||
|
@ -121,8 +121,8 @@ func (l *Logger) updateLogLevel() {
|
|||
continue
|
||||
}
|
||||
|
||||
scopes := strings.Split(strings.ToLower(env), ",")
|
||||
for _, scope := range scopes {
|
||||
scopes := strings.SplitSeq(strings.ToLower(env), ",")
|
||||
for scope := range scopes {
|
||||
l.scopeLevels[scope] = level
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,32 +13,32 @@ type pionLogger struct {
|
|||
func (c pionLogger) Trace(msg string) {
|
||||
c.logger.Trace().Msg(msg)
|
||||
}
|
||||
func (c pionLogger) Tracef(format string, args ...interface{}) {
|
||||
func (c pionLogger) Tracef(format string, args ...any) {
|
||||
c.logger.Trace().Msgf(format, args...)
|
||||
}
|
||||
|
||||
func (c pionLogger) Debug(msg string) {
|
||||
c.logger.Debug().Msg(msg)
|
||||
}
|
||||
func (c pionLogger) Debugf(format string, args ...interface{}) {
|
||||
func (c pionLogger) Debugf(format string, args ...any) {
|
||||
c.logger.Debug().Msgf(format, args...)
|
||||
}
|
||||
func (c pionLogger) Info(msg string) {
|
||||
c.logger.Info().Msg(msg)
|
||||
}
|
||||
func (c pionLogger) Infof(format string, args ...interface{}) {
|
||||
func (c pionLogger) Infof(format string, args ...any) {
|
||||
c.logger.Info().Msgf(format, args...)
|
||||
}
|
||||
func (c pionLogger) Warn(msg string) {
|
||||
c.logger.Warn().Msg(msg)
|
||||
}
|
||||
func (c pionLogger) Warnf(format string, args ...interface{}) {
|
||||
func (c pionLogger) Warnf(format string, args ...any) {
|
||||
c.logger.Warn().Msgf(format, args...)
|
||||
}
|
||||
func (c pionLogger) Error(msg string) {
|
||||
c.logger.Error().Msg(msg)
|
||||
}
|
||||
func (c pionLogger) Errorf(format string, args ...interface{}) {
|
||||
func (c pionLogger) Errorf(format string, args ...any) {
|
||||
c.logger.Error().Msgf(format, args...)
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ func GetDefaultLogger() *zerolog.Logger {
|
|||
return &defaultLogger
|
||||
}
|
||||
|
||||
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
|
||||
func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
|
||||
// TODO: move rootLogger to logging package
|
||||
if l == nil {
|
||||
l = &defaultLogger
|
||||
|
|
|
@ -3,6 +3,8 @@ package network
|
|||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/guregu/null/v6"
|
||||
|
@ -32,8 +34,9 @@ type IPv6StaticConfig struct {
|
|||
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
|
||||
}
|
||||
type NetworkConfig struct {
|
||||
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
||||
Domain null.String `json:"domain,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"`
|
||||
|
||||
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
|
||||
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
|
||||
|
@ -45,9 +48,11 @@ type NetworkConfig struct {
|
|||
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
|
||||
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"`
|
||||
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"`
|
||||
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
|
||||
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
||||
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 {
|
||||
|
@ -69,6 +74,18 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
|
|||
|
||||
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 {
|
||||
hostname := ToValidHostname(s.config.Hostname.String)
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ func updateEtcHosts(hostname string, fqdn string) error {
|
|||
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
|
||||
hostLineExists := false
|
||||
|
||||
for _, line := range strings.Split(string(lines), "\n") {
|
||||
for line := range strings.SplitSeq(string(lines), "\n") {
|
||||
if strings.HasPrefix(line, "127.0.1.1") {
|
||||
hostLineExists = true
|
||||
line = hostLine
|
||||
|
|
|
@ -21,6 +21,7 @@ type NetworkInterfaceState struct {
|
|||
ipv6Addr *net.IP
|
||||
ipv6Addresses []IPv6Address
|
||||
ipv6LinkLocal *net.IP
|
||||
ntpAddresses []*net.IP
|
||||
macAddr *net.HardwareAddr
|
||||
|
||||
l *zerolog.Logger
|
||||
|
@ -76,6 +77,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
|
|||
onInitialCheck: opts.OnInitialCheck,
|
||||
cbConfigChange: opts.OnConfigChange,
|
||||
config: opts.NetworkConfig,
|
||||
ntpAddresses: make([]*net.IP, 0),
|
||||
}
|
||||
|
||||
// create the dhcp client
|
||||
|
@ -89,7 +91,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
|
|||
opts.Logger.Error().Err(err).Msg("failed to update network state")
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.updateNtpServersFromLease(lease)
|
||||
_ = s.setHostnameIfNotSame()
|
||||
|
||||
opts.OnDhcpLeaseChange(lease)
|
||||
|
@ -135,6 +137,27 @@ func (s *NetworkInterfaceState) IPv6String() 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 {
|
||||
return s.macAddr
|
||||
}
|
||||
|
@ -318,6 +341,25 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
|
|||
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 {
|
||||
dhcpTargetState, err := s.update()
|
||||
if err != nil {
|
||||
|
|
|
@ -13,7 +13,7 @@ func lifetimeToTime(lifetime int) *time.Time {
|
|||
return &t
|
||||
}
|
||||
|
||||
func IsSame(a, b interface{}) bool {
|
||||
func IsSame(a, b any) bool {
|
||||
aJSON, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return false
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
@ -19,9 +20,9 @@ var defaultHTTPUrls = []string{
|
|||
// "http://www.msftconnecttest.com/connecttest.txt",
|
||||
}
|
||||
|
||||
func (t *TimeSync) queryAllHttpTime() (now *time.Time) {
|
||||
chunkSize := 4
|
||||
httpUrls := t.httpUrls
|
||||
func (t *TimeSync) queryAllHttpTime(httpUrls []string) (now *time.Time) {
|
||||
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
|
||||
t.l.Info().Strs("httpUrls", httpUrls).Int("chunkSize", chunkSize).Msg("querying HTTP URLs")
|
||||
|
||||
// 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] })
|
||||
|
@ -57,6 +58,7 @@ func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now
|
|||
ctx,
|
||||
url,
|
||||
timeout,
|
||||
t.networkConfig.GetTransportProxyFunc(),
|
||||
)
|
||||
duration := time.Since(startTime)
|
||||
|
||||
|
@ -122,10 +124,16 @@ func queryHttpTime(
|
|||
ctx context.Context,
|
||||
url string,
|
||||
timeout time.Duration,
|
||||
proxyFunc func(*http.Request) (*url.URL, error),
|
||||
) (now *time.Time, response *http.Response, err error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = proxyFunc
|
||||
|
||||
client := http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
|
|
@ -73,6 +73,7 @@ var (
|
|||
},
|
||||
[]string{"url"},
|
||||
)
|
||||
|
||||
metricNtpServerInfo = promauto.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "jetkvm_timesync_ntp_server_info",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package timesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"time"
|
||||
|
@ -21,9 +22,9 @@ var defaultNTPServers = []string{
|
|||
"3.pool.ntp.org",
|
||||
}
|
||||
|
||||
func (t *TimeSync) queryNetworkTime() (now *time.Time, offset *time.Duration) {
|
||||
chunkSize := 4
|
||||
ntpServers := t.ntpServers
|
||||
func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) {
|
||||
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
|
||||
t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP 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] })
|
||||
|
@ -46,6 +47,10 @@ type ntpResult struct {
|
|||
|
||||
func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (now *time.Time, offset *time.Duration) {
|
||||
results := make(chan *ntpResult, len(servers))
|
||||
|
||||
_, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
for _, server := range servers {
|
||||
go func(server string) {
|
||||
scopedLogger := t.l.With().
|
||||
|
@ -66,15 +71,25 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no
|
|||
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
|
||||
metricNtpServerLastRTT.WithLabelValues(
|
||||
server,
|
||||
).Set(float64(response.RTT.Milliseconds()))
|
||||
).Set(rtt)
|
||||
|
||||
// set the RTT histogram
|
||||
metricNtpServerRttHistogram.WithLabelValues(
|
||||
server,
|
||||
).Observe(float64(response.RTT.Milliseconds()))
|
||||
).Observe(rtt)
|
||||
|
||||
// set the server info
|
||||
metricNtpServerInfo.WithLabelValues(
|
||||
|
@ -91,10 +106,13 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no
|
|||
scopedLogger.Info().
|
||||
Str("time", now.Format(time.RFC3339)).
|
||||
Str("reference", response.ReferenceString()).
|
||||
Str("rtt", response.RTT.String()).
|
||||
Float64("rtt", rtt).
|
||||
Str("clockOffset", response.ClockOffset.String()).
|
||||
Uint8("stratum", response.Stratum).
|
||||
Msg("NTP server returned time")
|
||||
|
||||
cancel()
|
||||
|
||||
results <- &ntpResult{
|
||||
now: now,
|
||||
offset: &response.ClockOffset,
|
||||
|
|
|
@ -28,9 +28,8 @@ type TimeSync struct {
|
|||
syncLock *sync.Mutex
|
||||
l *zerolog.Logger
|
||||
|
||||
ntpServers []string
|
||||
httpUrls []string
|
||||
networkConfig *network.NetworkConfig
|
||||
networkConfig *network.NetworkConfig
|
||||
dhcpNtpAddresses []string
|
||||
|
||||
rtcDevicePath string
|
||||
rtcDevice *os.File //nolint:unused
|
||||
|
@ -64,14 +63,13 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
|
|||
}
|
||||
|
||||
t := &TimeSync{
|
||||
syncLock: &sync.Mutex{},
|
||||
l: opts.Logger,
|
||||
rtcDevicePath: rtcDevice,
|
||||
rtcLock: &sync.Mutex{},
|
||||
preCheckFunc: opts.PreCheckFunc,
|
||||
ntpServers: defaultNTPServers,
|
||||
httpUrls: defaultHTTPUrls,
|
||||
networkConfig: opts.NetworkConfig,
|
||||
syncLock: &sync.Mutex{},
|
||||
l: opts.Logger,
|
||||
dhcpNtpAddresses: []string{},
|
||||
rtcDevicePath: rtcDevice,
|
||||
rtcLock: &sync.Mutex{},
|
||||
preCheckFunc: opts.PreCheckFunc,
|
||||
networkConfig: opts.NetworkConfig,
|
||||
}
|
||||
|
||||
if t.rtcDevicePath != "" {
|
||||
|
@ -82,34 +80,42 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
|
|||
return t
|
||||
}
|
||||
|
||||
func (t *TimeSync) SetDhcpNtpAddresses(addresses []string) {
|
||||
t.dhcpNtpAddresses = addresses
|
||||
}
|
||||
|
||||
func (t *TimeSync) getSyncMode() SyncMode {
|
||||
syncMode := SyncMode{
|
||||
Ntp: true,
|
||||
Http: true,
|
||||
Ordering: []string{"ntp_dhcp", "ntp", "http"},
|
||||
NtpUseFallback: true,
|
||||
HttpUseFallback: true,
|
||||
}
|
||||
var syncModeString string
|
||||
|
||||
if t.networkConfig != nil {
|
||||
syncModeString = t.networkConfig.TimeSyncMode.String
|
||||
switch t.networkConfig.TimeSyncMode.String {
|
||||
case "ntp_only":
|
||||
syncMode.Http = false
|
||||
case "http_only":
|
||||
syncMode.Ntp = false
|
||||
}
|
||||
|
||||
if t.networkConfig.TimeSyncDisableFallback.Bool {
|
||||
syncMode.NtpUseFallback = false
|
||||
syncMode.HttpUseFallback = false
|
||||
}
|
||||
|
||||
var syncOrdering = t.networkConfig.TimeSyncOrdering
|
||||
if len(syncOrdering) > 0 {
|
||||
syncMode.Ordering = syncOrdering
|
||||
}
|
||||
}
|
||||
|
||||
switch syncModeString {
|
||||
case "ntp_only":
|
||||
syncMode.Ntp = true
|
||||
case "http_only":
|
||||
syncMode.Http = true
|
||||
default:
|
||||
syncMode.Ntp = true
|
||||
syncMode.Http = true
|
||||
}
|
||||
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")
|
||||
|
||||
return syncMode
|
||||
}
|
||||
|
||||
func (t *TimeSync) doTimeSync() {
|
||||
metricTimeSyncStatus.Set(0)
|
||||
for {
|
||||
|
@ -154,16 +160,61 @@ func (t *TimeSync) Sync() error {
|
|||
offset *time.Duration
|
||||
)
|
||||
|
||||
syncMode := t.getSyncMode()
|
||||
|
||||
metricTimeSyncCount.Inc()
|
||||
|
||||
if syncMode.Ntp {
|
||||
now, offset = t.queryNetworkTime()
|
||||
}
|
||||
syncMode := t.getSyncMode()
|
||||
|
||||
if syncMode.Http && now == nil {
|
||||
now = t.queryAllHttpTime()
|
||||
Orders:
|
||||
for _, mode := range syncMode.Ordering {
|
||||
switch mode {
|
||||
case "ntp_user_provided":
|
||||
if syncMode.Ntp {
|
||||
t.l.Info().Msg("using NTP custom servers")
|
||||
now, offset = t.queryNetworkTime(t.networkConfig.TimeSyncNTPServers)
|
||||
if now != nil {
|
||||
t.l.Info().Str("source", "NTP").Time("now", *now).Msg("time obtained")
|
||||
break Orders
|
||||
}
|
||||
}
|
||||
case "ntp_dhcp":
|
||||
if syncMode.Ntp {
|
||||
t.l.Info().Msg("using NTP servers from DHCP")
|
||||
now, offset = t.queryNetworkTime(t.dhcpNtpAddresses)
|
||||
if now != nil {
|
||||
t.l.Info().Str("source", "NTP DHCP").Time("now", *now).Msg("time obtained")
|
||||
break Orders
|
||||
}
|
||||
}
|
||||
case "ntp":
|
||||
if syncMode.Ntp && syncMode.NtpUseFallback {
|
||||
t.l.Info().Msg("using NTP fallback")
|
||||
now, offset = t.queryNetworkTime(defaultNTPServers)
|
||||
if now != nil {
|
||||
t.l.Info().Str("source", "NTP fallback").Time("now", *now).Msg("time obtained")
|
||||
break Orders
|
||||
}
|
||||
}
|
||||
case "http_user_provided":
|
||||
if syncMode.Http {
|
||||
t.l.Info().Msg("using HTTP custom URLs")
|
||||
now = t.queryAllHttpTime(t.networkConfig.TimeSyncHTTPUrls)
|
||||
if now != nil {
|
||||
t.l.Info().Str("source", "HTTP").Time("now", *now).Msg("time obtained")
|
||||
break Orders
|
||||
}
|
||||
}
|
||||
case "http":
|
||||
if syncMode.Http && syncMode.HttpUseFallback {
|
||||
t.l.Info().Msg("using HTTP fallback")
|
||||
now = t.queryAllHttpTime(defaultHTTPUrls)
|
||||
if now != nil {
|
||||
t.l.Info().Str("source", "HTTP fallback").Time("now", *now).Msg("time obtained")
|
||||
break Orders
|
||||
}
|
||||
}
|
||||
default:
|
||||
t.l.Warn().Str("mode", mode).Msg("unknown time sync mode, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
if now == nil {
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
var tmpl = `// Code generated by "go run gen.go". DO NOT EDIT.
|
||||
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
|
||||
package tzdata
|
||||
var TimeZones = []string{
|
||||
{{- range . }}
|
||||
"{{.}}",
|
||||
{{- end }}
|
||||
}
|
||||
`
|
||||
|
||||
var filename = flag.String("output", "tzdata.go", "output file name")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
path := os.Getenv("ZONEINFO")
|
||||
if path == "" {
|
||||
fmt.Println("ZONEINFO is not set")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
fmt.Printf("ZONEINFO %s does not exist\n", path)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
zipfile, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
fmt.Printf("Error opening ZONEINFO %s: %v\n", path, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer zipfile.Close()
|
||||
|
||||
timezones := []string{}
|
||||
|
||||
for _, file := range zipfile.File {
|
||||
timezones = append(timezones, file.Name)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
tmpl, err := template.New("tzdata").Parse(tmpl)
|
||||
if err != nil {
|
||||
fmt.Printf("Error parsing template: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = tmpl.Execute(&buf, timezones)
|
||||
if err != nil {
|
||||
fmt.Printf("Error executing template: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = os.WriteFile(*filename, buf.Bytes(), 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("Error writing file %s: %v\n", *filename, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,602 @@
|
|||
// Code generated by "go run gen.go". DO NOT EDIT.
|
||||
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
|
||||
package tzdata
|
||||
var TimeZones = []string{
|
||||
"Africa/Abidjan",
|
||||
"Africa/Accra",
|
||||
"Africa/Addis_Ababa",
|
||||
"Africa/Algiers",
|
||||
"Africa/Asmara",
|
||||
"Africa/Asmera",
|
||||
"Africa/Bamako",
|
||||
"Africa/Bangui",
|
||||
"Africa/Banjul",
|
||||
"Africa/Bissau",
|
||||
"Africa/Blantyre",
|
||||
"Africa/Brazzaville",
|
||||
"Africa/Bujumbura",
|
||||
"Africa/Cairo",
|
||||
"Africa/Casablanca",
|
||||
"Africa/Ceuta",
|
||||
"Africa/Conakry",
|
||||
"Africa/Dakar",
|
||||
"Africa/Dar_es_Salaam",
|
||||
"Africa/Djibouti",
|
||||
"Africa/Douala",
|
||||
"Africa/El_Aaiun",
|
||||
"Africa/Freetown",
|
||||
"Africa/Gaborone",
|
||||
"Africa/Harare",
|
||||
"Africa/Johannesburg",
|
||||
"Africa/Juba",
|
||||
"Africa/Kampala",
|
||||
"Africa/Khartoum",
|
||||
"Africa/Kigali",
|
||||
"Africa/Kinshasa",
|
||||
"Africa/Lagos",
|
||||
"Africa/Libreville",
|
||||
"Africa/Lome",
|
||||
"Africa/Luanda",
|
||||
"Africa/Lubumbashi",
|
||||
"Africa/Lusaka",
|
||||
"Africa/Malabo",
|
||||
"Africa/Maputo",
|
||||
"Africa/Maseru",
|
||||
"Africa/Mbabane",
|
||||
"Africa/Mogadishu",
|
||||
"Africa/Monrovia",
|
||||
"Africa/Nairobi",
|
||||
"Africa/Ndjamena",
|
||||
"Africa/Niamey",
|
||||
"Africa/Nouakchott",
|
||||
"Africa/Ouagadougou",
|
||||
"Africa/Porto-Novo",
|
||||
"Africa/Sao_Tome",
|
||||
"Africa/Timbuktu",
|
||||
"Africa/Tripoli",
|
||||
"Africa/Tunis",
|
||||
"Africa/Windhoek",
|
||||
"America/Adak",
|
||||
"America/Anchorage",
|
||||
"America/Anguilla",
|
||||
"America/Antigua",
|
||||
"America/Araguaina",
|
||||
"America/Argentina/Buenos_Aires",
|
||||
"America/Argentina/Catamarca",
|
||||
"America/Argentina/ComodRivadavia",
|
||||
"America/Argentina/Cordoba",
|
||||
"America/Argentina/Jujuy",
|
||||
"America/Argentina/La_Rioja",
|
||||
"America/Argentina/Mendoza",
|
||||
"America/Argentina/Rio_Gallegos",
|
||||
"America/Argentina/Salta",
|
||||
"America/Argentina/San_Juan",
|
||||
"America/Argentina/San_Luis",
|
||||
"America/Argentina/Tucuman",
|
||||
"America/Argentina/Ushuaia",
|
||||
"America/Aruba",
|
||||
"America/Asuncion",
|
||||
"America/Atikokan",
|
||||
"America/Atka",
|
||||
"America/Bahia",
|
||||
"America/Bahia_Banderas",
|
||||
"America/Barbados",
|
||||
"America/Belem",
|
||||
"America/Belize",
|
||||
"America/Blanc-Sablon",
|
||||
"America/Boa_Vista",
|
||||
"America/Bogota",
|
||||
"America/Boise",
|
||||
"America/Buenos_Aires",
|
||||
"America/Cambridge_Bay",
|
||||
"America/Campo_Grande",
|
||||
"America/Cancun",
|
||||
"America/Caracas",
|
||||
"America/Catamarca",
|
||||
"America/Cayenne",
|
||||
"America/Cayman",
|
||||
"America/Chicago",
|
||||
"America/Chihuahua",
|
||||
"America/Ciudad_Juarez",
|
||||
"America/Coral_Harbour",
|
||||
"America/Cordoba",
|
||||
"America/Costa_Rica",
|
||||
"America/Creston",
|
||||
"America/Cuiaba",
|
||||
"America/Curacao",
|
||||
"America/Danmarkshavn",
|
||||
"America/Dawson",
|
||||
"America/Dawson_Creek",
|
||||
"America/Denver",
|
||||
"America/Detroit",
|
||||
"America/Dominica",
|
||||
"America/Edmonton",
|
||||
"America/Eirunepe",
|
||||
"America/El_Salvador",
|
||||
"America/Ensenada",
|
||||
"America/Fort_Nelson",
|
||||
"America/Fort_Wayne",
|
||||
"America/Fortaleza",
|
||||
"America/Glace_Bay",
|
||||
"America/Godthab",
|
||||
"America/Goose_Bay",
|
||||
"America/Grand_Turk",
|
||||
"America/Grenada",
|
||||
"America/Guadeloupe",
|
||||
"America/Guatemala",
|
||||
"America/Guayaquil",
|
||||
"America/Guyana",
|
||||
"America/Halifax",
|
||||
"America/Havana",
|
||||
"America/Hermosillo",
|
||||
"America/Indiana/Indianapolis",
|
||||
"America/Indiana/Knox",
|
||||
"America/Indiana/Marengo",
|
||||
"America/Indiana/Petersburg",
|
||||
"America/Indiana/Tell_City",
|
||||
"America/Indiana/Vevay",
|
||||
"America/Indiana/Vincennes",
|
||||
"America/Indiana/Winamac",
|
||||
"America/Indianapolis",
|
||||
"America/Inuvik",
|
||||
"America/Iqaluit",
|
||||
"America/Jamaica",
|
||||
"America/Jujuy",
|
||||
"America/Juneau",
|
||||
"America/Kentucky/Louisville",
|
||||
"America/Kentucky/Monticello",
|
||||
"America/Knox_IN",
|
||||
"America/Kralendijk",
|
||||
"America/La_Paz",
|
||||
"America/Lima",
|
||||
"America/Los_Angeles",
|
||||
"America/Louisville",
|
||||
"America/Lower_Princes",
|
||||
"America/Maceio",
|
||||
"America/Managua",
|
||||
"America/Manaus",
|
||||
"America/Marigot",
|
||||
"America/Martinique",
|
||||
"America/Matamoros",
|
||||
"America/Mazatlan",
|
||||
"America/Mendoza",
|
||||
"America/Menominee",
|
||||
"America/Merida",
|
||||
"America/Metlakatla",
|
||||
"America/Mexico_City",
|
||||
"America/Miquelon",
|
||||
"America/Moncton",
|
||||
"America/Monterrey",
|
||||
"America/Montevideo",
|
||||
"America/Montreal",
|
||||
"America/Montserrat",
|
||||
"America/Nassau",
|
||||
"America/New_York",
|
||||
"America/Nipigon",
|
||||
"America/Nome",
|
||||
"America/Noronha",
|
||||
"America/North_Dakota/Beulah",
|
||||
"America/North_Dakota/Center",
|
||||
"America/North_Dakota/New_Salem",
|
||||
"America/Nuuk",
|
||||
"America/Ojinaga",
|
||||
"America/Panama",
|
||||
"America/Pangnirtung",
|
||||
"America/Paramaribo",
|
||||
"America/Phoenix",
|
||||
"America/Port-au-Prince",
|
||||
"America/Port_of_Spain",
|
||||
"America/Porto_Acre",
|
||||
"America/Porto_Velho",
|
||||
"America/Puerto_Rico",
|
||||
"America/Punta_Arenas",
|
||||
"America/Rainy_River",
|
||||
"America/Rankin_Inlet",
|
||||
"America/Recife",
|
||||
"America/Regina",
|
||||
"America/Resolute",
|
||||
"America/Rio_Branco",
|
||||
"America/Rosario",
|
||||
"America/Santa_Isabel",
|
||||
"America/Santarem",
|
||||
"America/Santiago",
|
||||
"America/Santo_Domingo",
|
||||
"America/Sao_Paulo",
|
||||
"America/Scoresbysund",
|
||||
"America/Shiprock",
|
||||
"America/Sitka",
|
||||
"America/St_Barthelemy",
|
||||
"America/St_Johns",
|
||||
"America/St_Kitts",
|
||||
"America/St_Lucia",
|
||||
"America/St_Thomas",
|
||||
"America/St_Vincent",
|
||||
"America/Swift_Current",
|
||||
"America/Tegucigalpa",
|
||||
"America/Thule",
|
||||
"America/Thunder_Bay",
|
||||
"America/Tijuana",
|
||||
"America/Toronto",
|
||||
"America/Tortola",
|
||||
"America/Vancouver",
|
||||
"America/Virgin",
|
||||
"America/Whitehorse",
|
||||
"America/Winnipeg",
|
||||
"America/Yakutat",
|
||||
"America/Yellowknife",
|
||||
"Antarctica/Casey",
|
||||
"Antarctica/Davis",
|
||||
"Antarctica/DumontDUrville",
|
||||
"Antarctica/Macquarie",
|
||||
"Antarctica/Mawson",
|
||||
"Antarctica/McMurdo",
|
||||
"Antarctica/Palmer",
|
||||
"Antarctica/Rothera",
|
||||
"Antarctica/South_Pole",
|
||||
"Antarctica/Syowa",
|
||||
"Antarctica/Troll",
|
||||
"Antarctica/Vostok",
|
||||
"Arctic/Longyearbyen",
|
||||
"Asia/Aden",
|
||||
"Asia/Almaty",
|
||||
"Asia/Amman",
|
||||
"Asia/Anadyr",
|
||||
"Asia/Aqtau",
|
||||
"Asia/Aqtobe",
|
||||
"Asia/Ashgabat",
|
||||
"Asia/Ashkhabad",
|
||||
"Asia/Atyrau",
|
||||
"Asia/Baghdad",
|
||||
"Asia/Bahrain",
|
||||
"Asia/Baku",
|
||||
"Asia/Bangkok",
|
||||
"Asia/Barnaul",
|
||||
"Asia/Beirut",
|
||||
"Asia/Bishkek",
|
||||
"Asia/Brunei",
|
||||
"Asia/Calcutta",
|
||||
"Asia/Chita",
|
||||
"Asia/Choibalsan",
|
||||
"Asia/Chongqing",
|
||||
"Asia/Chungking",
|
||||
"Asia/Colombo",
|
||||
"Asia/Dacca",
|
||||
"Asia/Damascus",
|
||||
"Asia/Dhaka",
|
||||
"Asia/Dili",
|
||||
"Asia/Dubai",
|
||||
"Asia/Dushanbe",
|
||||
"Asia/Famagusta",
|
||||
"Asia/Gaza",
|
||||
"Asia/Harbin",
|
||||
"Asia/Hebron",
|
||||
"Asia/Ho_Chi_Minh",
|
||||
"Asia/Hong_Kong",
|
||||
"Asia/Hovd",
|
||||
"Asia/Irkutsk",
|
||||
"Asia/Istanbul",
|
||||
"Asia/Jakarta",
|
||||
"Asia/Jayapura",
|
||||
"Asia/Jerusalem",
|
||||
"Asia/Kabul",
|
||||
"Asia/Kamchatka",
|
||||
"Asia/Karachi",
|
||||
"Asia/Kashgar",
|
||||
"Asia/Kathmandu",
|
||||
"Asia/Katmandu",
|
||||
"Asia/Khandyga",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Krasnoyarsk",
|
||||
"Asia/Kuala_Lumpur",
|
||||
"Asia/Kuching",
|
||||
"Asia/Kuwait",
|
||||
"Asia/Macao",
|
||||
"Asia/Macau",
|
||||
"Asia/Magadan",
|
||||
"Asia/Makassar",
|
||||
"Asia/Manila",
|
||||
"Asia/Muscat",
|
||||
"Asia/Nicosia",
|
||||
"Asia/Novokuznetsk",
|
||||
"Asia/Novosibirsk",
|
||||
"Asia/Omsk",
|
||||
"Asia/Oral",
|
||||
"Asia/Phnom_Penh",
|
||||
"Asia/Pontianak",
|
||||
"Asia/Pyongyang",
|
||||
"Asia/Qatar",
|
||||
"Asia/Qostanay",
|
||||
"Asia/Qyzylorda",
|
||||
"Asia/Rangoon",
|
||||
"Asia/Riyadh",
|
||||
"Asia/Saigon",
|
||||
"Asia/Sakhalin",
|
||||
"Asia/Samarkand",
|
||||
"Asia/Seoul",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Singapore",
|
||||
"Asia/Srednekolymsk",
|
||||
"Asia/Taipei",
|
||||
"Asia/Tashkent",
|
||||
"Asia/Tbilisi",
|
||||
"Asia/Tehran",
|
||||
"Asia/Tel_Aviv",
|
||||
"Asia/Thimbu",
|
||||
"Asia/Thimphu",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Tomsk",
|
||||
"Asia/Ujung_Pandang",
|
||||
"Asia/Ulaanbaatar",
|
||||
"Asia/Ulan_Bator",
|
||||
"Asia/Urumqi",
|
||||
"Asia/Ust-Nera",
|
||||
"Asia/Vientiane",
|
||||
"Asia/Vladivostok",
|
||||
"Asia/Yakutsk",
|
||||
"Asia/Yangon",
|
||||
"Asia/Yekaterinburg",
|
||||
"Asia/Yerevan",
|
||||
"Atlantic/Azores",
|
||||
"Atlantic/Bermuda",
|
||||
"Atlantic/Canary",
|
||||
"Atlantic/Cape_Verde",
|
||||
"Atlantic/Faeroe",
|
||||
"Atlantic/Faroe",
|
||||
"Atlantic/Jan_Mayen",
|
||||
"Atlantic/Madeira",
|
||||
"Atlantic/Reykjavik",
|
||||
"Atlantic/South_Georgia",
|
||||
"Atlantic/St_Helena",
|
||||
"Atlantic/Stanley",
|
||||
"Australia/ACT",
|
||||
"Australia/Adelaide",
|
||||
"Australia/Brisbane",
|
||||
"Australia/Broken_Hill",
|
||||
"Australia/Canberra",
|
||||
"Australia/Currie",
|
||||
"Australia/Darwin",
|
||||
"Australia/Eucla",
|
||||
"Australia/Hobart",
|
||||
"Australia/LHI",
|
||||
"Australia/Lindeman",
|
||||
"Australia/Lord_Howe",
|
||||
"Australia/Melbourne",
|
||||
"Australia/NSW",
|
||||
"Australia/North",
|
||||
"Australia/Perth",
|
||||
"Australia/Queensland",
|
||||
"Australia/South",
|
||||
"Australia/Sydney",
|
||||
"Australia/Tasmania",
|
||||
"Australia/Victoria",
|
||||
"Australia/West",
|
||||
"Australia/Yancowinna",
|
||||
"Brazil/Acre",
|
||||
"Brazil/DeNoronha",
|
||||
"Brazil/East",
|
||||
"Brazil/West",
|
||||
"CET",
|
||||
"CST6CDT",
|
||||
"Canada/Atlantic",
|
||||
"Canada/Central",
|
||||
"Canada/Eastern",
|
||||
"Canada/Mountain",
|
||||
"Canada/Newfoundland",
|
||||
"Canada/Pacific",
|
||||
"Canada/Saskatchewan",
|
||||
"Canada/Yukon",
|
||||
"Chile/Continental",
|
||||
"Chile/EasterIsland",
|
||||
"Cuba",
|
||||
"EET",
|
||||
"EST",
|
||||
"EST5EDT",
|
||||
"Egypt",
|
||||
"Eire",
|
||||
"Etc/GMT",
|
||||
"Etc/GMT+0",
|
||||
"Etc/GMT+1",
|
||||
"Etc/GMT+10",
|
||||
"Etc/GMT+11",
|
||||
"Etc/GMT+12",
|
||||
"Etc/GMT+2",
|
||||
"Etc/GMT+3",
|
||||
"Etc/GMT+4",
|
||||
"Etc/GMT+5",
|
||||
"Etc/GMT+6",
|
||||
"Etc/GMT+7",
|
||||
"Etc/GMT+8",
|
||||
"Etc/GMT+9",
|
||||
"Etc/GMT-0",
|
||||
"Etc/GMT-1",
|
||||
"Etc/GMT-10",
|
||||
"Etc/GMT-11",
|
||||
"Etc/GMT-12",
|
||||
"Etc/GMT-13",
|
||||
"Etc/GMT-14",
|
||||
"Etc/GMT-2",
|
||||
"Etc/GMT-3",
|
||||
"Etc/GMT-4",
|
||||
"Etc/GMT-5",
|
||||
"Etc/GMT-6",
|
||||
"Etc/GMT-7",
|
||||
"Etc/GMT-8",
|
||||
"Etc/GMT-9",
|
||||
"Etc/GMT0",
|
||||
"Etc/Greenwich",
|
||||
"Etc/UCT",
|
||||
"Etc/UTC",
|
||||
"Etc/Universal",
|
||||
"Etc/Zulu",
|
||||
"Europe/Amsterdam",
|
||||
"Europe/Andorra",
|
||||
"Europe/Astrakhan",
|
||||
"Europe/Athens",
|
||||
"Europe/Belfast",
|
||||
"Europe/Belgrade",
|
||||
"Europe/Berlin",
|
||||
"Europe/Bratislava",
|
||||
"Europe/Brussels",
|
||||
"Europe/Bucharest",
|
||||
"Europe/Budapest",
|
||||
"Europe/Busingen",
|
||||
"Europe/Chisinau",
|
||||
"Europe/Copenhagen",
|
||||
"Europe/Dublin",
|
||||
"Europe/Gibraltar",
|
||||
"Europe/Guernsey",
|
||||
"Europe/Helsinki",
|
||||
"Europe/Isle_of_Man",
|
||||
"Europe/Istanbul",
|
||||
"Europe/Jersey",
|
||||
"Europe/Kaliningrad",
|
||||
"Europe/Kiev",
|
||||
"Europe/Kirov",
|
||||
"Europe/Kyiv",
|
||||
"Europe/Lisbon",
|
||||
"Europe/Ljubljana",
|
||||
"Europe/London",
|
||||
"Europe/Luxembourg",
|
||||
"Europe/Madrid",
|
||||
"Europe/Malta",
|
||||
"Europe/Mariehamn",
|
||||
"Europe/Minsk",
|
||||
"Europe/Monaco",
|
||||
"Europe/Moscow",
|
||||
"Europe/Nicosia",
|
||||
"Europe/Oslo",
|
||||
"Europe/Paris",
|
||||
"Europe/Podgorica",
|
||||
"Europe/Prague",
|
||||
"Europe/Riga",
|
||||
"Europe/Rome",
|
||||
"Europe/Samara",
|
||||
"Europe/San_Marino",
|
||||
"Europe/Sarajevo",
|
||||
"Europe/Saratov",
|
||||
"Europe/Simferopol",
|
||||
"Europe/Skopje",
|
||||
"Europe/Sofia",
|
||||
"Europe/Stockholm",
|
||||
"Europe/Tallinn",
|
||||
"Europe/Tirane",
|
||||
"Europe/Tiraspol",
|
||||
"Europe/Ulyanovsk",
|
||||
"Europe/Uzhgorod",
|
||||
"Europe/Vaduz",
|
||||
"Europe/Vatican",
|
||||
"Europe/Vienna",
|
||||
"Europe/Vilnius",
|
||||
"Europe/Volgograd",
|
||||
"Europe/Warsaw",
|
||||
"Europe/Zagreb",
|
||||
"Europe/Zaporozhye",
|
||||
"Europe/Zurich",
|
||||
"Factory",
|
||||
"GB",
|
||||
"GB-Eire",
|
||||
"GMT",
|
||||
"GMT+0",
|
||||
"GMT-0",
|
||||
"GMT0",
|
||||
"Greenwich",
|
||||
"HST",
|
||||
"Hongkong",
|
||||
"Iceland",
|
||||
"Indian/Antananarivo",
|
||||
"Indian/Chagos",
|
||||
"Indian/Christmas",
|
||||
"Indian/Cocos",
|
||||
"Indian/Comoro",
|
||||
"Indian/Kerguelen",
|
||||
"Indian/Mahe",
|
||||
"Indian/Maldives",
|
||||
"Indian/Mauritius",
|
||||
"Indian/Mayotte",
|
||||
"Indian/Reunion",
|
||||
"Iran",
|
||||
"Israel",
|
||||
"Jamaica",
|
||||
"Japan",
|
||||
"Kwajalein",
|
||||
"Libya",
|
||||
"MET",
|
||||
"MST",
|
||||
"MST7MDT",
|
||||
"Mexico/BajaNorte",
|
||||
"Mexico/BajaSur",
|
||||
"Mexico/General",
|
||||
"NZ",
|
||||
"NZ-CHAT",
|
||||
"Navajo",
|
||||
"PRC",
|
||||
"PST8PDT",
|
||||
"Pacific/Apia",
|
||||
"Pacific/Auckland",
|
||||
"Pacific/Bougainville",
|
||||
"Pacific/Chatham",
|
||||
"Pacific/Chuuk",
|
||||
"Pacific/Easter",
|
||||
"Pacific/Efate",
|
||||
"Pacific/Enderbury",
|
||||
"Pacific/Fakaofo",
|
||||
"Pacific/Fiji",
|
||||
"Pacific/Funafuti",
|
||||
"Pacific/Galapagos",
|
||||
"Pacific/Gambier",
|
||||
"Pacific/Guadalcanal",
|
||||
"Pacific/Guam",
|
||||
"Pacific/Honolulu",
|
||||
"Pacific/Johnston",
|
||||
"Pacific/Kanton",
|
||||
"Pacific/Kiritimati",
|
||||
"Pacific/Kosrae",
|
||||
"Pacific/Kwajalein",
|
||||
"Pacific/Majuro",
|
||||
"Pacific/Marquesas",
|
||||
"Pacific/Midway",
|
||||
"Pacific/Nauru",
|
||||
"Pacific/Niue",
|
||||
"Pacific/Norfolk",
|
||||
"Pacific/Noumea",
|
||||
"Pacific/Pago_Pago",
|
||||
"Pacific/Palau",
|
||||
"Pacific/Pitcairn",
|
||||
"Pacific/Pohnpei",
|
||||
"Pacific/Ponape",
|
||||
"Pacific/Port_Moresby",
|
||||
"Pacific/Rarotonga",
|
||||
"Pacific/Saipan",
|
||||
"Pacific/Samoa",
|
||||
"Pacific/Tahiti",
|
||||
"Pacific/Tarawa",
|
||||
"Pacific/Tongatapu",
|
||||
"Pacific/Truk",
|
||||
"Pacific/Wake",
|
||||
"Pacific/Wallis",
|
||||
"Pacific/Yap",
|
||||
"Poland",
|
||||
"Portugal",
|
||||
"ROC",
|
||||
"ROK",
|
||||
"Singapore",
|
||||
"Turkey",
|
||||
"UCT",
|
||||
"US/Alaska",
|
||||
"US/Aleutian",
|
||||
"US/Arizona",
|
||||
"US/Central",
|
||||
"US/East-Indiana",
|
||||
"US/Eastern",
|
||||
"US/Hawaii",
|
||||
"US/Indiana-Starke",
|
||||
"US/Michigan",
|
||||
"US/Mountain",
|
||||
"US/Pacific",
|
||||
"US/Samoa",
|
||||
"UTC",
|
||||
"Universal",
|
||||
"W-SU",
|
||||
"WET",
|
||||
"Zulu",
|
||||
}
|
|
@ -101,7 +101,7 @@ func (l *Lease) SetLeaseExpiry() (time.Time, error) {
|
|||
func UnmarshalDHCPCLease(lease *Lease, str string) error {
|
||||
// parse the lease file as a map
|
||||
data := make(map[string]string)
|
||||
for _, line := range strings.Split(str, "\n") {
|
||||
for line := range strings.SplitSeq(str, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
// skip empty lines and comments
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
|
@ -165,7 +165,7 @@ func UnmarshalDHCPCLease(lease *Lease, str string) error {
|
|||
field.Set(reflect.ValueOf(ip))
|
||||
case []net.IP:
|
||||
val := make([]net.IP, 0)
|
||||
for _, ipStr := range strings.Fields(value) {
|
||||
for ipStr := range strings.FieldsSeq(value) {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
continue
|
||||
|
|
|
@ -52,7 +52,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
|
|||
}
|
||||
|
||||
func (c *DHCPClient) getWatchPaths() []string {
|
||||
watchPaths := make(map[string]interface{})
|
||||
watchPaths := make(map[string]any)
|
||||
watchPaths[filepath.Dir(c.leaseFile)] = nil
|
||||
|
||||
if c.pidFile != "" {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
package usbgadget
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
@ -14,9 +14,10 @@ var keyboardConfig = gadgetConfigItem{
|
|||
path: []string{"functions", "hid.usb0"},
|
||||
configPath: []string{"hid.usb0"},
|
||||
attrs: gadgetAttributes{
|
||||
"protocol": "1",
|
||||
"subclass": "1",
|
||||
"report_length": "8",
|
||||
"protocol": "1",
|
||||
"subclass": "1",
|
||||
"report_length": "8",
|
||||
"no_out_endpoint": "0",
|
||||
},
|
||||
reportDesc: keyboardReportDesc,
|
||||
}
|
||||
|
@ -60,6 +61,8 @@ var keyboardReportDesc = []byte{
|
|||
|
||||
const (
|
||||
hidReadBufferSize = 8
|
||||
hidKeyBufferSize = 6
|
||||
hidErrorRollOver = 0x01
|
||||
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf
|
||||
// https://www.usb.org/sites/default/files/hut1_2.pdf
|
||||
KeyboardLedMaskNumLock = 1 << 0
|
||||
|
@ -67,7 +70,9 @@ const (
|
|||
KeyboardLedMaskScrollLock = 1 << 2
|
||||
KeyboardLedMaskCompose = 1 << 3
|
||||
KeyboardLedMaskKana = 1 << 4
|
||||
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana
|
||||
// power on/off LED is 5
|
||||
KeyboardLedMaskShift = 1 << 6
|
||||
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana | KeyboardLedMaskShift
|
||||
)
|
||||
|
||||
// Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK,
|
||||
|
@ -80,6 +85,7 @@ type KeyboardState struct {
|
|||
ScrollLock bool `json:"scroll_lock"`
|
||||
Compose bool `json:"compose"`
|
||||
Kana bool `json:"kana"`
|
||||
Shift bool `json:"shift"` // This is not part of the main USB HID spec
|
||||
}
|
||||
|
||||
func getKeyboardState(b byte) KeyboardState {
|
||||
|
@ -90,27 +96,27 @@ func getKeyboardState(b byte) KeyboardState {
|
|||
ScrollLock: b&KeyboardLedMaskScrollLock != 0,
|
||||
Compose: b&KeyboardLedMaskCompose != 0,
|
||||
Kana: b&KeyboardLedMaskKana != 0,
|
||||
Shift: b&KeyboardLedMaskShift != 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UsbGadget) updateKeyboardState(b byte) {
|
||||
func (u *UsbGadget) updateKeyboardState(state byte) {
|
||||
u.keyboardStateLock.Lock()
|
||||
defer u.keyboardStateLock.Unlock()
|
||||
|
||||
if b&^ValidKeyboardLedMasks != 0 {
|
||||
u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring")
|
||||
if state&^ValidKeyboardLedMasks != 0 {
|
||||
u.log.Warn().Uint8("state", state).Msg("ignoring invalid bits")
|
||||
return
|
||||
}
|
||||
|
||||
newState := getKeyboardState(b)
|
||||
if reflect.DeepEqual(u.keyboardState, newState) {
|
||||
if u.keyboardState == state {
|
||||
return
|
||||
}
|
||||
u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated")
|
||||
u.keyboardState = newState
|
||||
u.log.Trace().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated")
|
||||
u.keyboardState = state
|
||||
|
||||
if u.onKeyboardStateChange != nil {
|
||||
(*u.onKeyboardStateChange)(newState)
|
||||
(*u.onKeyboardStateChange)(getKeyboardState(state))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -122,7 +128,35 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
|
|||
u.keyboardStateLock.Lock()
|
||||
defer u.keyboardStateLock.Unlock()
|
||||
|
||||
return u.keyboardState
|
||||
return getKeyboardState(u.keyboardState)
|
||||
}
|
||||
|
||||
func (u *UsbGadget) GetKeysDownState() KeysDownState {
|
||||
u.keyboardStateLock.Lock()
|
||||
defer u.keyboardStateLock.Unlock()
|
||||
|
||||
return u.keysDownState
|
||||
}
|
||||
|
||||
func (u *UsbGadget) updateKeyDownState(state KeysDownState) {
|
||||
u.keyboardStateLock.Lock()
|
||||
defer u.keyboardStateLock.Unlock()
|
||||
|
||||
if u.keysDownState.Modifier == state.Modifier &&
|
||||
bytes.Equal(u.keysDownState.Keys, state.Keys) {
|
||||
return // No change in key down state
|
||||
}
|
||||
|
||||
u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("keysDownState updated")
|
||||
u.keysDownState = state
|
||||
|
||||
if u.onKeysDownChange != nil {
|
||||
(*u.onKeysDownChange)(state)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
|
||||
u.onKeysDownChange = &f
|
||||
}
|
||||
|
||||
func (u *UsbGadget) listenKeyboardEvents() {
|
||||
|
@ -141,7 +175,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
|
|||
l.Info().Msg("context done")
|
||||
return
|
||||
default:
|
||||
l.Trace().Msg("reading from keyboard")
|
||||
l.Trace().Msg("reading from keyboard for LED state changes")
|
||||
if u.keyboardHidFile == nil {
|
||||
u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
|
||||
// show the error every 100 times to avoid spamming the logs
|
||||
|
@ -158,7 +192,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
|
|||
}
|
||||
u.resetLogSuppressionCounter("keyboardHidFileRead")
|
||||
|
||||
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
|
||||
l.Trace().Int("n", n).Uints8("buf", buf).Msg("got data from keyboard")
|
||||
if n != 1 {
|
||||
l.Trace().Int("n", n).Msg("expected 1 byte, got")
|
||||
continue
|
||||
|
@ -194,12 +228,12 @@ func (u *UsbGadget) OpenKeyboardHidFile() error {
|
|||
return u.openKeyboardHidFile()
|
||||
}
|
||||
|
||||
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
|
||||
func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error {
|
||||
if err := u.openKeyboardHidFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := u.keyboardHidFile.Write(data)
|
||||
_, err := u.keyboardHidFile.Write(append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...))
|
||||
if err != nil {
|
||||
u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
|
||||
u.keyboardHidFile.Close()
|
||||
|
@ -210,22 +244,147 @@ func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
|
||||
func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState {
|
||||
// if we just reported an error roll over, we should clear the keys
|
||||
if keys[0] == hidErrorRollOver {
|
||||
for i := range keys {
|
||||
keys[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
downState := KeysDownState{
|
||||
Modifier: modifier,
|
||||
Keys: []byte(keys[:]),
|
||||
}
|
||||
u.updateKeyDownState(downState)
|
||||
return downState
|
||||
}
|
||||
|
||||
func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) (KeysDownState, error) {
|
||||
u.keyboardLock.Lock()
|
||||
defer u.keyboardLock.Unlock()
|
||||
defer u.resetUserInputTime()
|
||||
|
||||
if len(keys) > 6 {
|
||||
keys = keys[:6]
|
||||
u.log.Trace().Uint8("modifier", modifier).Bytes("keys", keys).Msg("KeyboardReport")
|
||||
|
||||
if len(keys) > hidKeyBufferSize {
|
||||
keys = keys[:hidKeyBufferSize]
|
||||
}
|
||||
if len(keys) < 6 {
|
||||
keys = append(keys, make([]uint8, 6-len(keys))...)
|
||||
if len(keys) < hidKeyBufferSize {
|
||||
keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...)
|
||||
}
|
||||
|
||||
err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]})
|
||||
err := u.keyboardWriteHidFile(modifier, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0")
|
||||
}
|
||||
|
||||
u.resetUserInputTime()
|
||||
return nil
|
||||
return u.UpdateKeysDown(modifier, keys), err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -11,9 +11,10 @@ var absoluteMouseConfig = gadgetConfigItem{
|
|||
path: []string{"functions", "hid.usb1"},
|
||||
configPath: []string{"hid.usb1"},
|
||||
attrs: gadgetAttributes{
|
||||
"protocol": "2",
|
||||
"subclass": "0",
|
||||
"report_length": "6",
|
||||
"protocol": "2",
|
||||
"subclass": "0",
|
||||
"report_length": "6",
|
||||
"no_out_endpoint": "1",
|
||||
},
|
||||
reportDesc: absoluteMouseCombinedReportDesc,
|
||||
}
|
||||
|
@ -84,17 +85,19 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) AbsMouseReport(x, y int, buttons uint8) error {
|
||||
func (u *UsbGadget) AbsMouseReport(x int, y int, buttons uint8) error {
|
||||
u.absMouseLock.Lock()
|
||||
defer u.absMouseLock.Unlock()
|
||||
|
||||
u.log.Trace().Int("x", x).Int("y", y).Uint8("buttons", buttons).Msg("AbsMouseReport")
|
||||
|
||||
err := u.absMouseWriteHidFile([]byte{
|
||||
1, // Report ID 1
|
||||
buttons, // Buttons
|
||||
uint8(x), // X Low Byte
|
||||
uint8(x >> 8), // X High Byte
|
||||
uint8(y), // Y Low Byte
|
||||
uint8(y >> 8), // Y High Byte
|
||||
1, // Report ID 1
|
||||
buttons, // Buttons
|
||||
byte(x), // X Low Byte
|
||||
byte(x >> 8), // X High Byte
|
||||
byte(y), // Y Low Byte
|
||||
byte(y >> 8), // Y High Byte
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -108,6 +111,8 @@ func (u *UsbGadget) AbsMouseWheelReport(wheelY int8) error {
|
|||
u.absMouseLock.Lock()
|
||||
defer u.absMouseLock.Unlock()
|
||||
|
||||
u.log.Trace().Int8("wheelY", wheelY).Msg("AbsMouseWheelReport")
|
||||
|
||||
// Only send a report if the value is non-zero
|
||||
if wheelY == 0 {
|
||||
return nil
|
||||
|
|
|
@ -11,9 +11,10 @@ var relativeMouseConfig = gadgetConfigItem{
|
|||
path: []string{"functions", "hid.usb2"},
|
||||
configPath: []string{"hid.usb2"},
|
||||
attrs: gadgetAttributes{
|
||||
"protocol": "2",
|
||||
"subclass": "1",
|
||||
"report_length": "4",
|
||||
"protocol": "2",
|
||||
"subclass": "1",
|
||||
"report_length": "4",
|
||||
"no_out_endpoint": "1",
|
||||
},
|
||||
reportDesc: relativeMouseCombinedReportDesc,
|
||||
}
|
||||
|
@ -74,15 +75,15 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) RelMouseReport(mx, my int8, buttons uint8) error {
|
||||
func (u *UsbGadget) RelMouseReport(mx int8, my int8, buttons uint8) error {
|
||||
u.relMouseLock.Lock()
|
||||
defer u.relMouseLock.Unlock()
|
||||
|
||||
err := u.relMouseWriteHidFile([]byte{
|
||||
buttons, // Buttons
|
||||
uint8(mx), // X
|
||||
uint8(my), // Y
|
||||
0, // Wheel
|
||||
buttons, // Buttons
|
||||
byte(mx), // X
|
||||
byte(my), // Y
|
||||
0, // Wheel
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -41,6 +41,11 @@ var defaultUsbGadgetDevices = Devices{
|
|||
MassStorage: true,
|
||||
}
|
||||
|
||||
type KeysDownState struct {
|
||||
Modifier byte `json:"modifier"`
|
||||
Keys ByteSlice `json:"keys"`
|
||||
}
|
||||
|
||||
// UsbGadget is a struct that represents a USB gadget.
|
||||
type UsbGadget struct {
|
||||
name string
|
||||
|
@ -60,7 +65,9 @@ type UsbGadget struct {
|
|||
relMouseHidFile *os.File
|
||||
relMouseLock sync.Mutex
|
||||
|
||||
keyboardState KeyboardState
|
||||
keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana)
|
||||
keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys)
|
||||
|
||||
keyboardStateLock sync.Mutex
|
||||
keyboardStateCtx context.Context
|
||||
keyboardStateCancel context.CancelFunc
|
||||
|
@ -77,6 +84,7 @@ type UsbGadget struct {
|
|||
txLock sync.Mutex
|
||||
|
||||
onKeyboardStateChange *func(state KeyboardState)
|
||||
onKeysDownChange *func(state KeysDownState)
|
||||
|
||||
log *zerolog.Logger
|
||||
|
||||
|
@ -122,7 +130,8 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
|
|||
txLock: sync.Mutex{},
|
||||
keyboardStateCtx: keyboardCtx,
|
||||
keyboardStateCancel: keyboardCancel,
|
||||
keyboardState: KeyboardState{},
|
||||
keyboardState: 0,
|
||||
keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes
|
||||
enabledDevices: *enabledDevices,
|
||||
lastUserInput: time.Now(),
|
||||
log: logger,
|
||||
|
|
|
@ -2,6 +2,7 @@ package usbgadget
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
@ -10,6 +11,31 @@ import (
|
|||
"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 {
|
||||
pathArr := append([]string{basePath}, paths...)
|
||||
return filepath.Join(pathArr...)
|
||||
|
@ -81,7 +107,7 @@ func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool)
|
|||
return false
|
||||
}
|
||||
|
||||
func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) {
|
||||
func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...any) {
|
||||
u.logSuppressionLock.Lock()
|
||||
defer u.logSuppressionLock.Unlock()
|
||||
|
||||
|
|
147
jiggler.go
|
@ -1,39 +1,156 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/jetkvm/kvm/internal/tzdata"
|
||||
)
|
||||
|
||||
var lastUserInput = time.Now()
|
||||
type JigglerConfig struct {
|
||||
InactivityLimitSeconds int `json:"inactivity_limit_seconds"`
|
||||
JitterPercentage int `json:"jitter_percentage"`
|
||||
ScheduleCronTab string `json:"schedule_cron_tab"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
var jigglerEnabled = false
|
||||
var jobDelta time.Duration = 0
|
||||
var scheduler gocron.Scheduler = nil
|
||||
|
||||
func rpcSetJigglerState(enabled bool) {
|
||||
jigglerEnabled = enabled
|
||||
}
|
||||
|
||||
func rpcGetJigglerState() bool {
|
||||
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() {
|
||||
go runJiggler()
|
||||
ensureConfigLoaded()
|
||||
err := runJigglerCronTab()
|
||||
if err != nil {
|
||||
logger.Error().Msgf("Error scheduling jiggler crontab: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func runJigglerCronTab() error {
|
||||
cronTab := config.JigglerConfig.ScheduleCronTab
|
||||
|
||||
// Apply timezone if specified and valid
|
||||
if config.JigglerConfig.Timezone != "" && config.JigglerConfig.Timezone != "UTC" {
|
||||
// Validate timezone before applying
|
||||
if _, err := time.LoadLocation(config.JigglerConfig.Timezone); err != nil {
|
||||
logger.Warn().Msgf("Invalid timezone '%s', falling back to UTC: %v", config.JigglerConfig.Timezone, err)
|
||||
// Don't add TZ prefix, let it run in UTC
|
||||
} else {
|
||||
cronTab = fmt.Sprintf("TZ=%s %s", config.JigglerConfig.Timezone, cronTab)
|
||||
}
|
||||
}
|
||||
|
||||
s, err := gocron.NewScheduler()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scheduler = s
|
||||
_, err = s.NewJob(
|
||||
gocron.CronJob(
|
||||
cronTab,
|
||||
true,
|
||||
),
|
||||
gocron.NewTask(
|
||||
func() {
|
||||
runJiggler()
|
||||
},
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Start()
|
||||
delta, err := calculateJobDelta(s)
|
||||
jobDelta = delta
|
||||
logger.Info().Msgf("Time between jiggler runs: %v", jobDelta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runJiggler() {
|
||||
for {
|
||||
if jigglerEnabled {
|
||||
if time.Since(lastUserInput) > 20*time.Second {
|
||||
//TODO: change to rel mouse
|
||||
err := rpcAbsMouseReport(1, 1, 0)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to jiggle mouse")
|
||||
}
|
||||
err = rpcAbsMouseReport(0, 0, 0)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to reset mouse position")
|
||||
}
|
||||
if jigglerEnabled {
|
||||
if config.JigglerConfig.JitterPercentage != 0 {
|
||||
jitter := calculateJitterDuration(jobDelta)
|
||||
time.Sleep(jitter)
|
||||
}
|
||||
inactivitySeconds := config.JigglerConfig.InactivityLimitSeconds
|
||||
timeSinceLastInput := time.Since(gadget.GetLastUserInputTime())
|
||||
logger.Debug().Msgf("Time since last user input %v", timeSinceLastInput)
|
||||
if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second {
|
||||
logger.Debug().Msg("Jiggling mouse...")
|
||||
//TODO: change to rel mouse
|
||||
err := rpcAbsMouseReport(1, 1, 0)
|
||||
if err != nil {
|
||||
logger.Warn().Msgf("Failed to jiggle mouse: %v", err)
|
||||
}
|
||||
err = rpcAbsMouseReport(0, 0, 0)
|
||||
if err != nil {
|
||||
logger.Warn().Msgf("Failed to reset mouse position: %v", err)
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func calculateJobDelta(s gocron.Scheduler) (time.Duration, error) {
|
||||
j := s.Jobs()[0]
|
||||
runs, err := j.NextRuns(2)
|
||||
if err != nil {
|
||||
return 0.0, err
|
||||
}
|
||||
return runs[1].Sub(runs[0]), nil
|
||||
}
|
||||
|
||||
func calculateJitterDuration(delta time.Duration) time.Duration {
|
||||
jitter := rand.Float64() * float64(config.JigglerConfig.JitterPercentage) / 100 * delta.Seconds()
|
||||
return time.Duration(jitter * float64(time.Second))
|
||||
}
|
||||
|
|
124
jsonrpc.go
|
@ -13,29 +13,30 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/rs/zerolog"
|
||||
"go.bug.st/serial"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||
)
|
||||
|
||||
type JSONRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]interface{} `json:"params,omitempty"`
|
||||
ID interface{} `json:"id,omitempty"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
ID any `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type JSONRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error interface{} `json:"error,omitempty"`
|
||||
ID interface{} `json:"id"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error any `json:"error,omitempty"`
|
||||
ID any `json:"id"`
|
||||
}
|
||||
|
||||
type JSONRPCEvent struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params interface{} `json:"params,omitempty"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type DisplayRotationSettings struct {
|
||||
|
@ -61,7 +62,7 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
|||
}
|
||||
}
|
||||
|
||||
func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
||||
func writeJSONRPCEvent(event string, params any, session *Session) {
|
||||
request := JSONRPCEvent{
|
||||
JSONRPC: "2.0",
|
||||
Method: event,
|
||||
|
@ -102,7 +103,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
|||
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: map[string]interface{}{
|
||||
Error: map[string]any{
|
||||
"code": -32700,
|
||||
"message": "Parse error",
|
||||
},
|
||||
|
@ -123,7 +124,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
|||
if !ok {
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: map[string]interface{}{
|
||||
Error: map[string]any{
|
||||
"code": -32601,
|
||||
"message": "Method not found",
|
||||
},
|
||||
|
@ -133,13 +134,12 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
|||
return
|
||||
}
|
||||
|
||||
scopedLogger.Trace().Msg("Calling RPC handler")
|
||||
result, err := callRPCHandler(handler, request.Params)
|
||||
result, err := callRPCHandler(scopedLogger, handler, request.Params)
|
||||
if err != nil {
|
||||
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
|
||||
errorResponse := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: map[string]interface{}{
|
||||
Error: map[string]any{
|
||||
"code": -32603,
|
||||
"message": "Internal error",
|
||||
"data": err.Error(),
|
||||
|
@ -200,7 +200,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
|
|||
|
||||
func rpcSetStreamQualityFactor(factor float64) error {
|
||||
logger.Info().Float64("factor", factor).Msg("Setting stream quality factor")
|
||||
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
|
||||
var _, err = CallCtrlAction("set_video_quality_factor", map[string]any{"quality_factor": factor})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -240,7 +240,7 @@ func rpcSetEDID(edid string) error {
|
|||
} else {
|
||||
logger.Info().Str("edid", edid).Msg("Setting EDID")
|
||||
}
|
||||
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid})
|
||||
_, err := CallCtrlAction("set_edid", map[string]any{"edid": edid})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -467,12 +467,12 @@ func rpcSetTLSState(state TLSState) error {
|
|||
}
|
||||
|
||||
type RPCHandler struct {
|
||||
Func interface{}
|
||||
Func any
|
||||
Params []string
|
||||
}
|
||||
|
||||
// call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls
|
||||
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result interface{}, err error) {
|
||||
func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) {
|
||||
// Use defer to recover from a panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
@ -486,11 +486,11 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result i
|
|||
}()
|
||||
|
||||
// Call the handler
|
||||
result, err = riskyCallRPCHandler(handler, params)
|
||||
return result, err
|
||||
result, err = riskyCallRPCHandler(logger, handler, params)
|
||||
return result, err // do not combine these two lines into one, as it breaks the above defer function's setting of err
|
||||
}
|
||||
|
||||
func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
|
||||
func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (any, error) {
|
||||
handlerValue := reflect.ValueOf(handler.Func)
|
||||
handlerType := handlerValue.Type()
|
||||
|
||||
|
@ -499,20 +499,24 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
|||
}
|
||||
|
||||
numParams := handlerType.NumIn()
|
||||
args := make([]reflect.Value, numParams)
|
||||
// Get the parameter names from the RPCHandler
|
||||
paramNames := handler.Params
|
||||
paramNames := handler.Params // Get the parameter names from the RPCHandler
|
||||
|
||||
if len(paramNames) != numParams {
|
||||
return nil, errors.New("mismatch between handler parameters and defined parameter names")
|
||||
err := fmt.Errorf("mismatch between handler parameters (%d) and defined parameter names (%d)", numParams, len(paramNames))
|
||||
logger.Error().Strs("paramNames", paramNames).Err(err).Msg("Cannot call RPC handler")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := 0; i < numParams; i++ {
|
||||
args := make([]reflect.Value, numParams)
|
||||
|
||||
for i := range numParams {
|
||||
paramType := handlerType.In(i)
|
||||
paramName := paramNames[i]
|
||||
paramValue, ok := params[paramName]
|
||||
if !ok {
|
||||
return nil, errors.New("missing parameter: " + paramName)
|
||||
err := fmt.Errorf("missing parameter: %s", paramName)
|
||||
logger.Error().Err(err).Msg("Cannot marshal arguments for RPC handler")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
convertedValue := reflect.ValueOf(paramValue)
|
||||
|
@ -529,7 +533,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
|||
if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 {
|
||||
intValue := int(elemValue.Float())
|
||||
if intValue < 0 || intValue > 255 {
|
||||
return nil, fmt.Errorf("value out of range for uint8: %v", intValue)
|
||||
return nil, fmt.Errorf("value out of range for uint8: %v for parameter %s", intValue, paramName)
|
||||
}
|
||||
newSlice.Index(j).SetUint(uint64(intValue))
|
||||
} else {
|
||||
|
@ -545,12 +549,12 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
|||
} else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map {
|
||||
jsonData, err := json.Marshal(convertedValue.Interface())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal map to JSON: %v", err)
|
||||
return nil, fmt.Errorf("failed to marshal map to JSON: %v for parameter %s", err, paramName)
|
||||
}
|
||||
|
||||
newStruct := reflect.New(paramType).Interface()
|
||||
if err := json.Unmarshal(jsonData, newStruct); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err)
|
||||
return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v for parameter %s", err, paramName)
|
||||
}
|
||||
args[i] = reflect.ValueOf(newStruct).Elem()
|
||||
} else {
|
||||
|
@ -561,6 +565,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
|||
}
|
||||
}
|
||||
|
||||
logger.Trace().Msg("Calling RPC handler")
|
||||
results := handlerValue.Call(args)
|
||||
|
||||
if len(results) == 0 {
|
||||
|
@ -568,23 +573,32 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
|||
}
|
||||
|
||||
if len(results) == 1 {
|
||||
if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
||||
if !results[0].IsNil() {
|
||||
return nil, results[0].Interface().(error)
|
||||
if ok, err := asError(results[0]); ok {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
||||
if !results[1].IsNil() {
|
||||
return nil, results[1].Interface().(error)
|
||||
}
|
||||
return results[0].Interface(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("too many return values from handler: %d", len(results))
|
||||
}
|
||||
|
||||
return nil, errors.New("unexpected return values from handler")
|
||||
func asError(value reflect.Value) (bool, error) {
|
||||
if value.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
||||
if value.IsNil() {
|
||||
return true, nil
|
||||
}
|
||||
return true, value.Interface().(error)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func rpcSetMassStorageMode(mode string) (string, error) {
|
||||
|
@ -923,7 +937,7 @@ func rpcSetKeyboardLayout(layout string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func getKeyboardMacros() (interface{}, error) {
|
||||
func getKeyboardMacros() (any, error) {
|
||||
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
|
||||
copy(macros, config.KeyboardMacros)
|
||||
|
||||
|
@ -931,10 +945,10 @@ func getKeyboardMacros() (interface{}, error) {
|
|||
}
|
||||
|
||||
type KeyboardMacrosParams struct {
|
||||
Macros []interface{} `json:"macros"`
|
||||
Macros []any `json:"macros"`
|
||||
}
|
||||
|
||||
func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
||||
func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
|
||||
if params.Macros == nil {
|
||||
return nil, fmt.Errorf("missing or invalid macros parameter")
|
||||
}
|
||||
|
@ -942,7 +956,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
|||
newMacros := make([]KeyboardMacro, 0, len(params.Macros))
|
||||
|
||||
for i, item := range params.Macros {
|
||||
macroMap, ok := item.(map[string]interface{})
|
||||
macroMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid macro at index %d", i)
|
||||
}
|
||||
|
@ -960,16 +974,16 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
|||
}
|
||||
|
||||
steps := []KeyboardMacroStep{}
|
||||
if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
|
||||
if stepsArray, ok := macroMap["steps"].([]any); ok {
|
||||
for _, stepItem := range stepsArray {
|
||||
stepMap, ok := stepItem.(map[string]interface{})
|
||||
stepMap, ok := stepItem.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
step := KeyboardMacroStep{}
|
||||
|
||||
if keysArray, ok := stepMap["keys"].([]interface{}); ok {
|
||||
if keysArray, ok := stepMap["keys"].([]any); ok {
|
||||
for _, k := range keysArray {
|
||||
if keyStr, ok := k.(string); ok {
|
||||
step.Keys = append(step.Keys, keyStr)
|
||||
|
@ -977,7 +991,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
|||
}
|
||||
}
|
||||
|
||||
if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
|
||||
if modsArray, ok := stepMap["modifiers"].([]any); ok {
|
||||
for _, m := range modsArray {
|
||||
if modStr, ok := m.(string); ok {
|
||||
step.Modifiers = append(step.Modifiers, modStr)
|
||||
|
@ -1047,6 +1061,8 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
||||
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
|
||||
"getKeyDownState": {Func: rpcGetKeysDownState},
|
||||
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
||||
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
|
||||
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
||||
|
@ -1056,6 +1072,9 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
||||
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
||||
"getJigglerState": {Func: rpcGetJigglerState},
|
||||
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
|
||||
"getJigglerConfig": {Func: rpcGetJigglerConfig},
|
||||
"getTimezones": {Func: rpcGetTimezones},
|
||||
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
|
||||
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
|
||||
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
|
||||
|
@ -1084,7 +1103,6 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
||||
"getStorageSpace": {Func: rpcGetStorageSpace},
|
||||
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
||||
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
|
||||
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
||||
"listStorageFiles": {Func: rpcListStorageFiles},
|
||||
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
|
||||
|
|
2
log.go
|
@ -5,7 +5,7 @@ import (
|
|||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
|
||||
func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
|
||||
return logging.ErrorfL(l, format, err, args...)
|
||||
}
|
||||
|
||||
|
|
41
native.go
|
@ -9,6 +9,7 @@ import (
|
|||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
@ -20,18 +21,18 @@ import (
|
|||
var ctrlSocketConn net.Conn
|
||||
|
||||
type CtrlAction struct {
|
||||
Action string `json:"action"`
|
||||
Seq int32 `json:"seq,omitempty"`
|
||||
Params map[string]interface{} `json:"params,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Seq int32 `json:"seq,omitempty"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type CtrlResponse struct {
|
||||
Seq int32 `json:"seq,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Errno int32 `json:"errno,omitempty"`
|
||||
Result map[string]interface{} `json:"result,omitempty"`
|
||||
Event string `json:"event,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Seq int32 `json:"seq,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Errno int32 `json:"errno,omitempty"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Event string `json:"event,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type EventHandler func(event CtrlResponse)
|
||||
|
@ -47,7 +48,7 @@ var (
|
|||
nativeCmdLock = &sync.Mutex{}
|
||||
)
|
||||
|
||||
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
|
||||
func CallCtrlAction(action string, params map[string]any) (*CtrlResponse, error) {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
ctrlAction := CtrlAction{
|
||||
|
@ -366,6 +367,22 @@ func shouldOverwrite(destPath string, srcHash []byte) bool {
|
|||
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 {
|
||||
srcFile, err := resource.ResourceFS.Open("jetkvm_native")
|
||||
if err != nil {
|
||||
|
@ -373,7 +390,7 @@ func ensureBinaryUpdated(destPath string) error {
|
|||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
|
||||
srcHash, err := getNativeSha256()
|
||||
if err != nil {
|
||||
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
|
||||
srcHash = nil
|
||||
|
@ -412,7 +429,7 @@ func ensureBinaryUpdated(destPath string) error {
|
|||
func restoreHdmiEdid() {
|
||||
if config.EdidString != "" {
|
||||
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
|
||||
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString})
|
||||
_, err := CallCtrlAction("set_edid", map[string]any{"edid": config.EdidString})
|
||||
if err != nil {
|
||||
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
|
||||
}
|
||||
|
|
10
network.go
|
@ -19,6 +19,16 @@ func networkStateChanged() {
|
|||
// do not block the main thread
|
||||
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
|
||||
if mDNS != nil {
|
||||
_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())
|
||||
|
|
14
ota.go
|
@ -50,6 +50,10 @@ const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
|
|||
|
||||
var builtAppVersion = "0.1.0+dev"
|
||||
|
||||
func GetBuiltAppVersion() string {
|
||||
return builtAppVersion
|
||||
}
|
||||
|
||||
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
|
||||
appVersion, err = semver.NewVersion(builtAppVersion)
|
||||
if err != nil {
|
||||
|
@ -89,7 +93,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
|||
return nil, fmt.Errorf("error creating request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error sending request: %w", err)
|
||||
}
|
||||
|
@ -135,6 +146,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
|||
client := http.Client{
|
||||
Timeout: 10 * time.Minute,
|
||||
Transport: &http.Transport{
|
||||
Proxy: config.NetworkConfig.GetTransportProxyFunc(),
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: rootcerts.ServerCertPool(),
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type RemoteImageReader interface {
|
||||
Read(ctx context.Context, offset int64, size int64) ([]byte, error)
|
||||
}
|
||||
|
||||
type WebRTCDiskReader struct {
|
||||
}
|
||||
|
||||
var webRTCDiskReader WebRTCDiskReader
|
||||
|
||||
func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) ([]byte, error) {
|
||||
virtualMediaStateMutex.RLock()
|
||||
if currentVirtualMediaState == nil {
|
||||
virtualMediaStateMutex.RUnlock()
|
||||
return nil, errors.New("image not mounted")
|
||||
}
|
||||
if currentVirtualMediaState.Source != WebRTC {
|
||||
virtualMediaStateMutex.RUnlock()
|
||||
return nil, errors.New("image not mounted from webrtc")
|
||||
}
|
||||
mountedImageSize := currentVirtualMediaState.Size
|
||||
virtualMediaStateMutex.RUnlock()
|
||||
end := offset + size
|
||||
if end > mountedImageSize {
|
||||
end = mountedImageSize
|
||||
}
|
||||
req := DiskReadRequest{
|
||||
Start: uint64(offset),
|
||||
End: uint64(end),
|
||||
}
|
||||
jsonBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if currentSession == nil || currentSession.DiskChannel == nil {
|
||||
return nil, errors.New("not active session")
|
||||
}
|
||||
|
||||
logger.Debug().Str("request", string(jsonBytes)).Msg("reading from webrtc")
|
||||
err = currentSession.DiskChannel.SendText(string(jsonBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf []byte
|
||||
for {
|
||||
select {
|
||||
case data := <-diskReadChan:
|
||||
buf = data[16:]
|
||||
case <-ctx.Done():
|
||||
return nil, context.Canceled
|
||||
}
|
||||
if len(buf) >= int(end-offset) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return buf, nil
|
||||
}
|
|
@ -128,6 +128,7 @@ func pressATXResetButton(duration time.Duration) error {
|
|||
|
||||
func mountDCControl() error {
|
||||
_ = port.SetMode(defaultMode)
|
||||
registerDCMetrics()
|
||||
go runDCControl()
|
||||
return nil
|
||||
}
|
||||
|
@ -206,6 +207,9 @@ func runDCControl() {
|
|||
dcState.Current = amps
|
||||
dcState.Power = watts
|
||||
|
||||
// Update Prometheus metrics
|
||||
updateDCMetrics(dcState)
|
||||
|
||||
if currentSession != nil {
|
||||
writeJSONRPCEvent("dcState", dcState, currentSession)
|
||||
}
|
||||
|
|
|
@ -66,6 +66,10 @@ module.exports = defineConfig([{
|
|||
groups: ["builtin", "external", "internal", "parent", "sibling"],
|
||||
"newlines-between": "always",
|
||||
}],
|
||||
|
||||
"@typescript-eslint/no-unused-vars": ["warn", {
|
||||
"argsIgnorePattern": "^_", "varsIgnorePattern": "^_"
|
||||
}],
|
||||
},
|
||||
|
||||
settings: {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- These are the fonts used in the app -->
|
||||
<link
|
||||
|
@ -27,7 +27,14 @@
|
|||
/>
|
||||
<title>JetKVM</title>
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="JetKVM" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#051946" />
|
||||
<meta name="description" content="A web-based KVM console for managing remote servers." />
|
||||
<script>
|
||||
// Initial theme setup
|
||||
document.documentElement.classList.toggle(
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "kvm-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "2025.08.25.2300",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "22.15.0"
|
||||
|
@ -19,66 +19,66 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.3",
|
||||
"@headlessui/react": "^2.2.7",
|
||||
"@headlessui/tailwindcss": "^0.2.2",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@vitejs/plugin-basic-ssl": "^2.0.0",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cva": "^1.0.0-beta.3",
|
||||
"cva": "^1.0.0-beta.4",
|
||||
"dayjs": "^1.11.13",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"focus-trap-react": "^11.0.3",
|
||||
"framer-motion": "^12.11.4",
|
||||
"focus-trap-react": "^11.0.4",
|
||||
"framer-motion": "^12.23.12",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mini-svg-data-uri": "^1.4.4",
|
||||
"react": "^19.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-animate-height": "^3.2.3",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-simple-keyboard": "^3.8.72",
|
||||
"react-simple-keyboard": "^3.8.115",
|
||||
"react-use-websocket": "^4.13.0",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"recharts": "^2.15.3",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"validator": "^13.15.0",
|
||||
"validator": "^13.15.15",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.9",
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.26.0",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@types/react": "^19.1.11",
|
||||
"@types/react-dom": "^19.1.8",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/validator": "^13.15.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"@types/validator": "^13.15.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||
"@typescript-eslint/parser": "^8.41.0",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.1.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"typescript": "^5.8.3",
|
||||
"globals": "^16.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^6.3.5",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 972 B |
After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.2 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}@media (prefers-color-scheme:dark){:root{filter:none}}</style></svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "JetKVM",
|
||||
"short_name": "JetKVM",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#002b36",
|
||||
"background_color": "#051946",
|
||||
"display": "standalone"
|
||||
}
|
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 7.9 KiB |
|
@ -26,17 +26,13 @@ export default function Actionbar({
|
|||
requestFullscreen: () => Promise<void>;
|
||||
}) {
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||
const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
|
||||
const { setDisableVideoFocusTrap, terminalType, setTerminalType, toggleSidebarView } = useUiStore();
|
||||
|
||||
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const terminalType = useUiStore(state => state.terminalType);
|
||||
const setTerminalType = useUiStore(state => state.setTerminalType);
|
||||
const remoteVirtualMediaState = useMountMediaStore(
|
||||
state => state.remoteVirtualMediaState,
|
||||
);
|
||||
const developerMode = useSettingsStore(state => state.developerMode);
|
||||
const { developerMode } = useSettingsStore();
|
||||
|
||||
// This is the only way to get a reliable state change for the popover
|
||||
// at time of writing this there is no mount, or unmount event for the popover
|
||||
|
@ -47,13 +43,13 @@ export default function Actionbar({
|
|||
isOpen.current = open;
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
setDisableFocusTrap(false);
|
||||
console.log("Popover is closing. Returning focus trap to video");
|
||||
setDisableVideoFocusTrap(false);
|
||||
console.debug("Popover is closing. Returning focus trap to video");
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
[setDisableFocusTrap],
|
||||
[setDisableVideoFocusTrap],
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -81,7 +77,7 @@ export default function Actionbar({
|
|||
text="Paste text"
|
||||
LeadingIcon={MdOutlineContentPasteGo}
|
||||
onClick={() => {
|
||||
setDisableFocusTrap(true);
|
||||
setDisableVideoFocusTrap(true);
|
||||
}}
|
||||
/>
|
||||
</PopoverButton>
|
||||
|
@ -123,7 +119,7 @@ export default function Actionbar({
|
|||
);
|
||||
}}
|
||||
onClick={() => {
|
||||
setDisableFocusTrap(true);
|
||||
setDisableVideoFocusTrap(true);
|
||||
}}
|
||||
/>
|
||||
</PopoverButton>
|
||||
|
@ -154,7 +150,7 @@ export default function Actionbar({
|
|||
theme="light"
|
||||
text="Wake on LAN"
|
||||
onClick={() => {
|
||||
setDisableFocusTrap(true);
|
||||
setDisableVideoFocusTrap(true);
|
||||
}}
|
||||
LeadingIcon={({ className }) => (
|
||||
<svg
|
||||
|
@ -204,7 +200,7 @@ export default function Actionbar({
|
|||
theme="light"
|
||||
text="Virtual Keyboard"
|
||||
LeadingIcon={FaKeyboard}
|
||||
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
||||
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -218,7 +214,7 @@ export default function Actionbar({
|
|||
text="Extension"
|
||||
LeadingIcon={LuCable}
|
||||
onClick={() => {
|
||||
setDisableFocusTrap(true);
|
||||
setDisableVideoFocusTrap(true);
|
||||
}}
|
||||
/>
|
||||
</PopoverButton>
|
||||
|
@ -243,7 +239,7 @@ export default function Actionbar({
|
|||
theme="light"
|
||||
text="Virtual Keyboard"
|
||||
LeadingIcon={FaKeyboard}
|
||||
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
||||
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
|
@ -268,7 +264,10 @@ export default function Actionbar({
|
|||
theme="light"
|
||||
text="Settings"
|
||||
LeadingIcon={LuSettings}
|
||||
onClick={() => navigateTo("/settings")}
|
||||
onClick={() => {
|
||||
setDisableVideoFocusTrap(true);
|
||||
navigateTo("/settings")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ export default function FieldLabel({
|
|||
>
|
||||
{label}
|
||||
{description && (
|
||||
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
||||
<span className="mb-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
|
@ -36,11 +36,11 @@ export default function FieldLabel({
|
|||
} else if (as === "span") {
|
||||
return (
|
||||
<div className="flex select-none flex-col">
|
||||
<span className="font-display text-[13px] font-medium leading-snug text-black dark:text-white">
|
||||
<span className="font-display text-[13px] font-semibold leading-snug text-black dark:text-white">
|
||||
{label}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
||||
<span className="mb-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
|
@ -49,4 +49,4 @@ export default function FieldLabel({
|
|||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@ export default function DashboardNavbar({
|
|||
navigate("/");
|
||||
}, [navigate, setUser]);
|
||||
|
||||
const usbState = useHidStore(state => state.usbState);
|
||||
const { usbState } = useHidStore();
|
||||
|
||||
// for testing
|
||||
//userEmail = "user@example.org";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { cx } from "@/cva.config";
|
||||
import {
|
||||
|
@ -7,65 +7,68 @@ import {
|
|||
useRTCStore,
|
||||
useSettingsStore,
|
||||
useVideoStore,
|
||||
VideoState
|
||||
} from "@/hooks/stores";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
|
||||
export default function InfoBar() {
|
||||
const activeKeys = useHidStore(state => state.activeKeys);
|
||||
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 { keysDownState } = useHidStore();
|
||||
const { mouseX, mouseY, mouseMove } = useMouseStore();
|
||||
|
||||
const videoClientSize = useVideoStore(
|
||||
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
||||
(state: VideoState) => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
||||
);
|
||||
|
||||
const videoSize = useVideoStore(
|
||||
state => `${Math.round(state.width)}x${Math.round(state.height)}`,
|
||||
(state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`,
|
||||
);
|
||||
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
|
||||
const { rpcDataChannel } = useRTCStore();
|
||||
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!rpcDataChannel) return;
|
||||
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
|
||||
rpcDataChannel.onerror = e =>
|
||||
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
|
||||
rpcDataChannel.onerror = (e: Event) =>
|
||||
console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
|
||||
}, [rpcDataChannel]);
|
||||
|
||||
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
||||
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
||||
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
||||
const { keyboardLedState, usbState } = useHidStore();
|
||||
const { isTurnServerInUse } = useRTCStore();
|
||||
const { hdmiState } = useVideoStore();
|
||||
|
||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
||||
const displayKeys = useMemo(() => {
|
||||
if (!showPressedKeys)
|
||||
return "";
|
||||
|
||||
const usbState = useHidStore(state => state.usbState);
|
||||
const hdmiState = useVideoStore(state => state.hdmiState);
|
||||
const activeModifierMask = keysDownState.modifier || 0;
|
||||
const keysDown = keysDownState.keys || [];
|
||||
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
|
||||
const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name);
|
||||
|
||||
return [...modifierNames,...keyNames].join(", ");
|
||||
}, [keysDownState, showPressedKeys]);
|
||||
|
||||
return (
|
||||
<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 items-center">
|
||||
<div className="flex flex-wrap items-center pl-2 gap-x-4">
|
||||
{settings.debugMode ? (
|
||||
{debugMode ? (
|
||||
<div className="flex">
|
||||
<span className="text-xs font-semibold">Resolution:</span>{" "}
|
||||
<span className="text-xs">{videoSize}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settings.debugMode ? (
|
||||
{debugMode ? (
|
||||
<div className="flex">
|
||||
<span className="text-xs font-semibold">Video Size: </span>
|
||||
<span className="text-xs">{videoClientSize}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(settings.debugMode && settings.mouseMode == "absolute") ? (
|
||||
{(debugMode && mouseMode == "absolute") ? (
|
||||
<div className="flex w-[118px] items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">Pointer:</span>
|
||||
<span className="text-xs">
|
||||
|
@ -74,7 +77,7 @@ export default function InfoBar() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{(settings.debugMode && settings.mouseMode == "relative") ? (
|
||||
{(debugMode && mouseMode == "relative") ? (
|
||||
<div className="flex w-[118px] items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">Last Move:</span>
|
||||
<span className="text-xs">
|
||||
|
@ -85,13 +88,13 @@ export default function InfoBar() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{settings.debugMode && (
|
||||
{debugMode && (
|
||||
<div className="flex w-[156px] items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">USB State:</span>
|
||||
<span className="text-xs">{usbState}</span>
|
||||
</div>
|
||||
)}
|
||||
{settings.debugMode && (
|
||||
{debugMode && (
|
||||
<div className="flex w-[156px] items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">HDMI State:</span>
|
||||
<span className="text-xs">{hdmiState}</span>
|
||||
|
@ -102,14 +105,7 @@ export default function InfoBar() {
|
|||
<div className="flex items-center gap-x-1">
|
||||
<span className="text-xs font-semibold">Keys:</span>
|
||||
<h2 className="text-xs">
|
||||
{[
|
||||
...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(", ")}
|
||||
{displayKeys}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
@ -122,23 +118,10 @@ export default function InfoBar() {
|
|||
</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
|
||||
className={cx(
|
||||
"shrink-0 p-1 px-1.5 text-xs",
|
||||
keyboardLedState?.caps_lock
|
||||
keyboardLedState.caps_lock
|
||||
? "text-black dark:text-white"
|
||||
: "text-slate-800/20 dark:text-slate-300/20",
|
||||
)}
|
||||
|
@ -148,7 +131,7 @@ export default function InfoBar() {
|
|||
<div
|
||||
className={cx(
|
||||
"shrink-0 p-1 px-1.5 text-xs",
|
||||
keyboardLedState?.num_lock
|
||||
keyboardLedState.num_lock
|
||||
? "text-black dark:text-white"
|
||||
: "text-slate-800/20 dark:text-slate-300/20",
|
||||
)}
|
||||
|
@ -158,23 +141,28 @@ export default function InfoBar() {
|
|||
<div
|
||||
className={cx(
|
||||
"shrink-0 p-1 px-1.5 text-xs",
|
||||
keyboardLedState?.scroll_lock
|
||||
keyboardLedState.scroll_lock
|
||||
? "text-black dark:text-white"
|
||||
: "text-slate-800/20 dark:text-slate-300/20",
|
||||
)}
|
||||
>
|
||||
Scroll Lock
|
||||
</div>
|
||||
{keyboardLedState?.compose ? (
|
||||
{keyboardLedState.compose ? (
|
||||
<div className="shrink-0 p-1 px-1.5 text-xs">
|
||||
Compose
|
||||
</div>
|
||||
) : null}
|
||||
{keyboardLedState?.kana ? (
|
||||
{keyboardLedState.kana ? (
|
||||
<div className="shrink-0 p-1 px-1.5 text-xs">
|
||||
Kana
|
||||
</div>
|
||||
) : null}
|
||||
{keyboardLedState.shift ? (
|
||||
<div className="shrink-0 p-1 px-1.5 text-xs">
|
||||
Shift
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -26,7 +26,7 @@ type InputFieldProps = {
|
|||
|
||||
type InputFieldWithLabelProps = InputFieldProps & {
|
||||
label: React.ReactNode;
|
||||
description?: string | null;
|
||||
description?: React.ReactNode | string | null;
|
||||
};
|
||||
|
||||
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(
|
||||
|
|
|
@ -0,0 +1,176 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
|
||||
import { InputFieldWithLabel } from "./InputField";
|
||||
import { SelectMenuBasic } from "./SelectMenuBasic";
|
||||
|
||||
export interface JigglerConfig {
|
||||
inactivity_limit_seconds: number;
|
||||
jitter_percentage: number;
|
||||
schedule_cron_tab: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export function JigglerSetting({
|
||||
onSave,
|
||||
defaultJigglerState,
|
||||
}: {
|
||||
onSave: (jigglerConfig: JigglerConfig) => void;
|
||||
defaultJigglerState?: JigglerConfig;
|
||||
}) {
|
||||
const [jigglerConfigState, setJigglerConfigState] = useState<JigglerConfig>(
|
||||
defaultJigglerState || {
|
||||
inactivity_limit_seconds: 20,
|
||||
jitter_percentage: 0,
|
||||
schedule_cron_tab: "*/20 * * * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
);
|
||||
|
||||
const { send } = useJsonRpc();
|
||||
const [timezones, setTimezones] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
send("getTimezones", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) return;
|
||||
setTimezones(resp.result as string[]);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const timezoneOptions = useMemo(
|
||||
() =>
|
||||
timezones.map((timezone: string) => ({
|
||||
value: timezone,
|
||||
label: timezone,
|
||||
})),
|
||||
[timezones],
|
||||
);
|
||||
|
||||
const exampleConfigs = [
|
||||
{
|
||||
name: "Business Hours 9-17",
|
||||
config: {
|
||||
inactivity_limit_seconds: 60,
|
||||
jitter_percentage: 25,
|
||||
schedule_cron_tab: "0 * 9-17 * * 1-5",
|
||||
timezone: jigglerConfigState.timezone || "UTC",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Business Hours 8-17",
|
||||
config: {
|
||||
inactivity_limit_seconds: 60,
|
||||
jitter_percentage: 25,
|
||||
schedule_cron_tab: "0 * 8-17 * * 1-5",
|
||||
timezone: jigglerConfigState.timezone || "UTC",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Examples
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{exampleConfigs.map((example, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="XS"
|
||||
theme="light"
|
||||
text={example.name}
|
||||
onClick={() => setJigglerConfigState(example.config)}
|
||||
/>
|
||||
))}
|
||||
<LinkButton
|
||||
to="https://crontab.guru/examples.html"
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="More examples"
|
||||
LeadingIcon={LuExternalLink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 items-end gap-4 md:grid-cols-2">
|
||||
<InputFieldWithLabel
|
||||
required
|
||||
size="SM"
|
||||
label="Cron Schedule"
|
||||
description="Cron expression for scheduling"
|
||||
placeholder="*/20 * * * * *"
|
||||
value={jigglerConfigState.schedule_cron_tab}
|
||||
onChange={e =>
|
||||
setJigglerConfigState({
|
||||
...jigglerConfigState,
|
||||
schedule_cron_tab: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label="Inactivity Limit Seconds"
|
||||
description="Inactivity time before jiggle"
|
||||
value={jigglerConfigState.inactivity_limit_seconds}
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
onChange={e =>
|
||||
setJigglerConfigState({
|
||||
...jigglerConfigState,
|
||||
inactivity_limit_seconds: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<InputFieldWithLabel
|
||||
required
|
||||
size="SM"
|
||||
label="Random delay"
|
||||
description="To avoid recognizable patterns"
|
||||
placeholder="25"
|
||||
TrailingElm={<span className="px-2 text-xs text-slate-500">%</span>}
|
||||
value={jigglerConfigState.jitter_percentage}
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
onChange={e =>
|
||||
setJigglerConfigState({
|
||||
...jigglerConfigState,
|
||||
jitter_percentage: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label="Timezone"
|
||||
description="Timezone for cron schedule"
|
||||
value={jigglerConfigState.timezone || "UTC"}
|
||||
disabled={timezones.length === 0}
|
||||
onChange={e =>
|
||||
setJigglerConfigState({
|
||||
...jigglerConfigState,
|
||||
timezone: e.target.value,
|
||||
})
|
||||
}
|
||||
options={timezoneOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Save Jiggler Config"
|
||||
onClick={() => onSave(jigglerConfigState)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -10,7 +10,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
|
|||
export default function MacroBar() {
|
||||
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
||||
const { executeMacro } = useKeyboard();
|
||||
const [send] = useJsonRpc();
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
useEffect(() => {
|
||||
setSendFn(send);
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
import { useState } from "react";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
|
||||
import { KeySequence } from "@/hooks/stores";
|
||||
import { Button } from "@/components/Button";
|
||||
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
import Fieldset from "@/components/Fieldset";
|
||||
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
||||
import { MacroStepCard } from "@/components/MacroStepCard";
|
||||
import {
|
||||
DEFAULT_DELAY,
|
||||
MAX_STEPS_PER_MACRO,
|
||||
MAX_KEYS_PER_STEP,
|
||||
} from "@/constants/macros";
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
import { KeySequence } from "@/hooks/stores";
|
||||
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||
|
||||
interface ValidationErrors {
|
||||
name?: string;
|
||||
|
@ -44,6 +45,7 @@ export function MacroForm({
|
|||
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const { selectedKeyboard } = useKeyboardLayout();
|
||||
|
||||
const showTemporaryError = (message: string) => {
|
||||
setErrorMessage(message);
|
||||
|
@ -234,6 +236,7 @@ export function MacroForm({
|
|||
}
|
||||
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
|
||||
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
|
||||
keyboard={selectedKeyboard}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -1,23 +1,18 @@
|
|||
import { useMemo } from "react";
|
||||
import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
|
||||
|
||||
import { Button } from "@/components/Button";
|
||||
import { Combobox } from "@/components/Combobox";
|
||||
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
||||
import Card from "@/components/Card";
|
||||
import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
|
||||
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
|
||||
import { KeyboardLayout } from "@/keyboardLayouts";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
|
||||
// Filter out modifier keys since they're handled in the modifiers section
|
||||
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 => ({
|
||||
value: modifier,
|
||||
label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
|
||||
|
@ -67,6 +62,7 @@ interface MacroStepCardProps {
|
|||
onModifierChange: (modifiers: string[]) => void;
|
||||
onDelayChange: (delay: number) => void;
|
||||
isLastStep: boolean;
|
||||
keyboard: KeyboardLayout
|
||||
}
|
||||
|
||||
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
|
||||
|
@ -84,9 +80,22 @@ export function MacroStepCard({
|
|||
keyQuery,
|
||||
onModifierChange,
|
||||
onDelayChange,
|
||||
isLastStep
|
||||
isLastStep,
|
||||
keyboard
|
||||
}: MacroStepCardProps) {
|
||||
const getFilteredKeys = () => {
|
||||
const { keyDisplayMap } = keyboard;
|
||||
|
||||
const keyOptions = useMemo(() =>
|
||||
Object.keys(keys)
|
||||
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
|
||||
.map(key => ({
|
||||
value: key,
|
||||
label: keyDisplayMap[key] || key,
|
||||
})),
|
||||
[keyDisplayMap]
|
||||
);
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
const selectedKeys = ensureArray(step.keys);
|
||||
const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
|
||||
|
||||
|
@ -95,7 +104,7 @@ export function MacroStepCard({
|
|||
} else {
|
||||
return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
|
||||
}
|
||||
};
|
||||
}, [keyOptions, keyQuery, step.keys]);
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
|
@ -204,7 +213,7 @@ export function MacroStepCard({
|
|||
}}
|
||||
displayValue={() => keyQuery}
|
||||
onInputChange={onKeyQueryChange}
|
||||
options={getFilteredKeys}
|
||||
options={() => filteredKeys}
|
||||
disabledMessage="Max keys reached"
|
||||
size="SM"
|
||||
immediate
|
||||
|
|
|
@ -26,7 +26,7 @@ type SelectMenuProps = Pick<
|
|||
|
||||
const sizes = {
|
||||
XS: "h-[24.5px] pl-3 pr-8 text-xs",
|
||||
SM: "h-[32px] pl-3 pr-8 text-[13px]",
|
||||
SM: "h-[36px] pl-3 pr-8 text-[13px]",
|
||||
MD: "h-[40px] pl-4 pr-10 text-sm",
|
||||
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
|
||||
};
|
||||
|
@ -62,7 +62,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
|||
"text-sm",
|
||||
)}
|
||||
>
|
||||
{label && <FieldLabel label={label} id={id} as="span" />}
|
||||
{label && <FieldLabel label={label} id={id} />}
|
||||
<Card className="w-auto border! border-solid border-slate-800/30! shadow-xs outline-0 dark:border-slate-300/30!">
|
||||
<select
|
||||
ref={ref}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
export default function SettingsNestedSection({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="ml-2 border-l border-slate-800/30 pl-4 dark:border-slate-300/30">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import "react-simple-keyboard/build/css/index.css";
|
||||
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
|
@ -65,21 +65,22 @@ function Terminal({
|
|||
readonly dataChannel: RTCDataChannel;
|
||||
readonly type: AvailableTerminalTypes;
|
||||
}) {
|
||||
const enableTerminal = useUiStore(state => state.terminalType == type);
|
||||
const setTerminalType = useUiStore(state => state.setTerminalType);
|
||||
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
|
||||
const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore();
|
||||
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
|
||||
|
||||
const isTerminalTypeEnabled = useMemo(() => {
|
||||
return terminalType == type;
|
||||
}, [terminalType, type]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setDisableKeyboardFocusTrap(enableTerminal);
|
||||
setDisableVideoFocusTrap(isTerminalTypeEnabled);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
setDisableKeyboardFocusTrap(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
};
|
||||
}, [ref, instance, enableTerminal, setDisableKeyboardFocusTrap, type]);
|
||||
}, [setDisableVideoFocusTrap, isTerminalTypeEnabled]);
|
||||
|
||||
const readyState = dataChannel.readyState;
|
||||
useEffect(() => {
|
||||
|
@ -116,7 +117,7 @@ function Terminal({
|
|||
const { domEvent } = e;
|
||||
if (domEvent.key === "Escape") {
|
||||
setTerminalType("none");
|
||||
setDisableKeyboardFocusTrap(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
domEvent.preventDefault();
|
||||
}
|
||||
});
|
||||
|
@ -131,7 +132,7 @@ function Terminal({
|
|||
onDataHandler.dispose();
|
||||
onKeyHandler.dispose();
|
||||
};
|
||||
}, [instance, dataChannel, readyState, setDisableKeyboardFocusTrap, setTerminalType]);
|
||||
}, [dataChannel, instance, readyState, setDisableVideoFocusTrap, setTerminalType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance) return;
|
||||
|
@ -158,7 +159,7 @@ function Terminal({
|
|||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [ref, instance]);
|
||||
}, [instance]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -175,9 +176,9 @@ function Terminal({
|
|||
],
|
||||
{
|
||||
"pointer-events-none translate-y-[500px] opacity-100 transition duration-300":
|
||||
!enableTerminal,
|
||||
!isTerminalTypeEnabled,
|
||||
"pointer-events-auto -translate-y-[0px] opacity-100 transition duration-300":
|
||||
enableTerminal,
|
||||
isTerminalTypeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -4,9 +4,7 @@ import { cx } from "@/cva.config";
|
|||
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
|
||||
import LoadingSpinner from "@components/LoadingSpinner";
|
||||
import StatusCard from "@components/StatusCards";
|
||||
import { HidState } from "@/hooks/stores";
|
||||
|
||||
type USBStates = HidState["usbState"];
|
||||
import { USBStates } from "@/hooks/stores";
|
||||
|
||||
type StatusProps = Record<
|
||||
USBStates,
|
||||
|
@ -67,7 +65,7 @@ export default function USBStateStatus({
|
|||
};
|
||||
const props = StatusCardProps[state];
|
||||
if (!props) {
|
||||
console.log("Unsupported USB state: ", state);
|
||||
console.warn("Unsupported USB state: ", state);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback , useEffect, useState } from "react";
|
||||
|
||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import notifications from "../notifications";
|
||||
import { SettingsItem } from "../routes/devices.$id.settings";
|
||||
|
||||
|
@ -59,7 +59,7 @@ const usbPresets = [
|
|||
];
|
||||
|
||||
export function UsbDeviceSetting() {
|
||||
const [send] = useJsonRpc();
|
||||
const { send } = useJsonRpc();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [usbDeviceConfig, setUsbDeviceConfig] =
|
||||
|
@ -67,7 +67,7 @@ export function UsbDeviceSetting() {
|
|||
const [selectedPreset, setSelectedPreset] = useState<string>("default");
|
||||
|
||||
const syncUsbDeviceConfig = useCallback(() => {
|
||||
send("getUsbDevices", {}, resp => {
|
||||
send("getUsbDevices", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
console.error("Failed to load USB devices:", resp.error);
|
||||
notifications.error(
|
||||
|
@ -97,7 +97,7 @@ export function UsbDeviceSetting() {
|
|||
const handleUsbConfigChange = useCallback(
|
||||
(devices: UsbDeviceConfig) => {
|
||||
setLoading(true);
|
||||
send("setUsbDevices", { devices }, async resp => {
|
||||
send("setUsbDevices", { devices }, async (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`,
|
||||
|
@ -127,7 +127,7 @@ export function UsbDeviceSetting() {
|
|||
);
|
||||
|
||||
const handlePresetChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newPreset = e.target.value;
|
||||
setSelectedPreset(newPreset);
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Button } from "@components/Button";
|
|||
|
||||
|
||||
import { UsbConfigState } from "../hooks/stores";
|
||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import notifications from "../notifications";
|
||||
import { SettingsItem } from "../routes/devices.$id.settings";
|
||||
|
||||
|
@ -54,7 +54,7 @@ const usbConfigs = [
|
|||
type UsbConfigMap = Record<string, USBConfig>;
|
||||
|
||||
export function UsbInfoSetting() {
|
||||
const [send] = useJsonRpc();
|
||||
const { send } = useJsonRpc();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [usbConfigProduct, setUsbConfigProduct] = useState("");
|
||||
|
@ -94,15 +94,15 @@ export function UsbInfoSetting() {
|
|||
);
|
||||
|
||||
const syncUsbConfigProduct = useCallback(() => {
|
||||
send("getUsbConfig", {}, resp => {
|
||||
send("getUsbConfig", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
console.error("Failed to load USB Config:", resp.error);
|
||||
notifications.error(
|
||||
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
} else {
|
||||
console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result);
|
||||
const usbConfigState = resp.result as UsbConfigState;
|
||||
console.log("syncUsbConfigProduct#getUsbConfig result:", usbConfigState);
|
||||
const product = usbConfigs.map(u => u.value).includes(usbConfigState.product)
|
||||
? usbConfigState.product
|
||||
: "custom";
|
||||
|
@ -114,7 +114,7 @@ export function UsbInfoSetting() {
|
|||
const handleUsbConfigChange = useCallback(
|
||||
(usbConfig: USBConfig) => {
|
||||
setLoading(true);
|
||||
send("setUsbConfig", { usbConfig }, async resp => {
|
||||
send("setUsbConfig", { usbConfig }, async (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
|
||||
|
@ -137,7 +137,7 @@ export function UsbInfoSetting() {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
send("getDeviceID", {}, async resp => {
|
||||
send("getDeviceID", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
return notifications.error(
|
||||
`Failed to get device ID: ${resp.error.data || "Unknown error"}`,
|
||||
|
@ -205,10 +205,10 @@ function USBConfigDialog({
|
|||
product: "",
|
||||
});
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
const syncUsbConfig = useCallback(() => {
|
||||
send("getUsbConfig", {}, resp => {
|
||||
send("getUsbConfig", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
console.error("Failed to load USB Config:", resp.error);
|
||||
} else {
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { useShallow } from "zustand/react/shallow";
|
||||
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
@ -13,9 +12,10 @@ import "react-simple-keyboard/build/css/index.css";
|
|||
import AttachIconRaw from "@/assets/attach-icon.svg";
|
||||
import DetachIconRaw from "@/assets/detach-icon.svg";
|
||||
import { cx } from "@/cva.config";
|
||||
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||
import { useHidStore, useUiStore } from "@/hooks/stores";
|
||||
import useKeyboard from "@/hooks/useKeyboard";
|
||||
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
|
||||
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||
import { keys, modifiers, latchingKeys, decodeModifiers } from "@/keyboardMappings";
|
||||
|
||||
export const DetachIcon = ({ className }: { className?: string }) => {
|
||||
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
|
||||
|
@ -26,34 +26,47 @@ const AttachIcon = ({ className }: { className?: string }) => {
|
|||
};
|
||||
|
||||
function KeyboardWrapper() {
|
||||
const [layoutName, setLayoutName] = useState("default");
|
||||
|
||||
const keyboardRef = useRef<HTMLDivElement>(null);
|
||||
const showAttachedVirtualKeyboard = useUiStore(
|
||||
state => state.isAttachedVirtualKeyboardVisible,
|
||||
);
|
||||
const setShowAttachedVirtualKeyboard = useUiStore(
|
||||
state => state.setAttachedVirtualKeyboardVisibility,
|
||||
);
|
||||
|
||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||
const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = useUiStore();
|
||||
const { keysDownState, /* keyboardLedState,*/ isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
|
||||
const { handleKeyPress, executeMacro } = useKeyboard();
|
||||
const { selectedKeyboard } = useKeyboardLayout();
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
|
||||
const keyDisplayMap = useMemo(() => {
|
||||
return selectedKeyboard.keyDisplayMap;
|
||||
}, [selectedKeyboard]);
|
||||
|
||||
// HID related states
|
||||
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
||||
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
||||
const isKeyboardLedManagedByHost = useMemo(() =>
|
||||
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
|
||||
[keyboardLedSync, keyboardLedStateSyncAvailable],
|
||||
);
|
||||
const virtualKeyboard = useMemo(() => {
|
||||
return selectedKeyboard.virtualKeyboard;
|
||||
}, [selectedKeyboard]);
|
||||
|
||||
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
|
||||
//const isCapsLockActive = useMemo(() => {
|
||||
// return (keyboardLedState.caps_lock);
|
||||
//}, [keyboardLedState]);
|
||||
|
||||
const { isShiftActive, /*isControlActive, isAltActive, isMetaActive, isAltGrActive*/ } = useMemo(() => {
|
||||
return decodeModifiers(keysDownState.modifier);
|
||||
}, [keysDownState]);
|
||||
|
||||
const mainLayoutName = useMemo(() => {
|
||||
const layoutName = isShiftActive ? "shift": "default";
|
||||
return layoutName;
|
||||
}, [isShiftActive]);
|
||||
|
||||
const keyNamesForDownKeys = useMemo(() => {
|
||||
const activeModifierMask = keysDownState.modifier || 0;
|
||||
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
|
||||
|
||||
const keysDown = keysDownState.keys || [];
|
||||
const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name);
|
||||
|
||||
return [...modifierNames,...keyNames, ' ']; // we have to have at least one space to avoid keyboard whining
|
||||
}, [keysDownState]);
|
||||
|
||||
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
|
||||
if (!keyboardRef.current) return;
|
||||
if (e instanceof TouchEvent && e.touches.length > 1) return;
|
||||
|
@ -123,94 +136,69 @@ function KeyboardWrapper() {
|
|||
};
|
||||
}, [endDrag, onDrag, startDrag]);
|
||||
|
||||
const onKeyUp = useCallback(
|
||||
async (_: string, e: MouseEvent | undefined) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(key: string) => {
|
||||
const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
|
||||
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"));
|
||||
};
|
||||
async (key: string, e: MouseEvent | undefined) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
|
||||
// handle the fake key-macros we have defined for common combinations
|
||||
if (key === "CtrlAltDelete") {
|
||||
sendKeyboardEvent(
|
||||
[keys["Delete"]],
|
||||
[modifiers["ControlLeft"], modifiers["AltLeft"]],
|
||||
);
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
await executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "AltMetaEscape") {
|
||||
sendKeyboardEvent(
|
||||
[keys["Escape"]],
|
||||
[modifiers["MetaLeft"], modifiers["AltLeft"]],
|
||||
);
|
||||
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
await executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "CtrlAltBackspace") {
|
||||
sendKeyboardEvent(
|
||||
[keys["Backspace"]],
|
||||
[modifiers["ControlLeft"], modifiers["AltLeft"]],
|
||||
);
|
||||
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
await executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isKeyShift || isKeyCaps) {
|
||||
toggleLayout();
|
||||
|
||||
if (isCapsLockActive) {
|
||||
if (!isKeyboardLedManagedByHost) {
|
||||
setIsCapsLockActive(false);
|
||||
}
|
||||
sendKeyboardEvent([keys["CapsLock"]], []);
|
||||
return;
|
||||
}
|
||||
// if they press any of the latching keys, we send a keypress down event and the release it automatically (on timer)
|
||||
if (latchingKeys.includes(key)) {
|
||||
console.debug(`Latching key pressed: ${key} sending down and delayed up pair`);
|
||||
handleKeyPress(keys[key], true)
|
||||
setTimeout(() => handleKeyPress(keys[key], false), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle caps lock state change
|
||||
if (isKeyCaps && !isKeyboardLedManagedByHost) {
|
||||
setIsCapsLockActive(!isCapsLockActive);
|
||||
// if they press any of the dynamic keys, we send a keypress down event but we don't release it until they click it again
|
||||
if (Object.keys(modifiers).includes(key)) {
|
||||
const currentlyDown = keyNamesForDownKeys.includes(key);
|
||||
console.debug(`Dynamic key pressed: ${key} was currently down: ${currentlyDown}, toggling state`);
|
||||
handleKeyPress(keys[key], !currentlyDown)
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect new active keys and modifiers
|
||||
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
|
||||
const newModifiers =
|
||||
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
|
||||
|
||||
// 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);
|
||||
// otherwise, just treat it as a down+up pair
|
||||
const cleanKey = key.replace(/[()]/g, "");
|
||||
console.debug(`Regular key pressed: ${cleanKey} sending down and up pair`);
|
||||
handleKeyPress(keys[cleanKey], true);
|
||||
setTimeout(() => handleKeyPress(keys[cleanKey], false), 50);
|
||||
},
|
||||
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
|
||||
[executeMacro, handleKeyPress, keyNamesForDownKeys],
|
||||
);
|
||||
|
||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="transition-all duration-500 ease-in-out"
|
||||
style={{
|
||||
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
|
||||
marginBottom: isVirtualKeyboardEnabled ? "0px" : `-${350}px`,
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{virtualKeyboard && (
|
||||
{isVirtualKeyboardEnabled && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: "100%" }}
|
||||
animate={{ opacity: 1, y: "0%" }}
|
||||
|
@ -222,30 +210,30 @@ function KeyboardWrapper() {
|
|||
>
|
||||
<div
|
||||
className={cx(
|
||||
!showAttachedVirtualKeyboard
|
||||
!isAttachedVirtualKeyboardVisible
|
||||
? "fixed left-0 top-0 z-50 select-none"
|
||||
: "relative",
|
||||
)}
|
||||
ref={keyboardRef}
|
||||
style={{
|
||||
...(!showAttachedVirtualKeyboard
|
||||
...(!isAttachedVirtualKeyboardVisible
|
||||
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className={cx("overflow-hidden", {
|
||||
"rounded-none": showAttachedVirtualKeyboard,
|
||||
"rounded-none": isAttachedVirtualKeyboardVisible,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
|
||||
<div className="absolute left-2 flex items-center gap-x-2">
|
||||
{showAttachedVirtualKeyboard ? (
|
||||
{isAttachedVirtualKeyboardVisible ? (
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Detach"
|
||||
onClick={() => setShowAttachedVirtualKeyboard(false)}
|
||||
onClick={() => setAttachedVirtualKeyboardVisibility(false)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
|
@ -253,7 +241,7 @@ function KeyboardWrapper() {
|
|||
theme="light"
|
||||
text="Attach"
|
||||
LeadingIcon={AttachIcon}
|
||||
onClick={() => setShowAttachedVirtualKeyboard(true)}
|
||||
onClick={() => setAttachedVirtualKeyboardVisibility(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -266,7 +254,7 @@ function KeyboardWrapper() {
|
|||
theme="light"
|
||||
text="Hide"
|
||||
LeadingIcon={ChevronDownIcon}
|
||||
onClick={() => setVirtualKeyboard(false)}
|
||||
onClick={() => setVirtualKeyboardEnabled(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -275,66 +263,61 @@ function KeyboardWrapper() {
|
|||
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
|
||||
<Keyboard
|
||||
baseClass="simple-keyboard-main"
|
||||
layoutName={layoutName}
|
||||
layoutName={mainLayoutName}
|
||||
onKeyPress={onKeyDown}
|
||||
onKeyReleased={onKeyUp}
|
||||
buttonTheme={[
|
||||
{
|
||||
class: "combination-key",
|
||||
buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
},
|
||||
{
|
||||
class: "down-key",
|
||||
buttons: keyNamesForDownKeys.join(" "),
|
||||
},
|
||||
]}
|
||||
display={keyDisplayMap}
|
||||
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",
|
||||
],
|
||||
}}
|
||||
layout={virtualKeyboard.main}
|
||||
disableButtonHold={true}
|
||||
syncInstanceInputs={true}
|
||||
debug={false}
|
||||
enableLayoutCandidates={false}
|
||||
preventMouseDownDefault={true}
|
||||
preventMouseUpDefault={true}
|
||||
stopMouseDownPropagation={true}
|
||||
stopMouseUpPropagation={true}
|
||||
/>
|
||||
|
||||
<div className="controlArrows">
|
||||
<Keyboard
|
||||
baseClass="simple-keyboard-control"
|
||||
theme="simple-keyboard hg-theme-default hg-layout-default"
|
||||
layoutName={layoutName}
|
||||
layoutName="default"
|
||||
onKeyPress={onKeyDown}
|
||||
onKeyReleased={onKeyUp}
|
||||
display={keyDisplayMap}
|
||||
layout={{
|
||||
default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
|
||||
shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
|
||||
}}
|
||||
syncInstanceInputs={true}
|
||||
debug={false}
|
||||
layout={virtualKeyboard.control}
|
||||
disableButtonHold={true}
|
||||
enableLayoutCandidates={false}
|
||||
preventMouseDownDefault={true}
|
||||
preventMouseUpDefault={true}
|
||||
stopMouseDownPropagation={true}
|
||||
stopMouseUpPropagation={true}
|
||||
/>
|
||||
<Keyboard
|
||||
baseClass="simple-keyboard-arrows"
|
||||
theme="simple-keyboard hg-theme-default hg-layout-default"
|
||||
onKeyPress={onKeyDown}
|
||||
onKeyReleased={onKeyUp}
|
||||
display={keyDisplayMap}
|
||||
layout={{
|
||||
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
|
||||
}}
|
||||
syncInstanceInputs={true}
|
||||
debug={false}
|
||||
layout={virtualKeyboard.arrows}
|
||||
disableButtonHold={true}
|
||||
enableLayoutCandidates={false}
|
||||
preventMouseDownDefault={true}
|
||||
preventMouseUpDefault={true}
|
||||
stopMouseDownPropagation={true}
|
||||
stopMouseUpPropagation={true}
|
||||
/>
|
||||
</div>
|
||||
{ /* TODO add optional number pad */ }
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
@ -9,9 +9,8 @@ import notifications from "@/notifications";
|
|||
import useKeyboard from "@/hooks/useKeyboard";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { cx } from "@/cva.config";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { keys } from "@/keyboardMappings";
|
||||
import {
|
||||
useHidStore,
|
||||
useMouseStore,
|
||||
useRTCStore,
|
||||
useSettingsStore,
|
||||
|
@ -28,15 +27,14 @@ import {
|
|||
export default function WebRTCVideo() {
|
||||
// Video and stream related refs and states
|
||||
const videoElm = useRef<HTMLVideoElement>(null);
|
||||
const mediaStream = useRTCStore(state => state.mediaStream);
|
||||
const { mediaStream, peerConnectionState } = useRTCStore();
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
|
||||
const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false);
|
||||
// Store hooks
|
||||
const settings = useSettingsStore();
|
||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||
const setMousePosition = useMouseStore(state => state.setMousePosition);
|
||||
const setMouseMove = useMouseStore(state => state.setMouseMove);
|
||||
const { handleKeyPress, resetKeyboardState } = useKeyboard();
|
||||
const { setMousePosition, setMouseMove } = useMouseStore();
|
||||
const {
|
||||
setClientSize: setVideoClientSize,
|
||||
setSize: setVideoSize,
|
||||
|
@ -44,49 +42,39 @@ export default function WebRTCVideo() {
|
|||
height: videoHeight,
|
||||
clientWidth: videoClientWidth,
|
||||
clientHeight: videoClientHeight,
|
||||
hdmiState,
|
||||
} = useVideoStore();
|
||||
|
||||
// Video enhancement settings
|
||||
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);
|
||||
const { videoSaturation, videoBrightness, videoContrast } = useSettingsStore();
|
||||
|
||||
// RTC related states
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
|
||||
const { peerConnection } = useRTCStore();
|
||||
const hidDataChannel = useRTCStore(state => state.hidDataChannel);
|
||||
// HDMI and UI states
|
||||
const hdmiState = useVideoStore(state => state.hdmiState);
|
||||
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
|
||||
const isVideoLoading = !isPlaying;
|
||||
|
||||
// Mouse wheel states
|
||||
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
|
||||
|
||||
// Misc states and hooks
|
||||
const [send] = useJsonRpc();
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
// 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({
|
||||
ref: videoElm as React.RefObject<HTMLElement>,
|
||||
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);
|
||||
}
|
||||
},
|
||||
onResize: handleResize,
|
||||
});
|
||||
|
||||
const updateVideoSizeStore = useCallback(
|
||||
|
@ -107,15 +95,15 @@ export default function WebRTCVideo() {
|
|||
function updateVideoSizeOnMount() {
|
||||
if (videoElm.current) updateVideoSizeStore(videoElm.current);
|
||||
},
|
||||
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
|
||||
[updateVideoSizeStore],
|
||||
);
|
||||
|
||||
// Pointer lock and keyboard lock related
|
||||
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
|
||||
const isFullscreenEnabled = document.fullscreenEnabled;
|
||||
|
||||
|
||||
const checkNavigatorPermissions = useCallback(async (permissionName: string) => {
|
||||
if (!navigator.permissions || !navigator.permissions.query) {
|
||||
if (!navigator || !navigator.permissions || !navigator.permissions.query) {
|
||||
return false; // if can't query permissions, assume NOT granted
|
||||
}
|
||||
|
||||
|
@ -149,29 +137,31 @@ export default function WebRTCVideo() {
|
|||
if (videoElm.current === null) return;
|
||||
|
||||
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
|
||||
|
||||
if (isKeyboardLockGranted && "keyboard" in navigator) {
|
||||
|
||||
if (isKeyboardLockGranted && navigator && "keyboard" in navigator) {
|
||||
try {
|
||||
// @ts-expect-error - keyboard lock is not supported in all browsers
|
||||
await navigator.keyboard.lock();
|
||||
await navigator.keyboard.lock();
|
||||
setIsKeyboardLockActive(true);
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
}, [checkNavigatorPermissions]);
|
||||
}, [checkNavigatorPermissions, setIsKeyboardLockActive]);
|
||||
|
||||
const releaseKeyboardLock = useCallback(async () => {
|
||||
if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return;
|
||||
|
||||
if ("keyboard" in navigator) {
|
||||
try {
|
||||
// @ts-expect-error - keyboard unlock is not supported in all browsers
|
||||
await navigator.keyboard.unlock();
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
if (navigator && "keyboard" in navigator) {
|
||||
try {
|
||||
// @ts-expect-error - keyboard unlock is not supported in all browsers
|
||||
await navigator.keyboard.unlock();
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
setIsKeyboardLockActive(false);
|
||||
}
|
||||
}, []);
|
||||
}, [setIsKeyboardLockActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPointerLockPossible || !videoElm.current) return;
|
||||
|
@ -197,7 +187,7 @@ export default function WebRTCVideo() {
|
|||
}, [isPointerLockPossible]);
|
||||
|
||||
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
|
||||
// If keyboard lock is activated after fullscreen is already in effect, then the user my
|
||||
|
@ -253,11 +243,21 @@ export default function WebRTCVideo() {
|
|||
const sendAbsMouseMovement = useCallback(
|
||||
(x: number, y: number, buttons: number) => {
|
||||
if (settings.mouseMode !== "absolute") return;
|
||||
send("absMouseReport", { x, y, buttons });
|
||||
if (hidDataChannel?.readyState === "open") {
|
||||
const buffer = new ArrayBuffer(6);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, 8); // type
|
||||
view.setUint16(1, x, true); // x, little-endian
|
||||
view.setUint16(3, y, true); // y, little-endian
|
||||
view.setUint8(5, buttons); // buttons
|
||||
hidDataChannel.send(buffer);
|
||||
} else {
|
||||
send("absMouseReport", { x, y, buttons });
|
||||
}
|
||||
// We set that for the debug info bar
|
||||
setMousePosition(x, y);
|
||||
},
|
||||
[send, setMousePosition, settings.mouseMode],
|
||||
[hidDataChannel?.readyState, send, setMousePosition, settings.mouseMode],
|
||||
);
|
||||
|
||||
const absMouseMoveHandler = useCallback(
|
||||
|
@ -344,153 +344,58 @@ export default function WebRTCVideo() {
|
|||
sendAbsMouseMovement(0, 0, 0);
|
||||
}, [sendAbsMouseMovement]);
|
||||
|
||||
// Keyboard-related
|
||||
const handleModifierKeys = useCallback(
|
||||
(e: KeyboardEvent, activeModifiers: number[]) => {
|
||||
const { shiftKey, ctrlKey, altKey, metaKey } = e;
|
||||
|
||||
const filteredModifiers = activeModifiers.filter(Boolean);
|
||||
|
||||
// Example: activeModifiers = [0x01, 0x02, 0x04, 0x08]
|
||||
// Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft
|
||||
return (
|
||||
filteredModifiers
|
||||
// Shift: Keep if Shift is pressed or if the key isn't a Shift key
|
||||
// Example: If shiftKey is true, keep all modifiers
|
||||
// If shiftKey is false, filter out 0x02 (ShiftLeft) and 0x20 (ShiftRight)
|
||||
.filter(
|
||||
modifier =>
|
||||
shiftKey ||
|
||||
(modifier !== modifiers["ShiftLeft"] &&
|
||||
modifier !== modifiers["ShiftRight"]),
|
||||
)
|
||||
// Ctrl: Keep if Ctrl is pressed or if the key isn't a Ctrl key
|
||||
// Example: If ctrlKey is true, keep all modifiers
|
||||
// If ctrlKey is false, filter out 0x01 (ControlLeft) and 0x10 (ControlRight)
|
||||
.filter(
|
||||
modifier =>
|
||||
ctrlKey ||
|
||||
(modifier !== modifiers["ControlLeft"] &&
|
||||
modifier !== modifiers["ControlRight"]),
|
||||
)
|
||||
// Alt: Keep if Alt is pressed or if the key isn't an Alt key
|
||||
// Example: If altKey is true, keep all modifiers
|
||||
// If altKey is false, filter out 0x04 (AltLeft)
|
||||
//
|
||||
// But intentionally do not filter out 0x40 (AltRight) to accomodate
|
||||
// Alt Gr (Alt Graph) as a modifier. Oddly, Alt Gr does not declare
|
||||
// itself to be an altKey. For example, the KeyboardEvent for
|
||||
// Alt Gr + 2 has the following structure:
|
||||
// - altKey: false
|
||||
// - code: "Digit2"
|
||||
// - type: [ "keydown" | "keyup" ]
|
||||
//
|
||||
// For context, filteredModifiers aims to keep track which modifiers
|
||||
// are being pressed on the physical keyboard at any point in time.
|
||||
// There is logic in the keyUpHandler and keyDownHandler to add and
|
||||
// remove 0x40 (AltRight) from the list of new modifiers.
|
||||
//
|
||||
// But relying on the two handlers alone to track the state of the
|
||||
// modifier bears the risk that the key up event for Alt Gr could
|
||||
// get lost while the browser window is temporarily out of focus,
|
||||
// which means the Alt Gr key state would then be "stuck". At this
|
||||
// point, we would need to rely on the user to press Alt Gr again
|
||||
// to properly release the state of that modifier.
|
||||
.filter(modifier => altKey || modifier !== modifiers["AltLeft"])
|
||||
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
|
||||
// Example: If metaKey is true, keep all modifiers
|
||||
// If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight)
|
||||
.filter(
|
||||
modifier =>
|
||||
metaKey ||
|
||||
(modifier !== modifiers["MetaLeft"] && modifier !== modifiers["MetaRight"]),
|
||||
)
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const keyDownHandler = useCallback(
|
||||
async (e: KeyboardEvent) => {
|
||||
(e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
const prev = useHidStore.getState();
|
||||
let code = e.code;
|
||||
const key = e.key;
|
||||
const code = getAdjustedKeyCode(e);
|
||||
const hidKey = keys[code];
|
||||
|
||||
if (!isKeyboardLedManagedByHost) {
|
||||
setIsNumLockActive(e.getModifierState("NumLock"));
|
||||
setIsCapsLockActive(e.getModifierState("CapsLock"));
|
||||
setIsScrollLockActive(e.getModifierState("ScrollLock"));
|
||||
if (hidKey === undefined) {
|
||||
console.warn(`Key down not mapped: ${code}`);
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
// event, so we need to clear the keys after a short delay
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
|
||||
if (e.metaKey) {
|
||||
if (e.metaKey && hidKey < 0xE0) {
|
||||
setTimeout(() => {
|
||||
const prev = useHidStore.getState();
|
||||
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
|
||||
console.debug(`Forcing the meta key release of associated key: ${hidKey}`);
|
||||
handleKeyPress(hidKey, false);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
||||
console.debug(`Key down: ${hidKey}`);
|
||||
handleKeyPress(hidKey, true);
|
||||
|
||||
if (!isKeyboardLockActive && hidKey === keys.MetaLeft) {
|
||||
// If the left meta key was just pressed and we're not keyboard locked
|
||||
// we'll never see the keyup event because the browser is going to lose
|
||||
// focus so set a deferred keyup after a short delay
|
||||
setTimeout(() => {
|
||||
console.debug(`Forcing the left meta key release`);
|
||||
handleKeyPress(hidKey, false);
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[
|
||||
handleModifierKeys,
|
||||
sendKeyboardEvent,
|
||||
isKeyboardLedManagedByHost,
|
||||
setIsNumLockActive,
|
||||
setIsCapsLockActive,
|
||||
setIsScrollLockActive,
|
||||
],
|
||||
[handleKeyPress, isKeyboardLockActive],
|
||||
);
|
||||
|
||||
const keyUpHandler = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
async (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
const prev = useHidStore.getState();
|
||||
const code = getAdjustedKeyCode(e);
|
||||
const hidKey = keys[code];
|
||||
|
||||
if (!isKeyboardLedManagedByHost) {
|
||||
setIsNumLockActive(e.getModifierState("NumLock"));
|
||||
setIsCapsLockActive(e.getModifierState("CapsLock"));
|
||||
setIsScrollLockActive(e.getModifierState("ScrollLock"));
|
||||
if (hidKey === undefined) {
|
||||
console.warn(`Key up not mapped: ${code}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filtering out the key that was just released (keys[e.code])
|
||||
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)]);
|
||||
console.debug(`Key up: ${hidKey}`);
|
||||
handleKeyPress(hidKey, false);
|
||||
},
|
||||
[
|
||||
handleModifierKeys,
|
||||
sendKeyboardEvent,
|
||||
isKeyboardLedManagedByHost,
|
||||
setIsNumLockActive,
|
||||
setIsCapsLockActive,
|
||||
setIsScrollLockActive,
|
||||
],
|
||||
[handleKeyPress],
|
||||
);
|
||||
|
||||
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
|
||||
|
@ -501,7 +406,7 @@ export default function WebRTCVideo() {
|
|||
// Fix only works in chrome based browsers.
|
||||
if (e.code === "Space") {
|
||||
if (videoElm.current.paused) {
|
||||
console.log("Force playing video");
|
||||
console.debug("Force playing video");
|
||||
videoElm.current.play();
|
||||
}
|
||||
}
|
||||
|
@ -544,13 +449,7 @@ export default function WebRTCVideo() {
|
|||
// We set the as early as possible
|
||||
addStreamToVideoElm(mediaStream);
|
||||
},
|
||||
[
|
||||
setVideoClientSize,
|
||||
mediaStream,
|
||||
updateVideoSizeStore,
|
||||
peerConnection,
|
||||
addStreamToVideoElm,
|
||||
],
|
||||
[addStreamToVideoElm, mediaStream],
|
||||
);
|
||||
|
||||
// Setup Keyboard Events
|
||||
|
@ -606,7 +505,7 @@ export default function WebRTCVideo() {
|
|||
|
||||
videoElmRefValue.addEventListener("mousemove", 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, {
|
||||
signal,
|
||||
passive: true,
|
||||
|
@ -657,6 +556,28 @@ export default function WebRTCVideo() {
|
|||
return true;
|
||||
}, [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 (
|
||||
<div className="grid h-full w-full grid-rows-(--grid-layout)">
|
||||
<div className="flex min-h-[39.5px] flex-col">
|
||||
|
@ -689,50 +610,48 @@ export default function WebRTCVideo() {
|
|||
<PointerLockBar show={showPointerLockBar} />
|
||||
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<video
|
||||
ref={videoElm}
|
||||
autoPlay={true}
|
||||
controls={false}
|
||||
onPlaying={onVideoPlaying}
|
||||
onPlay={onVideoPlaying}
|
||||
muted={true}
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
controlsList="nofullscreen"
|
||||
style={{
|
||||
filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`,
|
||||
}}
|
||||
className={cx(
|
||||
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
|
||||
{
|
||||
"cursor-none": settings.isCursorHidden,
|
||||
"opacity-0":
|
||||
isVideoLoading ||
|
||||
hdmiError ||
|
||||
peerConnectionState !== "connected",
|
||||
"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" }}
|
||||
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="relative h-full w-full rounded-md">
|
||||
<LoadingVideoOverlay show={isVideoLoading} />
|
||||
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
|
||||
<NoAutoplayPermissionsOverlay
|
||||
show={hasNoAutoPlayPermissions}
|
||||
onPlayClick={() => {
|
||||
videoElm.current?.play();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<video
|
||||
ref={videoElm}
|
||||
autoPlay
|
||||
controls={false}
|
||||
onPlaying={onVideoPlaying}
|
||||
onPlay={onVideoPlaying}
|
||||
muted
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
controlsList="nofullscreen"
|
||||
style={videoStyle}
|
||||
className={cx(
|
||||
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
|
||||
{
|
||||
"cursor-none": settings.isCursorHidden,
|
||||
"opacity-0":
|
||||
isVideoLoading ||
|
||||
hdmiError ||
|
||||
peerConnectionState !== "connected",
|
||||
"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" }}
|
||||
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="relative h-full w-full rounded-md">
|
||||
<LoadingVideoOverlay show={isVideoLoading} />
|
||||
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
|
||||
<NoAutoplayPermissionsOverlay
|
||||
show={hasNoAutoPlayPermissions}
|
||||
onPlayClick={() => {
|
||||
videoElm.current?.play();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<VirtualKeyboard />
|
||||
|
|
|
@ -7,7 +7,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
|
|||
import notifications from "@/notifications";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
||||
import { useJsonRpc } from "../../hooks/useJsonRpc";
|
||||
import { JsonRpcResponse, useJsonRpc } from "../../hooks/useJsonRpc";
|
||||
|
||||
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
|
||||
|
||||
|
@ -23,7 +23,7 @@ export function ATXPowerControl() {
|
|||
> | 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") {
|
||||
setAtxState(resp.params as ATXState);
|
||||
}
|
||||
|
@ -31,7 +31,7 @@ export function ATXPowerControl() {
|
|||
|
||||
// Request initial state
|
||||
useEffect(() => {
|
||||
send("getATXState", {}, resp => {
|
||||
send("getATXState", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`,
|
||||
|
@ -54,7 +54,7 @@ export function ATXPowerControl() {
|
|||
const timer = setTimeout(() => {
|
||||
// Send long press action
|
||||
console.log("Sending long press ATX power action");
|
||||
send("setATXPowerAction", { action: "power-long" }, resp => {
|
||||
send("setATXPowerAction", { action: "power-long" }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
||||
|
@ -75,7 +75,7 @@ export function ATXPowerControl() {
|
|||
|
||||
// Send short press action
|
||||
console.log("Sending short press ATX power action");
|
||||
send("setATXPowerAction", { action: "power-short" }, resp => {
|
||||
send("setATXPowerAction", { action: "power-short" }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
||||
|
@ -127,7 +127,7 @@ export function ATXPowerControl() {
|
|||
LeadingIcon={LuRotateCcw}
|
||||
text="Reset"
|
||||
onClick={() => {
|
||||
send("setATXPowerAction", { action: "reset" }, resp => {
|
||||
send("setATXPowerAction", { action: "reset" }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { Button } from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "@/notifications";
|
||||
import FieldLabel from "@components/FieldLabel";
|
||||
import LoadingSpinner from "@components/LoadingSpinner";
|
||||
|
@ -19,11 +19,11 @@ interface DCPowerState {
|
|||
}
|
||||
|
||||
export function DCPowerControl() {
|
||||
const [send] = useJsonRpc();
|
||||
const { send } = useJsonRpc();
|
||||
const [powerState, setPowerState] = useState<DCPowerState | null>(null);
|
||||
|
||||
const getDCPowerState = useCallback(() => {
|
||||
send("getDCPowerState", {}, resp => {
|
||||
send("getDCPowerState", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to get DC power state: ${resp.error.data || "Unknown error"}`,
|
||||
|
@ -35,7 +35,7 @@ export function DCPowerControl() {
|
|||
}, [send]);
|
||||
|
||||
const handlePowerToggle = (enabled: boolean) => {
|
||||
send("setDCPowerState", { enabled }, resp => {
|
||||
send("setDCPowerState", { enabled }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
|
||||
|
@ -47,7 +47,7 @@ export function DCPowerControl() {
|
|||
};
|
||||
const handleRestoreChange = (state: number) => {
|
||||
// const state = powerState?.restoreState === 0 ? 1 : powerState?.restoreState === 1 ? 2 : 0;
|
||||
send("setDCRestoreState", { state }, resp => {
|
||||
send("setDCRestoreState", { state }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
|||
import { Button } from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "@/notifications";
|
||||
import { useUiStore } from "@/hooks/stores";
|
||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||
|
@ -17,7 +17,7 @@ interface SerialSettings {
|
|||
}
|
||||
|
||||
export function SerialConsole() {
|
||||
const [send] = useJsonRpc();
|
||||
const { send } = useJsonRpc();
|
||||
const [settings, setSettings] = useState<SerialSettings>({
|
||||
baudRate: "9600",
|
||||
dataBits: "8",
|
||||
|
@ -26,7 +26,7 @@ export function SerialConsole() {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
send("getSerialSettings", {}, resp => {
|
||||
send("getSerialSettings", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.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 newSettings = { ...settings, [setting]: value };
|
||||
send("setSerialSettings", { settings: newSettings }, resp => {
|
||||
send("setSerialSettings", { settings: newSettings }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to update serial settings: ${resp.error.data || "Unknown error"}`,
|
||||
|
@ -49,7 +49,7 @@ export function SerialConsole() {
|
|||
setSettings(newSettings);
|
||||
});
|
||||
};
|
||||
const setTerminalType = useUiStore(state => state.setTerminalType);
|
||||
const { setTerminalType } = useUiStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
|
||||
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import Card, { GridCard } from "@components/Card";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
|
||||
|
@ -39,12 +39,12 @@ const AVAILABLE_EXTENSIONS: Extension[] = [
|
|||
];
|
||||
|
||||
export default function ExtensionPopover() {
|
||||
const [send] = useJsonRpc();
|
||||
const { send } = useJsonRpc();
|
||||
const [activeExtension, setActiveExtension] = useState<Extension | null>(null);
|
||||
|
||||
// Load active extension on component mount
|
||||
useEffect(() => {
|
||||
send("getActiveExtension", {}, resp => {
|
||||
send("getActiveExtension", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) return;
|
||||
const extensionId = resp.result as string;
|
||||
if (extensionId) {
|
||||
|
@ -57,7 +57,7 @@ export default function ExtensionPopover() {
|
|||
}, [send]);
|
||||
|
||||
const handleSetActiveExtension = (extension: Extension | null) => {
|
||||
send("setActiveExtension", { extensionId: extension?.id || "" }, resp => {
|
||||
send("setActiveExtension", { extensionId: extension?.id || "" }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set active extension: ${resp.error.data || "Unknown error"}`,
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { useMemo, forwardRef, useEffect, useCallback } from "react";
|
||||
import { forwardRef, useEffect, useCallback } from "react";
|
||||
import {
|
||||
LuArrowUpFromLine,
|
||||
LuCheckCheck,
|
||||
LuLink,
|
||||
LuPlus,
|
||||
LuRadioReceiver,
|
||||
|
@ -14,40 +11,19 @@ import { useLocation } from "react-router-dom";
|
|||
import { Button } from "@components/Button";
|
||||
import Card, { GridCard } from "@components/Card";
|
||||
import { formatters } from "@/utils";
|
||||
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
|
||||
import { RemoteVirtualMediaState, useMountMediaStore } from "@/hooks/stores";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
import notifications from "@/notifications";
|
||||
|
||||
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
||||
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
|
||||
const [send] = useJsonRpc();
|
||||
const { send } = useJsonRpc();
|
||||
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
|
||||
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(() => {
|
||||
send("getVirtualMediaState", {}, response => {
|
||||
send("getVirtualMediaState", {}, (response: JsonRpcResponse) => {
|
||||
if ("error" in response) {
|
||||
notifications.error(
|
||||
`Failed to get virtual media state: ${response.error.message}`,
|
||||
|
@ -59,7 +35,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
}, [send, setRemoteVirtualMediaState]);
|
||||
|
||||
const handleUnmount = () => {
|
||||
send("unmountImage", {}, response => {
|
||||
send("unmountImage", {}, (response: JsonRpcResponse) => {
|
||||
if ("error" in response) {
|
||||
notifications.error(`Failed to unmount image: ${response.error.message}`);
|
||||
} else {
|
||||
|
@ -94,42 +70,6 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
const { source, filename, size, url, path } = remoteVirtualMediaState;
|
||||
|
||||
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":
|
||||
return (
|
||||
<div className="">
|
||||
|
@ -202,18 +142,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
description="Mount an image to boot from or install an operating system."
|
||||
/>
|
||||
|
||||
{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
|
||||
<div
|
||||
className="animate-fadeIn opacity-0 space-y-2"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { LuCornerDownLeft } from "react-icons/lu";
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
||||
import { useClose } from "@headlessui/react";
|
||||
|
@ -7,104 +7,102 @@ import { Button } from "@components/Button";
|
|||
import { GridCard } from "@components/Card";
|
||||
import { TextAreaWithLabel } from "@components/TextArea";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { layouts, chars } from "@/keyboardLayouts";
|
||||
import { KeyStroke } from "@/keyboardLayouts";
|
||||
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||
import notifications from "@/notifications";
|
||||
|
||||
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
||||
return { keys, modifier };
|
||||
const hidKeyboardPayload = (modifier: number, keys: number[]) => {
|
||||
return { modifier, keys };
|
||||
};
|
||||
|
||||
const modifierCode = (shift?: boolean, altRight?: boolean) => {
|
||||
return (shift ? modifiers["ShiftLeft"] : 0)
|
||||
| (altRight ? modifiers["AltRight"] : 0)
|
||||
return (shift ? modifiers.ShiftLeft : 0)
|
||||
| (altRight ? modifiers.AltRight : 0)
|
||||
}
|
||||
const noModifier = 0
|
||||
|
||||
export default function PasteModal() {
|
||||
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
|
||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const { setPasteModeEnabled } = useHidStore();
|
||||
const { setDisableVideoFocusTrap } = useUiStore();
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
const { send } = useJsonRpc();
|
||||
const { rpcDataChannel } = useRTCStore();
|
||||
|
||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||
const close = useClose();
|
||||
|
||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||
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]);
|
||||
const { setKeyboardLayout } = useSettingsStore();
|
||||
const { selectedKeyboard } = useKeyboardLayout();
|
||||
|
||||
useEffect(() => {
|
||||
send("getKeyboardLayout", {}, resp => {
|
||||
send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) return;
|
||||
setKeyboardLayout(resp.result as string);
|
||||
});
|
||||
}, [send, setKeyboardLayout]);
|
||||
|
||||
const onCancelPasteMode = useCallback(() => {
|
||||
setPasteMode(false);
|
||||
setPasteModeEnabled(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
setInvalidChars([]);
|
||||
}, [setDisableVideoFocusTrap, setPasteMode]);
|
||||
}, [setDisableVideoFocusTrap, setPasteModeEnabled]);
|
||||
|
||||
const onConfirmPaste = useCallback(async () => {
|
||||
setPasteMode(false);
|
||||
setPasteModeEnabled(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
|
||||
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
|
||||
if (!safeKeyboardLayout) return;
|
||||
if (!chars[safeKeyboardLayout]) return;
|
||||
if (!selectedKeyboard) return;
|
||||
|
||||
const text = TextAreaRef.current.value;
|
||||
|
||||
try {
|
||||
for (const char of text) {
|
||||
const { key, shift, altRight, deadKey, accentKey } = chars[safeKeyboardLayout][char]
|
||||
const keyprops = selectedKeyboard.chars[char];
|
||||
if (!keyprops) continue;
|
||||
|
||||
const { key, shift, altRight, deadKey, accentKey } = keyprops;
|
||||
if (!key) continue;
|
||||
|
||||
const keyz = [ keys[key] ];
|
||||
const modz = [ modifierCode(shift, altRight) ];
|
||||
|
||||
if (deadKey) {
|
||||
keyz.push(keys["Space"]);
|
||||
modz.push(noModifier);
|
||||
}
|
||||
// if this is an accented character, we need to send that accent FIRST
|
||||
if (accentKey) {
|
||||
keyz.unshift(keys[accentKey.key])
|
||||
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
|
||||
await sendKeystroke({modifier: modifierCode(accentKey.shift, accentKey.altRight), keys: [ keys[accentKey.key] ] })
|
||||
}
|
||||
|
||||
for (const [index, kei] of keyz.entries()) {
|
||||
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();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
// 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) {
|
||||
await sendKeystroke({ modifier: noModifier, keys: [ keys["Space"] ] });
|
||||
}
|
||||
|
||||
// now send a message with no keys down to "release" the keys
|
||||
await sendKeystroke({ modifier: 0, keys: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error("Failed to paste text:", error);
|
||||
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(() => {
|
||||
if (TextAreaRef.current) {
|
||||
|
@ -154,7 +152,7 @@ export default function PasteModal() {
|
|||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||
[...new Intl.Segmenter().segment(value)]
|
||||
.map(x => x.segment)
|
||||
.filter(char => !chars[safeKeyboardLayout][char]),
|
||||
.filter(char => !selectedKeyboard.chars[char]),
|
||||
),
|
||||
];
|
||||
|
||||
|
@ -175,7 +173,7 @@ export default function PasteModal() {
|
|||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
Sending text using keyboard layout: {layouts[safeKeyboardLayout]}
|
||||
Sending text using keyboard layout: {selectedKeyboard.isoCode}-{selectedKeyboard.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useClose } from "@headlessui/react";
|
|||
|
||||
import { GridCard } from "@components/Card";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
||||
import notifications from "@/notifications";
|
||||
|
||||
|
@ -14,26 +14,24 @@ import AddDeviceForm from "./AddDeviceForm";
|
|||
export default function WakeOnLanModal() {
|
||||
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
const { setDisableVideoFocusTrap } = useUiStore();
|
||||
const { rpcDataChannel } = useRTCStore();
|
||||
const { send } = useJsonRpc();
|
||||
const close = useClose();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const onCancelWakeOnLanModal = useCallback(() => {
|
||||
setDisableVideoFocusTrap(false);
|
||||
close();
|
||||
setDisableFocusTrap(false);
|
||||
}, [close, setDisableFocusTrap]);
|
||||
}, [close, setDisableVideoFocusTrap]);
|
||||
|
||||
const onSendMagicPacket = useCallback(
|
||||
(macAddress: string) => {
|
||||
setErrorMessage(null);
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
|
||||
send("sendWOLMagicPacket", { macAddress }, resp => {
|
||||
send("sendWOLMagicPacket", { macAddress }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
const isInvalid = resp.error.data?.includes("invalid MAC address");
|
||||
if (isInvalid) {
|
||||
|
@ -43,16 +41,16 @@ export default function WakeOnLanModal() {
|
|||
}
|
||||
} else {
|
||||
notifications.success("Magic Packet sent successfully");
|
||||
setDisableFocusTrap(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
close();
|
||||
}
|
||||
});
|
||||
},
|
||||
[close, rpcDataChannel?.readyState, send, setDisableFocusTrap],
|
||||
[close, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap],
|
||||
);
|
||||
|
||||
const syncStoredDevices = useCallback(() => {
|
||||
send("getWakeOnLanDevices", {}, resp => {
|
||||
send("getWakeOnLanDevices", {}, (resp: JsonRpcResponse) => {
|
||||
if ("result" in resp) {
|
||||
setStoredDevices(resp.result as StoredDevice[]);
|
||||
} else {
|
||||
|
@ -70,7 +68,7 @@ export default function WakeOnLanModal() {
|
|||
(index: number) => {
|
||||
const updatedDevices = storedDevices.filter((_, i) => i !== index);
|
||||
|
||||
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
|
||||
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
console.error("Failed to update Wake-on-LAN devices:", resp.error);
|
||||
} else {
|
||||
|
@ -78,7 +76,7 @@ export default function WakeOnLanModal() {
|
|||
}
|
||||
});
|
||||
},
|
||||
[storedDevices, send, syncStoredDevices],
|
||||
[send, storedDevices, syncStoredDevices],
|
||||
);
|
||||
|
||||
const onAddDevice = useCallback(
|
||||
|
@ -86,7 +84,7 @@ export default function WakeOnLanModal() {
|
|||
if (!name || !macAddress) return;
|
||||
const updatedDevices = [...storedDevices, { name, macAddress }];
|
||||
console.log("updatedDevices", updatedDevices);
|
||||
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
|
||||
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
console.error("Failed to add Wake-on-LAN device:", resp.error);
|
||||
setAddDeviceErrorMessage("Failed to add device");
|
||||
|
|
|
@ -37,10 +37,18 @@ function createChartArray<T, K extends keyof T>(
|
|||
}
|
||||
|
||||
export default function ConnectionStatsSidebar() {
|
||||
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
|
||||
|
||||
const candidatePairStats = useRTCStore(state => state.candidatePairStats);
|
||||
const setSidebarView = useUiStore(state => state.setSidebarView);
|
||||
const { sidebarView, setSidebarView } = useUiStore();
|
||||
const {
|
||||
mediaStream,
|
||||
peerConnection,
|
||||
inboundRtpStats,
|
||||
appendInboundRtpStats,
|
||||
candidatePairStats,
|
||||
appendCandidatePairStats,
|
||||
appendLocalCandidateStats,
|
||||
appendRemoteCandidateStats,
|
||||
appendDiskDataChannelStats,
|
||||
} = useRTCStore();
|
||||
|
||||
function isMetricSupported<T, K extends keyof T>(
|
||||
stream: Map<number, T>,
|
||||
|
@ -49,20 +57,6 @@ export default function ConnectionStatsSidebar() {
|
|||
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() {
|
||||
(async () => {
|
||||
if (!mediaStream) return;
|
||||
|
@ -80,8 +74,7 @@ export default function ConnectionStatsSidebar() {
|
|||
successfulLocalCandidateId = report.localCandidateId;
|
||||
successfulRemoteCandidateId = report.remoteCandidateId;
|
||||
}
|
||||
|
||||
appendIceCandidatePair(report);
|
||||
appendCandidatePairStats(report);
|
||||
} else if (report.type === "local-candidate") {
|
||||
// We only want to append the local candidate stats that were used in nominated candidate pair
|
||||
if (successfulLocalCandidateId === report.id) {
|
||||
|
|
|
@ -47,12 +47,12 @@ export interface User {
|
|||
picture?: string;
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
export interface UserState {
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
}
|
||||
|
||||
interface UIState {
|
||||
export interface UIState {
|
||||
sidebarView: AvailableSidebarViews | null;
|
||||
setSidebarView: (view: AvailableSidebarViews | null) => void;
|
||||
|
||||
|
@ -68,21 +68,21 @@ interface UIState {
|
|||
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
|
||||
|
||||
terminalType: AvailableTerminalTypes;
|
||||
setTerminalType: (enabled: UIState["terminalType"]) => void;
|
||||
setTerminalType: (type: UIState["terminalType"]) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UIState>(set => ({
|
||||
terminalType: "none",
|
||||
setTerminalType: type => set({ terminalType: type }),
|
||||
setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }),
|
||||
|
||||
sidebarView: null,
|
||||
setSidebarView: view => set({ sidebarView: view }),
|
||||
setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }),
|
||||
|
||||
disableVideoFocusTrap: false,
|
||||
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
|
||||
setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }),
|
||||
|
||||
isWakeOnLanModalVisible: false,
|
||||
setWakeOnLanModalVisibility: enabled => set({ isWakeOnLanModalVisible: enabled }),
|
||||
setWakeOnLanModalVisibility: (enabled: boolean) => set({ isWakeOnLanModalVisible: enabled }),
|
||||
|
||||
toggleSidebarView: view =>
|
||||
set(state => {
|
||||
|
@ -94,20 +94,17 @@ export const useUiStore = create<UIState>(set => ({
|
|||
}),
|
||||
|
||||
isAttachedVirtualKeyboardVisible: true,
|
||||
setAttachedVirtualKeyboardVisibility: enabled =>
|
||||
setAttachedVirtualKeyboardVisibility: (enabled: boolean) =>
|
||||
set({ isAttachedVirtualKeyboardVisible: enabled }),
|
||||
}));
|
||||
|
||||
interface RTCState {
|
||||
export interface RTCState {
|
||||
peerConnection: RTCPeerConnection | null;
|
||||
setPeerConnection: (pc: RTCState["peerConnection"]) => void;
|
||||
|
||||
setRpcDataChannel: (channel: RTCDataChannel) => void;
|
||||
rpcDataChannel: RTCDataChannel | null;
|
||||
|
||||
diskChannel: RTCDataChannel | null;
|
||||
setDiskChannel: (channel: RTCDataChannel) => void;
|
||||
|
||||
peerConnectionState: RTCPeerConnectionState | null;
|
||||
setPeerConnectionState: (state: RTCPeerConnectionState) => void;
|
||||
|
||||
|
@ -118,18 +115,18 @@ interface RTCState {
|
|||
setMediaStream: (stream: MediaStream) => void;
|
||||
|
||||
videoStreamStats: RTCInboundRtpStreamStats | null;
|
||||
appendVideoStreamStats: (state: RTCInboundRtpStreamStats) => void;
|
||||
appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => void;
|
||||
videoStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>;
|
||||
|
||||
isTurnServerInUse: boolean;
|
||||
setTurnServerInUse: (inUse: boolean) => void;
|
||||
|
||||
inboundRtpStats: Map<number, RTCInboundRtpStreamStats>;
|
||||
appendInboundRtpStats: (state: RTCInboundRtpStreamStats) => void;
|
||||
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => void;
|
||||
clearInboundRtpStats: () => void;
|
||||
|
||||
candidatePairStats: Map<number, RTCIceCandidatePairStats>;
|
||||
appendCandidatePairStats: (pair: RTCIceCandidatePairStats) => void;
|
||||
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => void;
|
||||
clearCandidatePairStats: () => void;
|
||||
|
||||
// Remote ICE candidates stat type doesn't exist as of today
|
||||
|
@ -141,86 +138,89 @@ interface RTCState {
|
|||
|
||||
// Disk data channel stats type doesn't exist as of today
|
||||
diskDataChannelStats: Map<number, RTCDataChannelStats>;
|
||||
appendDiskDataChannelStats: (stat: RTCDataChannelStats) => void;
|
||||
appendDiskDataChannelStats: (stats: RTCDataChannelStats) => void;
|
||||
|
||||
terminalChannel: RTCDataChannel | null;
|
||||
setTerminalChannel: (channel: RTCDataChannel) => void;
|
||||
|
||||
hidDataChannel: RTCDataChannel | null;
|
||||
setHidDataChannel: (channel: RTCDataChannel) => void;
|
||||
}
|
||||
|
||||
export const useRTCStore = create<RTCState>(set => ({
|
||||
peerConnection: null,
|
||||
setPeerConnection: pc => set({ peerConnection: pc }),
|
||||
setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }),
|
||||
|
||||
rpcDataChannel: null,
|
||||
setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
|
||||
setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }),
|
||||
|
||||
hidDataChannel: null,
|
||||
setHidDataChannel: channel => set({ hidDataChannel: channel }),
|
||||
|
||||
transceiver: null,
|
||||
setTransceiver: transceiver => set({ transceiver }),
|
||||
setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }),
|
||||
|
||||
peerConnectionState: null,
|
||||
setPeerConnectionState: state => set({ peerConnectionState: state }),
|
||||
|
||||
diskChannel: null,
|
||||
setDiskChannel: channel => set({ diskChannel: channel }),
|
||||
setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }),
|
||||
|
||||
mediaStream: null,
|
||||
setMediaStream: stream => set({ mediaStream: stream }),
|
||||
setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }),
|
||||
|
||||
videoStreamStats: null,
|
||||
appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
|
||||
appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }),
|
||||
videoStreamStatsHistory: new Map(),
|
||||
|
||||
isTurnServerInUse: false,
|
||||
setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
|
||||
setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }),
|
||||
|
||||
inboundRtpStats: new Map(),
|
||||
appendInboundRtpStats: newStat => {
|
||||
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => {
|
||||
set(prevState => ({
|
||||
inboundRtpStats: appendStatToMap(newStat, prevState.inboundRtpStats),
|
||||
inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats),
|
||||
}));
|
||||
},
|
||||
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
|
||||
|
||||
candidatePairStats: new Map(),
|
||||
appendCandidatePairStats: newStat => {
|
||||
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => {
|
||||
set(prevState => ({
|
||||
candidatePairStats: appendStatToMap(newStat, prevState.candidatePairStats),
|
||||
candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats),
|
||||
}));
|
||||
},
|
||||
clearCandidatePairStats: () => set({ candidatePairStats: new Map() }),
|
||||
|
||||
localCandidateStats: new Map(),
|
||||
appendLocalCandidateStats: newStat => {
|
||||
appendLocalCandidateStats: (stats: RTCIceCandidateStats) => {
|
||||
set(prevState => ({
|
||||
localCandidateStats: appendStatToMap(newStat, prevState.localCandidateStats),
|
||||
localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats),
|
||||
}));
|
||||
},
|
||||
|
||||
remoteCandidateStats: new Map(),
|
||||
appendRemoteCandidateStats: newStat => {
|
||||
appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => {
|
||||
set(prevState => ({
|
||||
remoteCandidateStats: appendStatToMap(newStat, prevState.remoteCandidateStats),
|
||||
remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats),
|
||||
}));
|
||||
},
|
||||
|
||||
diskDataChannelStats: new Map(),
|
||||
appendDiskDataChannelStats: newStat => {
|
||||
appendDiskDataChannelStats: (stats: RTCDataChannelStats) => {
|
||||
set(prevState => ({
|
||||
diskDataChannelStats: appendStatToMap(newStat, prevState.diskDataChannelStats),
|
||||
diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats),
|
||||
}));
|
||||
},
|
||||
|
||||
// Add these new properties to the store implementation
|
||||
terminalChannel: null,
|
||||
setTerminalChannel: channel => set({ terminalChannel: channel }),
|
||||
setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }),
|
||||
}));
|
||||
|
||||
interface MouseMove {
|
||||
export interface MouseMove {
|
||||
x: number;
|
||||
y: number;
|
||||
buttons: number;
|
||||
}
|
||||
interface MouseState {
|
||||
export interface MouseState {
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
mouseMove?: MouseMove;
|
||||
|
@ -232,9 +232,17 @@ export const useMouseStore = create<MouseState>(set => ({
|
|||
mouseX: 0,
|
||||
mouseY: 0,
|
||||
setMouseMove: (move?: MouseMove) => set({ mouseMove: move }),
|
||||
setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }),
|
||||
setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }),
|
||||
}));
|
||||
|
||||
export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting";
|
||||
export type HdmiErrorStates = Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">
|
||||
|
||||
export interface HdmiState {
|
||||
ready: boolean;
|
||||
error?: HdmiErrorStates;
|
||||
}
|
||||
|
||||
export interface VideoState {
|
||||
width: number;
|
||||
height: number;
|
||||
|
@ -242,19 +250,13 @@ export interface VideoState {
|
|||
clientHeight: number;
|
||||
setClientSize: (width: number, height: number) => void;
|
||||
setSize: (width: number, height: number) => void;
|
||||
hdmiState: "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting";
|
||||
hdmiState: HdmiStates;
|
||||
setHdmiState: (state: {
|
||||
ready: boolean;
|
||||
error?: Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">;
|
||||
error?: HdmiErrorStates;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface BacklightSettings {
|
||||
max_brightness: number;
|
||||
dim_after: number;
|
||||
off_after: number;
|
||||
}
|
||||
|
||||
export const useVideoStore = create<VideoState>(set => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
|
@ -263,13 +265,13 @@ export const useVideoStore = create<VideoState>(set => ({
|
|||
clientHeight: 0,
|
||||
|
||||
// The video element's client size
|
||||
setClientSize: (clientWidth, clientHeight) => set({ clientWidth, clientHeight }),
|
||||
setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }),
|
||||
|
||||
// Resolution
|
||||
setSize: (width, height) => set({ width, height }),
|
||||
setSize: (width: number, height: number) => set({ width, height }),
|
||||
|
||||
hdmiState: "connecting",
|
||||
setHdmiState: state => {
|
||||
setHdmiState: (state: HdmiState) => {
|
||||
if (!state) return;
|
||||
const { ready, error } = state;
|
||||
|
||||
|
@ -283,9 +285,13 @@ export const useVideoStore = create<VideoState>(set => ({
|
|||
},
|
||||
}));
|
||||
|
||||
export type KeyboardLedSync = "auto" | "browser" | "host";
|
||||
export interface BacklightSettings {
|
||||
max_brightness: number;
|
||||
dim_after: number;
|
||||
off_after: number;
|
||||
}
|
||||
|
||||
interface SettingsState {
|
||||
export interface SettingsState {
|
||||
isCursorHidden: boolean;
|
||||
setCursorVisibility: (enabled: boolean) => void;
|
||||
|
||||
|
@ -308,9 +314,6 @@ interface SettingsState {
|
|||
keyboardLayout: string;
|
||||
setKeyboardLayout: (layout: string) => void;
|
||||
|
||||
keyboardLedSync: KeyboardLedSync;
|
||||
setKeyboardLedSync: (sync: KeyboardLedSync) => void;
|
||||
|
||||
scrollThrottling: number;
|
||||
setScrollThrottling: (value: number) => void;
|
||||
|
||||
|
@ -330,17 +333,17 @@ export const useSettingsStore = create(
|
|||
persist<SettingsState>(
|
||||
set => ({
|
||||
isCursorHidden: false,
|
||||
setCursorVisibility: enabled => set({ isCursorHidden: enabled }),
|
||||
setCursorVisibility: (enabled: boolean) => set({ isCursorHidden: enabled }),
|
||||
|
||||
mouseMode: "absolute",
|
||||
setMouseMode: mode => set({ mouseMode: mode }),
|
||||
setMouseMode: (mode: string) => set({ mouseMode: mode }),
|
||||
|
||||
debugMode: import.meta.env.DEV,
|
||||
setDebugMode: enabled => set({ debugMode: enabled }),
|
||||
setDebugMode: (enabled: boolean) => set({ debugMode: enabled }),
|
||||
|
||||
// Add developer mode with default value
|
||||
developerMode: false,
|
||||
setDeveloperMode: enabled => set({ developerMode: enabled }),
|
||||
setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }),
|
||||
|
||||
displayRotation: "270",
|
||||
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
|
||||
|
@ -354,24 +357,21 @@ export const useSettingsStore = create(
|
|||
set({ backlightSettings: settings }),
|
||||
|
||||
keyboardLayout: "en-US",
|
||||
setKeyboardLayout: layout => set({ keyboardLayout: layout }),
|
||||
|
||||
keyboardLedSync: "auto",
|
||||
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
|
||||
setKeyboardLayout: (layout: string) => set({ keyboardLayout: layout }),
|
||||
|
||||
scrollThrottling: 0,
|
||||
setScrollThrottling: value => set({ scrollThrottling: value }),
|
||||
setScrollThrottling: (value: number) => set({ scrollThrottling: value }),
|
||||
|
||||
showPressedKeys: true,
|
||||
setShowPressedKeys: show => set({ showPressedKeys: show }),
|
||||
setShowPressedKeys: (show: boolean) => set({ showPressedKeys: show }),
|
||||
|
||||
// Video enhancement settings with default values (1.0 = normal)
|
||||
videoSaturation: 1.0,
|
||||
setVideoSaturation: value => set({ videoSaturation: value }),
|
||||
setVideoSaturation: (value: number) => set({ videoSaturation: value }),
|
||||
videoBrightness: 1.0,
|
||||
setVideoBrightness: value => set({ videoBrightness: value }),
|
||||
setVideoBrightness: (value: number) => set({ videoBrightness: value }),
|
||||
videoContrast: 1.0,
|
||||
setVideoContrast: value => set({ videoContrast: value }),
|
||||
setVideoContrast: (value: number) => set({ videoContrast: value }),
|
||||
}),
|
||||
{
|
||||
name: "settings",
|
||||
|
@ -381,7 +381,7 @@ export const useSettingsStore = create(
|
|||
);
|
||||
|
||||
export interface RemoteVirtualMediaState {
|
||||
source: "WebRTC" | "HTTP" | "Storage" | null;
|
||||
source: "HTTP" | "Storage" | null;
|
||||
mode: "CDROM" | "Disk" | null;
|
||||
filename: string | null;
|
||||
url: string | null;
|
||||
|
@ -390,13 +390,10 @@ export interface RemoteVirtualMediaState {
|
|||
}
|
||||
|
||||
export interface MountMediaState {
|
||||
localFile: File | null;
|
||||
setLocalFile: (file: MountMediaState["localFile"]) => void;
|
||||
|
||||
remoteVirtualMediaState: RemoteVirtualMediaState | null;
|
||||
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
|
||||
|
||||
modalView: "mode" | "browser" | "url" | "device" | "upload" | "error" | null;
|
||||
modalView: "mode" | "url" | "device" | "upload" | "error" | null;
|
||||
setModalView: (view: MountMediaState["modalView"]) => void;
|
||||
|
||||
isMountMediaDialogOpen: boolean;
|
||||
|
@ -410,24 +407,21 @@ export interface MountMediaState {
|
|||
}
|
||||
|
||||
export const useMountMediaStore = create<MountMediaState>(set => ({
|
||||
localFile: null,
|
||||
setLocalFile: file => set({ localFile: file }),
|
||||
|
||||
remoteVirtualMediaState: null,
|
||||
setRemoteVirtualMediaState: state => set({ remoteVirtualMediaState: state }),
|
||||
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }),
|
||||
|
||||
modalView: "mode",
|
||||
setModalView: view => set({ modalView: view }),
|
||||
setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }),
|
||||
|
||||
isMountMediaDialogOpen: false,
|
||||
setIsMountMediaDialogOpen: isOpen => set({ isMountMediaDialogOpen: isOpen }),
|
||||
setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }),
|
||||
|
||||
uploadedFiles: [],
|
||||
addUploadedFile: file =>
|
||||
addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) =>
|
||||
set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })),
|
||||
|
||||
errorMessage: null,
|
||||
setErrorMessage: message => set({ errorMessage: message }),
|
||||
setErrorMessage: (message: string | null) => set({ errorMessage: message }),
|
||||
}));
|
||||
|
||||
export interface KeyboardLedState {
|
||||
|
@ -436,41 +430,33 @@ export interface KeyboardLedState {
|
|||
scroll_lock: boolean;
|
||||
compose: 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 {
|
||||
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;
|
||||
keyboardLedState: KeyboardLedState;
|
||||
setKeyboardLedState: (state: KeyboardLedState) => void;
|
||||
setIsNumLockActive: (active: boolean) => void;
|
||||
setIsCapsLockActive: (active: boolean) => void;
|
||||
setIsScrollLockActive: (active: boolean) => void;
|
||||
|
||||
keyboardLedStateSyncAvailable: boolean;
|
||||
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
|
||||
keysDownState: KeysDownState;
|
||||
setKeysDownState: (state: KeysDownState) => void;
|
||||
|
||||
keyPressReportApiAvailable: boolean;
|
||||
setkeyPressReportApiAvailable: (available: boolean) => void;
|
||||
|
||||
isVirtualKeyboardEnabled: boolean;
|
||||
setVirtualKeyboardEnabled: (enabled: boolean) => void;
|
||||
|
@ -478,55 +464,29 @@ export interface HidState {
|
|||
isPasteModeEnabled: boolean;
|
||||
setPasteModeEnabled: (enabled: boolean) => void;
|
||||
|
||||
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed";
|
||||
setUsbState: (state: HidState["usbState"]) => void;
|
||||
usbState: USBStates;
|
||||
setUsbState: (state: USBStates) => void;
|
||||
}
|
||||
|
||||
export const useHidStore = create<HidState>((set, get) => ({
|
||||
activeKeys: [],
|
||||
activeModifiers: [],
|
||||
updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
|
||||
return set({ activeKeys: keys, activeModifiers: modifiers });
|
||||
},
|
||||
export const useHidStore = create<HidState>(set => ({
|
||||
keyboardLedState: {} as KeyboardLedState,
|
||||
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }),
|
||||
|
||||
altGrArmed: false,
|
||||
setAltGrArmed: armed => set({ altGrArmed: armed }),
|
||||
keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState,
|
||||
setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }),
|
||||
|
||||
altGrTimer: 0,
|
||||
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 }),
|
||||
keyPressReportApiAvailable: true,
|
||||
setkeyPressReportApiAvailable: (available: boolean) => set({ keyPressReportApiAvailable: available }),
|
||||
|
||||
isVirtualKeyboardEnabled: false,
|
||||
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
|
||||
setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }),
|
||||
|
||||
isPasteModeEnabled: false,
|
||||
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
|
||||
setPasteModeEnabled: (enabled: boolean): void => set({ isPasteModeEnabled: enabled }),
|
||||
|
||||
// Add these new properties for USB state
|
||||
usbState: "not attached",
|
||||
setUsbState: state => set({ usbState: state }),
|
||||
setUsbState: (state: USBStates) => set({ usbState: state }),
|
||||
}));
|
||||
|
||||
export const useUserStore = create<UserState>(set => ({
|
||||
|
@ -534,11 +494,15 @@ export const useUserStore = create<UserState>(set => ({
|
|||
setUser: user => set({ user }),
|
||||
}));
|
||||
|
||||
export interface UpdateState {
|
||||
isUpdatePending: boolean;
|
||||
setIsUpdatePending: (isPending: boolean) => void;
|
||||
updateDialogHasBeenMinimized: boolean;
|
||||
otaState: {
|
||||
export type UpdateModalViews =
|
||||
| "loading"
|
||||
| "updating"
|
||||
| "upToDate"
|
||||
| "updateAvailable"
|
||||
| "updateCompleted"
|
||||
| "error";
|
||||
|
||||
export interface OtaState {
|
||||
updating: boolean;
|
||||
error: string | null;
|
||||
|
||||
|
@ -567,24 +531,24 @@ export interface UpdateState {
|
|||
|
||||
systemUpdateProgress: number;
|
||||
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;
|
||||
modalView:
|
||||
| "loading"
|
||||
| "updating"
|
||||
| "upToDate"
|
||||
| "updateAvailable"
|
||||
| "updateCompleted"
|
||||
| "error";
|
||||
setModalView: (view: UpdateState["modalView"]) => void;
|
||||
modalView: UpdateModalViews
|
||||
setModalView: (view: UpdateModalViews) => void;
|
||||
setUpdateErrorMessage: (errorMessage: string) => void;
|
||||
updateErrorMessage: string | null;
|
||||
}
|
||||
|
||||
export const useUpdateStore = create<UpdateState>(set => ({
|
||||
isUpdatePending: false,
|
||||
setIsUpdatePending: isPending => set({ isUpdatePending: isPending }),
|
||||
setIsUpdatePending: (isPending: boolean) => set({ isUpdatePending: isPending }),
|
||||
|
||||
setOtaState: state => set({ otaState: state }),
|
||||
otaState: {
|
||||
|
@ -608,18 +572,22 @@ export const useUpdateStore = create<UpdateState>(set => ({
|
|||
},
|
||||
|
||||
updateDialogHasBeenMinimized: false,
|
||||
setUpdateDialogHasBeenMinimized: hasBeenMinimized =>
|
||||
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) =>
|
||||
set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
|
||||
modalView: "loading",
|
||||
setModalView: view => set({ modalView: view }),
|
||||
setModalView: (view: UpdateModalViews) => set({ modalView: view }),
|
||||
updateErrorMessage: null,
|
||||
setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }),
|
||||
setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }),
|
||||
}));
|
||||
|
||||
interface UsbConfigModalState {
|
||||
modalView: "updateUsbConfig" | "updateUsbConfigSuccess";
|
||||
export type UsbConfigModalViews =
|
||||
| "updateUsbConfig"
|
||||
| "updateUsbConfigSuccess";
|
||||
|
||||
export interface UsbConfigModalState {
|
||||
modalView: UsbConfigModalViews ;
|
||||
errorMessage: string | null;
|
||||
setModalView: (view: UsbConfigModalState["modalView"]) => void;
|
||||
setModalView: (view: UsbConfigModalViews) => void;
|
||||
setErrorMessage: (message: string | null) => void;
|
||||
}
|
||||
|
||||
|
@ -634,24 +602,26 @@ export interface UsbConfigState {
|
|||
export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
|
||||
modalView: "updateUsbConfig",
|
||||
errorMessage: null,
|
||||
setModalView: view => set({ modalView: view }),
|
||||
setErrorMessage: message => set({ errorMessage: message }),
|
||||
setModalView: (view: UsbConfigModalViews) => set({ modalView: view }),
|
||||
setErrorMessage: (message: string | null) => set({ errorMessage: message }),
|
||||
}));
|
||||
|
||||
interface LocalAuthModalState {
|
||||
modalView:
|
||||
| "createPassword"
|
||||
| "deletePassword"
|
||||
| "updatePassword"
|
||||
| "creationSuccess"
|
||||
| "deleteSuccess"
|
||||
| "updateSuccess";
|
||||
setModalView: (view: LocalAuthModalState["modalView"]) => void;
|
||||
export type LocalAuthModalViews =
|
||||
| "createPassword"
|
||||
| "deletePassword"
|
||||
| "updatePassword"
|
||||
| "creationSuccess"
|
||||
| "deleteSuccess"
|
||||
| "updateSuccess";
|
||||
|
||||
export interface LocalAuthModalState {
|
||||
modalView:LocalAuthModalViews;
|
||||
setModalView: (view:LocalAuthModalViews) => void;
|
||||
}
|
||||
|
||||
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
|
||||
modalView: "createPassword",
|
||||
setModalView: view => set({ modalView: view }),
|
||||
setModalView: (view: LocalAuthModalViews) => set({ modalView: view }),
|
||||
}));
|
||||
|
||||
export interface DeviceState {
|
||||
|
@ -666,8 +636,8 @@ export const useDeviceStore = create<DeviceState>(set => ({
|
|||
appVersion: null,
|
||||
systemVersion: null,
|
||||
|
||||
setAppVersion: version => set({ appVersion: version }),
|
||||
setSystemVersion: version => set({ systemVersion: version }),
|
||||
setAppVersion: (version: string) => set({ appVersion: version }),
|
||||
setSystemVersion: (version: string) => set({ systemVersion: version }),
|
||||
}));
|
||||
|
||||
export interface DhcpLease {
|
||||
|
@ -747,6 +717,7 @@ export type TimeSyncMode =
|
|||
export interface NetworkSettings {
|
||||
hostname: string;
|
||||
domain: string;
|
||||
http_proxy: string;
|
||||
ipv4_mode: IPv4Mode;
|
||||
ipv6_mode: IPv6Mode;
|
||||
lldp_mode: LLDPMode;
|
||||
|
@ -832,7 +803,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
|||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sendFn("getKeyboardMacros", {}, response => {
|
||||
sendFn("getKeyboardMacros", {}, (response: JsonRpcResponse) => {
|
||||
if (response.error) {
|
||||
console.error("Error loading macros:", response.error);
|
||||
reject(new Error(response.error.message));
|
||||
|
@ -912,7 +883,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
|||
sendFn(
|
||||
"setKeyboardMacros",
|
||||
{ params: { macros: macrosWithSortOrder } },
|
||||
response => {
|
||||
(response: JsonRpcResponse) => {
|
||||
resolve(response);
|
||||
},
|
||||
);
|
||||
|
@ -935,5 +906,5 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
|||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
}
|
||||
}));
|
||||
|
|
|
@ -33,10 +33,10 @@ const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>(
|
|||
let requestCounter = 0;
|
||||
|
||||
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
const { rpcDataChannel } = useRTCStore();
|
||||
|
||||
const send = useCallback(
|
||||
(method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
|
||||
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
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],
|
||||
[rpcDataChannel]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -61,7 +61,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
|||
return;
|
||||
}
|
||||
|
||||
if ("error" in payload) console.error(payload.error);
|
||||
if ("error" in payload) console.error("RPC error", payload);
|
||||
if (!payload.id) return;
|
||||
|
||||
const callback = callbackStore.get(payload.id);
|
||||
|
@ -76,7 +76,8 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
|||
return () => {
|
||||
rpcDataChannel.removeEventListener("message", messageHandler);
|
||||
};
|
||||
}, [rpcDataChannel, onRequest]);
|
||||
},
|
||||
[rpcDataChannel, onRequest]);
|
||||
|
||||
return [send];
|
||||
return { send };
|
||||
}
|
||||
|
|
|
@ -1,42 +1,160 @@
|
|||
import { useCallback } from "react";
|
||||
|
||||
import { useHidStore, useRTCStore } from "@/hooks/stores";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { KeysDownState, useHidStore, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
|
||||
|
||||
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 rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
const hidDataChannel = useRTCStore(state => state.hidDataChannel);
|
||||
|
||||
const updateActiveKeysAndModifiers = useHidStore(
|
||||
state => state.updateActiveKeysAndModifiers,
|
||||
);
|
||||
|
||||
const sendKeyboardEvent = useCallback(
|
||||
(keys: number[], modifiers: number[]) => {
|
||||
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[]) => {
|
||||
const rpcChannelReady = rpcDataChannel?.readyState === "open";
|
||||
const hidChannelReady = hidDataChannel?.readyState === "open";
|
||||
if (!rpcChannelReady && !hidChannelReady) return;
|
||||
|
||||
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
|
||||
|
||||
send("keyboardReport", { keys, modifier: accModifier });
|
||||
if (hidChannelReady) {
|
||||
if (accModifier > 0) {
|
||||
hidDataChannel?.send(new Uint8Array([1, accModifier, ...keys]));
|
||||
} else {
|
||||
if (keys.length > 0) {
|
||||
hidDataChannel?.send(new Uint8Array([2, ...keys]));
|
||||
} else {
|
||||
hidDataChannel?.send(new Uint8Array([3]));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
send("keyboardReport", { keys, modifier: accModifier });
|
||||
}
|
||||
|
||||
// We do this for the info bar to display the currently pressed keys for the user
|
||||
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers });
|
||||
},
|
||||
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers],
|
||||
[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,
|
||||
rpcDataChannel?.readyState,
|
||||
send,
|
||||
updateActiveKeysAndModifiers,
|
||||
],
|
||||
);
|
||||
|
||||
const resetKeyboardState = useCallback(() => {
|
||||
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 }[]) => {
|
||||
for (const [index, step] of steps.entries()) {
|
||||
const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || [];
|
||||
const modifierValues = step.modifiers?.map(mod => modifiers[mod]).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);
|
||||
|
||||
// If the step has keys and/or modifiers, press them and hold for the delay
|
||||
if (keyValues.length > 0 || modifierValues.length > 0) {
|
||||
sendKeyboardEvent(keyValues, modifierValues);
|
||||
if (keyValues.length > 0 || modifierMask > 0) {
|
||||
sendKeyboardEvent({ keys: keyValues, modifier: modifierMask });
|
||||
await new Promise(resolve => setTimeout(resolve, step.delay || 50));
|
||||
|
||||
resetKeyboardState();
|
||||
|
@ -52,5 +170,92 @@ export default function useKeyboard() {
|
|||
}
|
||||
};
|
||||
|
||||
return { sendKeyboardEvent, resetKeyboardState, executeMacro };
|
||||
// handleKeyPress is used to handle a key press or release event.
|
||||
// This function handle both key press and key release events.
|
||||
// It checks if the keyPressReport API is available and sends the key press event.
|
||||
// If the keyPressReport API is not available, it simulates the device-side key
|
||||
// handling for legacy devices and updates the keysDownState accordingly.
|
||||
// It then sends the full keyboard state to the device.
|
||||
const handleKeyPress = useCallback(
|
||||
async (key: number, press: boolean) => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
if ((key || 0) === 0) return; // ignore zero key presses (they are bad mappings)
|
||||
|
||||
if (keyPressReportApiAvailable) {
|
||||
// if the keyPress api is available, we can just send the key press event
|
||||
sendKeypressEvent(key, press);
|
||||
} else {
|
||||
// if the keyPress api is not available, we need to handle the key locally
|
||||
const downState = simulateDeviceSideKeyHandlingForLegacyDevices(keysDownState, key, press);
|
||||
sendKeyboardEvent(downState); // then we send the full state
|
||||
|
||||
// if we just sent ErrorRollOver, reset to empty state
|
||||
if (downState.keys[0] === hidErrorRollOver) {
|
||||
resetKeyboardState();
|
||||
}
|
||||
}
|
||||
},
|
||||
[keyPressReportApiAvailable, keysDownState, resetKeyboardState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent],
|
||||
);
|
||||
|
||||
// IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists
|
||||
function simulateDeviceSideKeyHandlingForLegacyDevices(state: KeysDownState, key: number, press: boolean): KeysDownState {
|
||||
// IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver
|
||||
// for handling key presses and releases. It ensures that the USB gadget
|
||||
// behaves similarly to a real USB HID keyboard. This logic is paralleled
|
||||
// in the device-side code in hid_keyboard.go so make sure to keep them in sync.
|
||||
let modifiers = state.modifier;
|
||||
const keys = state.keys;
|
||||
const modifierMask = hidKeyToModifierMask[key] || 0;
|
||||
|
||||
if (modifierMask !== 0) {
|
||||
// If the key is a modifier key, we update the keyboardModifier state
|
||||
// by setting or clearing the corresponding bit in the modifier byte.
|
||||
// This allows us to track the state of dynamic modifier keys like
|
||||
// Shift, Control, Alt, and Super.
|
||||
if (press) {
|
||||
modifiers |= modifierMask;
|
||||
} else {
|
||||
modifiers &= ~modifierMask;
|
||||
}
|
||||
} else {
|
||||
// handle other keys that are not modifier keys by placing or removing them
|
||||
// from the key buffer since the buffer tracks currently pressed keys
|
||||
let overrun = true;
|
||||
for (let i = 0; i < hidKeyBufferSize; i++) {
|
||||
// If we find the key in the buffer the buffer, we either remove it (if press is false)
|
||||
// or do nothing (if down is true) because the buffer tracks currently pressed keys
|
||||
// and if we find a zero byte, we can place the key there (if press is true)
|
||||
if (keys[i] === key || keys[i] === 0) {
|
||||
if (press) {
|
||||
keys[i] = key // overwrites the zero byte or the same key if already pressed
|
||||
} else {
|
||||
// we are releasing the key, remove it from the buffer
|
||||
if (keys[i] !== 0) {
|
||||
keys.splice(i, 1);
|
||||
keys.push(0); // add a zero at the end
|
||||
}
|
||||
}
|
||||
overrun = false; // We found a slot for the key
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here it means we didn't find an empty slot or the key in the buffer
|
||||
if (overrun) {
|
||||
if (press) {
|
||||
console.warn(`keyboard buffer overflow current keys ${keys}, key: ${key} not added`);
|
||||
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
|
||||
keys.length = hidKeyBufferSize;
|
||||
keys.fill(hidErrorRollOver);
|
||||
} else {
|
||||
// If we are releasing a key, and we didn't find it in a slot, who cares?
|
||||
console.debug(`key ${key} not found in buffer, nothing to release`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { modifier: modifiers, keys };
|
||||
}
|
||||
|
||||
return { handleKeyPress, resetKeyboardState, executeMacro };
|
||||
}
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import { useMemo } from "react";
|
||||
|
||||
import { useSettingsStore } from "@/hooks/stores";
|
||||
import { keyboards } from "@/keyboardLayouts";
|
||||
|
||||
export default function useKeyboardLayout() {
|
||||
const { keyboardLayout } = useSettingsStore();
|
||||
|
||||
const keyboardOptions = useMemo(() => {
|
||||
return keyboards.map((keyboard) => {
|
||||
return { label: keyboard.name, value: keyboard.isoCode }
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isoCode = useMemo(() => {
|
||||
// If we don't have a specific layout, default to "en-US" because that was the original layout
|
||||
// developed so it is a good fallback. Additionally, we replace "en_US" with "en-US" because
|
||||
// the original server-side code used "en_US" as the default value, but that's not the correct
|
||||
// ISO code for English/United State. To ensure we remain backward compatible with devices that
|
||||
// have not had their Keyboard Layout selected by the user, we want to treat "en_US" as if it was
|
||||
// "en-US" to match the ISO standard codes now used in the keyboardLayouts.
|
||||
console.debug("Current keyboard layout from store:", keyboardLayout);
|
||||
if (keyboardLayout && keyboardLayout.length > 0)
|
||||
return keyboardLayout.replace("en_US", "en-US");
|
||||
return "en-US";
|
||||
}, [keyboardLayout]);
|
||||
|
||||
const selectedKeyboard = useMemo(() => {
|
||||
// fallback to original behaviour of en-US if no isoCode given or matching layout not found
|
||||
return keyboards.find(keyboard => keyboard.isoCode === isoCode)
|
||||
?? keyboards.find(keyboard => keyboard.isoCode === "en-US")!;
|
||||
}, [isoCode]);
|
||||
|
||||
return { keyboardOptions, isoCode, selectedKeyboard };
|
||||
}
|
|
@ -315,6 +315,11 @@ video::-webkit-media-controls {
|
|||
@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:not(:last-child) {
|
||||
@apply mr-[2px]! md:mr-[5px]!;
|
||||
|
|
|
@ -1,45 +1,31 @@
|
|||
import { chars as chars_fr_BE, name as name_fr_BE } from "@/keyboardLayouts/fr_BE"
|
||||
import { chars as chars_cs_CZ, name as name_cs_CZ } from "@/keyboardLayouts/cs_CZ"
|
||||
import { chars as chars_en_UK, name as name_en_UK } from "@/keyboardLayouts/en_UK"
|
||||
import { chars as chars_en_US, name as name_en_US } from "@/keyboardLayouts/en_US"
|
||||
import { chars as chars_fr_FR, name as name_fr_FR } from "@/keyboardLayouts/fr_FR"
|
||||
import { chars as chars_de_DE, name as name_de_DE } from "@/keyboardLayouts/de_DE"
|
||||
import { chars as chars_it_IT, name as name_it_IT } from "@/keyboardLayouts/it_IT"
|
||||
import { chars as chars_nb_NO, name as name_nb_NO } from "@/keyboardLayouts/nb_NO"
|
||||
import { chars as chars_es_ES, name as name_es_ES } from "@/keyboardLayouts/es_ES"
|
||||
import { chars as chars_sv_SE, name as name_sv_SE } from "@/keyboardLayouts/sv_SE"
|
||||
import { chars as chars_fr_CH, name as name_fr_CH } from "@/keyboardLayouts/fr_CH"
|
||||
import { chars as chars_de_CH, name as name_de_CH } from "@/keyboardLayouts/de_CH"
|
||||
|
||||
interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean }
|
||||
export type KeyCombo = KeyInfo & { deadKey?: boolean, accentKey?: KeyInfo }
|
||||
|
||||
export const layouts: Record<string, string> = {
|
||||
be_FR: name_fr_BE,
|
||||
cs_CZ: name_cs_CZ,
|
||||
en_UK: name_en_UK,
|
||||
en_US: name_en_US,
|
||||
fr_FR: name_fr_FR,
|
||||
de_DE: name_de_DE,
|
||||
it_IT: name_it_IT,
|
||||
nb_NO: name_nb_NO,
|
||||
es_ES: name_es_ES,
|
||||
sv_SE: name_sv_SE,
|
||||
fr_CH: name_fr_CH,
|
||||
de_CH: name_de_CH,
|
||||
export interface KeyStroke { modifier: number; keys: number[]; }
|
||||
export interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean }
|
||||
export interface KeyCombo extends KeyInfo { deadKey?: boolean, accentKey?: KeyInfo }
|
||||
export interface KeyboardLayout {
|
||||
isoCode: string;
|
||||
name: string;
|
||||
chars: Record<string, KeyCombo>;
|
||||
modifierDisplayMap: Record<string, string>;
|
||||
keyDisplayMap: Record<string, string>;
|
||||
virtualKeyboard: {
|
||||
main: { default: string[], shift: string[] },
|
||||
control?: { default: string[], shift?: string[] },
|
||||
arrows?: { default: string[] }
|
||||
};
|
||||
}
|
||||
|
||||
export const chars: Record<string, Record<string, KeyCombo>> = {
|
||||
be_FR: chars_fr_BE,
|
||||
cs_CZ: chars_cs_CZ,
|
||||
en_UK: chars_en_UK,
|
||||
en_US: chars_en_US,
|
||||
fr_FR: chars_fr_FR,
|
||||
de_DE: chars_de_DE,
|
||||
it_IT: chars_it_IT,
|
||||
nb_NO: chars_nb_NO,
|
||||
es_ES: chars_es_ES,
|
||||
sv_SE: chars_sv_SE,
|
||||
fr_CH: chars_fr_CH,
|
||||
de_CH: chars_de_CH,
|
||||
};
|
||||
// To add a new layout, create a file like the above and add it to the list
|
||||
import { cs_CZ } from "@/keyboardLayouts/cs_CZ"
|
||||
import { de_CH } from "@/keyboardLayouts/de_CH"
|
||||
import { de_DE } from "@/keyboardLayouts/de_DE"
|
||||
import { en_US } from "@/keyboardLayouts/en_US"
|
||||
import { en_UK } from "@/keyboardLayouts/en_UK"
|
||||
import { es_ES } from "@/keyboardLayouts/es_ES"
|
||||
import { fr_BE } from "@/keyboardLayouts/fr_BE"
|
||||
import { fr_CH } from "@/keyboardLayouts/fr_CH"
|
||||
import { fr_FR } from "@/keyboardLayouts/fr_FR"
|
||||
import { it_IT } from "@/keyboardLayouts/it_IT"
|
||||
import { nb_NO } from "@/keyboardLayouts/nb_NO"
|
||||
import { sv_SE } from "@/keyboardLayouts/sv_SE"
|
||||
|
||||
export const keyboards: KeyboardLayout[] = [ cs_CZ, de_CH, de_DE, en_UK, en_US, es_ES, fr_BE, fr_CH, fr_FR, it_IT, nb_NO, sv_SE ];
|
||||
|
|
|
@ -1,19 +1,22 @@
|
|||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
|
||||
export const name = "Čeština";
|
||||
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
||||
|
||||
const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
|
||||
const 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 name = "Čeština";
|
||||
const isoCode = "cs-CZ";
|
||||
|
||||
export const chars = {
|
||||
const keyTrema: KeyCombo = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
|
||||
const keyAcute: KeyCombo = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
const keyHat: KeyCombo = { key: "Digit3", shift: true, altRight: true } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||
const keyCaron: KeyCombo = { key: "Equal", shift: true } // caron or haček (inverted hat), mark ˇ placed above the letter
|
||||
const keyGrave: KeyCombo = { key: "Digit7", shift: true, altRight: true } // accent grave, mark ` placed above the letter
|
||||
const keyTilde: KeyCombo = { key: "Digit1", shift: true, altRight: true } // tilde, mark ~ placed above the letter
|
||||
const keyRing: KeyCombo = { key: "Backquote", shift: true } // kroužek (little ring), mark ° placed above the letter
|
||||
const keyOverdot: KeyCombo = { key: "Digit8", shift: true, altRight: true } // overdot (dot above), mark ˙ placed above the letter
|
||||
const keyHook: KeyCombo = { key: "Digit6", shift: true, altRight: true } // ogonoek (little hook), mark ˛ placed beneath a letter
|
||||
const keyCedille: KeyCombo = { key: "Equal", shift: true, altRight: true } // accent cedille (cedilla), mark ¸ placed beneath a letter
|
||||
|
||||
const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
|
||||
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
|
||||
|
@ -242,3 +245,13 @@ export const chars = {
|
|||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} 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
|
||||
};
|
|
@ -1,14 +1,17 @@
|
|||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
|
||||
export const name = "Schwiizerdütsch";
|
||||
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
||||
|
||||
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
||||
const 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 name = "Schwiizerdütsch";
|
||||
const isoCode = "de-CH";
|
||||
|
||||
export const chars = {
|
||||
const keyTrema: KeyCombo = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
|
||||
const keyAcute: KeyCombo = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
const keyHat: KeyCombo = { key: "Equal" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||
const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
|
||||
const keyTilde: KeyCombo = { key: "Equal", altRight: true } // tilde, mark ~ placed above the letter
|
||||
|
||||
const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
|
||||
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
|
||||
|
@ -163,3 +166,23 @@ export const chars = {
|
|||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} 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
|
||||
};
|
|
@ -1,113 +1,146 @@
|
|||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
|
||||
export const name = "Deutsch";
|
||||
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
||||
|
||||
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
const 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 name = "Deutsch";
|
||||
const isoCode = "de-DE";
|
||||
|
||||
export const chars = {
|
||||
const keyAcute: KeyCombo = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
const keyHat: KeyCombo = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||
const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
|
||||
|
||||
const chars = {
|
||||
a: { key: "KeyA" },
|
||||
"á": { key: "KeyA", accentKey: keyAcute },
|
||||
"â": { key: "KeyA", accentKey: keyHat },
|
||||
"à": { key: "KeyA", accentKey: keyGrave },
|
||||
A: { key: "KeyA", shift: true },
|
||||
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
|
||||
"Â": { key: "KeyA", shift: true, accentKey: keyHat },
|
||||
"À": { key: "KeyA", shift: true, accentKey: keyGrave },
|
||||
"☺": { key: "KeyA", altRight: true }, // white smiling face ☺
|
||||
b: { key: "KeyB" },
|
||||
B: { key: "KeyB", shift: true },
|
||||
"‹": { key: "KeyB", altRight: true }, // single left-pointing angle quotation mark, ‹
|
||||
c: { key: "KeyC" },
|
||||
C: { key: "KeyC", shift: true },
|
||||
"\u202f": { key: "KeyC", altRight: true }, // narrow no-break space
|
||||
d: { key: "KeyD" },
|
||||
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 },
|
||||
"É": { key: "KeyE", shift: true, accentKey: keyAcute },
|
||||
"Ê": { key: "KeyE", shift: true, accentKey: keyHat },
|
||||
"È": { key: "KeyE", shift: true, accentKey: keyGrave },
|
||||
F: { key: "KeyF", shift: true },
|
||||
G: { key: "KeyG", shift: true },
|
||||
H: { key: "KeyH", shift: true },
|
||||
I: { key: "KeyI", shift: true },
|
||||
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
|
||||
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
|
||||
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
|
||||
J: { key: "KeyJ", shift: true },
|
||||
K: { key: "KeyK", shift: true },
|
||||
L: { key: "KeyL", shift: true },
|
||||
M: { key: "KeyM", shift: true },
|
||||
N: { key: "KeyN", shift: true },
|
||||
O: { key: "KeyO", shift: true },
|
||||
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
|
||||
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
|
||||
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
|
||||
P: { key: "KeyP", shift: true },
|
||||
Q: { key: "KeyQ", shift: true },
|
||||
R: { key: "KeyR", shift: true },
|
||||
S: { key: "KeyS", shift: true },
|
||||
T: { key: "KeyT", shift: true },
|
||||
U: { key: "KeyU", shift: true },
|
||||
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
|
||||
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
|
||||
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
|
||||
V: { key: "KeyV", shift: true },
|
||||
W: { key: "KeyW", shift: true },
|
||||
X: { key: "KeyX", shift: true },
|
||||
Y: { key: "KeyZ", shift: true },
|
||||
Z: { key: "KeyY", shift: true },
|
||||
a: { key: "KeyA" },
|
||||
"á": { key: "KeyA", accentKey: keyAcute },
|
||||
"â": { key: "KeyA", accentKey: keyHat },
|
||||
"à": { key: "KeyA", accentKey: keyGrave},
|
||||
b: { key: "KeyB" },
|
||||
c: { key: "KeyC" },
|
||||
d: { key: "KeyD" },
|
||||
e: { key: "KeyE" },
|
||||
"é": { key: "KeyE", accentKey: keyAcute},
|
||||
"ê": { key: "KeyE", accentKey: keyHat },
|
||||
"è": { key: "KeyE", accentKey: keyGrave },
|
||||
"€": { key: "KeyE", altRight: true },
|
||||
f: { key: "KeyF" },
|
||||
F: { key: "KeyF", shift: true },
|
||||
"˟": { key: "KeyF", deadKey: true, altRight: true }, // modifier letter cross accent, ˟
|
||||
G: { key: "KeyG", shift: true },
|
||||
g: { key: "KeyG" },
|
||||
"ẞ": { key: "KeyG", altRight: true }, // capital sharp S, ẞ
|
||||
h: { key: "KeyH" },
|
||||
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 },
|
||||
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
|
||||
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
|
||||
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
|
||||
"˜": { key: "KeyI", deadKey: true, altRight: true }, // tilde accent, mark ˜ placed above the letter
|
||||
j: { key: "KeyJ" },
|
||||
J: { key: "KeyJ", shift: true },
|
||||
"¸": { key: "KeyJ", deadKey: true, altRight: true }, // cedilla accent, mark ¸ placed below the letter
|
||||
k: { key: "KeyK" },
|
||||
K: { key: "KeyK", shift: true },
|
||||
l: { key: "KeyL" },
|
||||
L: { key: "KeyL", shift: true },
|
||||
"ˏ": { key: "KeyL", deadKey: true, altRight: true }, // modifier letter reversed comma, ˏ
|
||||
m: { key: "KeyM" },
|
||||
M: { key: "KeyM", shift: true },
|
||||
"µ": { key: "KeyM", altRight: true },
|
||||
n: { key: "KeyN" },
|
||||
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 },
|
||||
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
|
||||
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
|
||||
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
|
||||
"˚": { key: "KeyO", deadKey: true, altRight: true }, // ring above, ˚
|
||||
p: { key: "KeyP" },
|
||||
P: { key: "KeyP", shift: true },
|
||||
"ˀ": { key: "KeyP", deadKey: true, altRight: true }, // modifier letter apostrophe, ʾ
|
||||
q: { key: "KeyQ" },
|
||||
Q: { key: "KeyQ", shift: true },
|
||||
"@": { key: "KeyQ", altRight: 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" },
|
||||
"″": { key: "KeyS", altRight: true }, // double prime, mark ″ placed above the letter
|
||||
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 },
|
||||
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
|
||||
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
|
||||
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
|
||||
"˘": { key: "KeyU", deadKey: true, altRight: true }, // breve accent, ˘ placed above the letter
|
||||
v: { key: "KeyV" },
|
||||
V: { key: "KeyV", shift: true },
|
||||
"«": { key: "KeyV", altRight: true }, // left-pointing double angle quotation mark, «
|
||||
w: { key: "KeyW" },
|
||||
W: { key: "KeyW", shift: true },
|
||||
"¯": { key: "KeyW", deadKey: true, altRight: true }, // macron accent, mark ¯ placed above the letter
|
||||
x: { key: "KeyX" },
|
||||
X: { key: "KeyX", shift: true },
|
||||
"»": { key: "KeyX", altRight: true },
|
||||
// cross key between shift and y (aka OEM 102 key)
|
||||
y: { key: "KeyZ" },
|
||||
Y: { key: "KeyZ", shift: true },
|
||||
"›": { key: "KeyZ", altRight: true }, // single right-pointing angle quotation mark, ›
|
||||
z: { key: "KeyY" },
|
||||
Z: { key: "KeyY", shift: true },
|
||||
"¨": { key: "KeyY", deadKey: true, altRight: true }, // diaeresis accent, mark ¨ placed above the letter
|
||||
"°": { key: "Backquote", shift: true },
|
||||
"^": { key: "Backquote", deadKey: true },
|
||||
"|": { key: "Backquote", altRight: true },
|
||||
1: { key: "Digit1" },
|
||||
"!": { key: "Digit1", shift: true },
|
||||
"’": { key: "Digit1", altRight: true }, // single quote, mark ’ placed above the letter
|
||||
2: { key: "Digit2" },
|
||||
"\"": { key: "Digit2", shift: true },
|
||||
"²": { key: "Digit2", altRight: true },
|
||||
"<": { key: "Digit2", altRight: true }, // non-US < and >
|
||||
3: { key: "Digit3" },
|
||||
"§": { key: "Digit3", shift: true },
|
||||
"³": { key: "Digit3", altRight: true },
|
||||
">": { key: "Digit3", altRight: true }, // non-US < and >
|
||||
4: { key: "Digit4" },
|
||||
"$": { key: "Digit4", shift: true },
|
||||
"—": { key: "Digit4", altRight: true }, // em dash, —
|
||||
5: { key: "Digit5" },
|
||||
"%": { key: "Digit5", shift: true },
|
||||
"¡": { key: "Digit5", altRight: true }, // inverted exclamation mark, ¡
|
||||
6: { key: "Digit6" },
|
||||
"&": { key: "Digit6", shift: true },
|
||||
"¿": { key: "Digit6", altRight: true }, // inverted question mark, ¿
|
||||
7: { key: "Digit7" },
|
||||
"/": { key: "Digit7", shift: true },
|
||||
"{": { key: "Digit7", altRight: true },
|
||||
|
@ -123,30 +156,192 @@ export const chars = {
|
|||
"ß": { key: "Minus" },
|
||||
"?": { key: "Minus", shift: true },
|
||||
"\\": { key: "Minus", altRight: true },
|
||||
"´": { key: "Equal", deadKey: true },
|
||||
"`": { key: "Equal", shift: true, deadKey: true },
|
||||
"´": { key: "Equal", deadKey: true }, // accent acute, mark ´ placed above the letter
|
||||
"`": { key: "Equal", shift: true, deadKey: true }, // accent grave, mark ` placed above the letter
|
||||
"˙": { key: "Equal", control: true, altRight: true, deadKey: true }, // acute accent, mark ˙ placed above the letter
|
||||
"ü": { key: "BracketLeft" },
|
||||
"Ü": { key: "BracketLeft", shift: true },
|
||||
Escape: { key: "BracketLeft", control: true },
|
||||
"ʼ": { key: "BracketLeft", altRight: true }, // modifier letter apostrophe, ʼ
|
||||
"+": { key: "BracketRight" },
|
||||
"*": { key: "BracketRight", shift: true },
|
||||
Control: { key: "BracketRight", control: true },
|
||||
"~": { key: "BracketRight", altRight: true },
|
||||
"ö": { key: "Semicolon" },
|
||||
"Ö": { key: "Semicolon", shift: true },
|
||||
"ˌ": { key: "Semicolon", deadkey: true, altRight: true }, // modifier letter low vertical line, ˌ
|
||||
"ä": { key: "Quote" },
|
||||
"Ä": { key: "Quote", shift: true },
|
||||
"˗": { key: "Quote", deadKey: true, altRight: true }, // modifier letter minus sign, ˗
|
||||
"#": { key: "Backslash" },
|
||||
"'": { key: "Backslash", shift: true },
|
||||
"−": { key: "Backslash", altRight: true }, // minus sign, −
|
||||
",": { key: "Comma" },
|
||||
";": { key: "Comma", shift: true },
|
||||
"\u2011": { key: "Comma", altRight: true }, // non-breaking hyphen, ‑
|
||||
".": { key: "Period" },
|
||||
":": { key: "Period", shift: true },
|
||||
"·": { key: "Period", altRight: true }, // middle dot, ·
|
||||
"-": { key: "Slash" },
|
||||
"_": { key: "Slash", shift: true },
|
||||
"<": { key: "IntlBackslash" },
|
||||
">": { key: "IntlBackslash", shift: true },
|
||||
"|": { key: "IntlBackslash", altRight: true },
|
||||
"\u00ad": { key: "Slash", altRight: true }, // soft hyphen,
|
||||
" ": { key: "Space" },
|
||||
"\n": { key: "Enter" },
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} 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
|
||||
};
|
|
@ -1,8 +1,11 @@
|
|||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
|
||||
export const name = "English (UK)";
|
||||
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
||||
|
||||
export const chars = {
|
||||
const name = "English (UK)";
|
||||
const isoCode = "en-UK";
|
||||
|
||||
const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
B: { key: "KeyB", shift: true },
|
||||
C: { key: "KeyC", shift: true },
|
||||
|
@ -105,3 +108,13 @@ export const chars = {
|
|||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} 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
|
||||
};
|
|
@ -1,6 +1,16 @@
|
|||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
|
||||
export const name = "English (US)";
|
||||
const name = "English (US)";
|
||||
const isoCode = "en-US";
|
||||
|
||||
// dead keys for "international" 101 keyboards TODO
|
||||
/*
|
||||
const keyAcute = { key: "Quote", control: true, menu: true, mark: "´" } // acute accent
|
||||
const keyCedilla = { key: ".", shift: true, alt: true, mark: "¸" } // cedilla accent
|
||||
const keyComma = { key: "BracketRight", shift: true, altRight: true, mark: "," } // comma accent
|
||||
const keyDiaeresis = { key: "Quote", shift: true, control: true, menu: true, mark: "¨" } // diaeresis accent
|
||||
const keyDegree = { key: "Semicolon", shift: true, control: true, menu: true, mark: "°" } // degree accent
|
||||
*/
|
||||
|
||||
export const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
|
@ -89,25 +99,213 @@ export const chars = {
|
|||
">": { key: "Period", shift: true },
|
||||
";": { key: "Semicolon" },
|
||||
":": { key: "Semicolon", shift: true },
|
||||
"¶": { key: "Semicolon", altRight: true }, // pilcrow sign
|
||||
"[": { key: "BracketLeft" },
|
||||
"{": { key: "BracketLeft", shift: true },
|
||||
"«": { key: "BracketLeft", altRight: true }, // double left quote sign
|
||||
"]": { key: "BracketRight" },
|
||||
"}": { key: "BracketRight", shift: true },
|
||||
"»": { key: "BracketRight", altRight: true }, // double right quote sign
|
||||
"\\": { key: "Backslash" },
|
||||
"|": { key: "Backslash", shift: true },
|
||||
"¬": { key: "Backslash", altRight: true }, // not sign
|
||||
"`": { key: "Backquote" },
|
||||
"~": { key: "Backquote", shift: true },
|
||||
"§": { key: "IntlBackslash" },
|
||||
"±": { key: "IntlBackslash", shift: true },
|
||||
" ": { key: "Space", shift: false },
|
||||
"\n": { key: "Enter", shift: false },
|
||||
Enter: { key: "Enter", shift: false },
|
||||
Tab: { key: "Tab", shift: false },
|
||||
PrintScreen: { key: "Prt Sc", shift: false },
|
||||
" ": { key: "Space" },
|
||||
"\n": { key: "Enter" },
|
||||
Enter: { key: "Enter" },
|
||||
Escape: { key: "Escape" },
|
||||
Tab: { key: "Tab" },
|
||||
PrintScreen: { key: "Prt Sc" },
|
||||
SystemRequest: { key: "Prt Sc", shift: true },
|
||||
ScrollLock: { key: "ScrollLock", shift: false},
|
||||
Pause: { key: "Pause", shift: false },
|
||||
ScrollLock: { key: "ScrollLock" },
|
||||
Pause: { key: "Pause" },
|
||||
Break: { key: "Pause", shift: true },
|
||||
Insert: { key: "Insert", shift: false },
|
||||
Delete: { key: "Delete", shift: false },
|
||||
Insert: { key: "Insert" },
|
||||
Delete: { key: "Delete" },
|
||||
} 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
|
||||
};
|
||||
|
||||
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
|
||||
export const name = "Español";
|
||||
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
||||
|
||||
const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||
const 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 name = "Español";
|
||||
const isoCode = "es-ES";
|
||||
|
||||
export const chars = {
|
||||
const keyTrema: KeyCombo = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||
const keyAcute: KeyCombo = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
const keyHat: KeyCombo = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||
const keyGrave: KeyCombo = { key: "BracketRight" } // accent grave, mark ` placed above the letter
|
||||
const keyTilde: KeyCombo = { key: "Key4", altRight: true } // tilde, mark ~ placed above the letter
|
||||
|
||||
const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
|
||||
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
|
||||
|
@ -166,3 +169,13 @@ export const chars = {
|
|||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} 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
|
||||
};
|
|
@ -1,14 +1,17 @@
|
|||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
|
||||
export const name = "Belgisch Nederlands";
|
||||
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
||||
|
||||
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||
const 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 name = "Belgisch Nederlands";
|
||||
const isoCode = "nl-BE";
|
||||
|
||||
export const chars = {
|
||||
const keyTrema: KeyCombo = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
|
||||
const keyHat: KeyCombo = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
|
||||
const keyAcute: KeyCombo = { key: "Semicolon", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
|
||||
const keyGrave: KeyCombo = { key: "Quote", shift: true } // accent grave, mark ` placed above the letter
|
||||
const keyTilde: KeyCombo = { key: "Slash", altRight: true } // tilde, mark ~ placed above the letter
|
||||
|
||||
const chars = {
|
||||
A: { key: "KeyQ", shift: true },
|
||||
"Ä": { key: "KeyQ", shift: true, accentKey: keyTrema },
|
||||
"Â": { key: "KeyQ", shift: true, accentKey: keyHat },
|
||||
|
@ -165,3 +168,13 @@ export const chars = {
|
|||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
} 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
|
||||
};
|
|
@ -1,11 +1,12 @@
|
|||
import { KeyCombo } from "../keyboardLayouts"
|
||||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
|
||||
import { chars as chars_de_CH } from "./de_CH"
|
||||
import { de_CH } from "./de_CH"
|
||||
|
||||
export const name = "Français de Suisse";
|
||||
const name = "Français de Suisse";
|
||||
const isoCode = "fr-CH";
|
||||
|
||||
export const chars = {
|
||||
...chars_de_CH,
|
||||
const chars = {
|
||||
...de_CH.chars,
|
||||
"è": { key: "BracketLeft" },
|
||||
"ü": { key: "BracketLeft", shift: true },
|
||||
"é": { key: "Semicolon" },
|
||||
|
@ -13,3 +14,23 @@ export const chars = {
|
|||
"à": { key: "Quote" },
|
||||
"ä": { key: "Quote", shift: true },
|
||||
} 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
|
||||
};
|
||||
|
|