mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 18:09:19 -06:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c77981cc0c | ||
|
|
34746afea3 | ||
|
|
a4978d93d6 | ||
|
|
01e28f10ae | ||
|
|
ea9068fd0d | ||
|
|
d85823abcf | ||
|
|
4c0027ffd3 | ||
|
|
afb868af30 | ||
|
|
66b1d5dc30 | ||
|
|
7d8cdc74c6 | ||
|
|
809581df49 | ||
|
|
5d47a8d48d | ||
|
|
6242f1931e | ||
|
|
b768219c18 | ||
|
|
6935923bd1 | ||
|
|
559e6e2556 | ||
|
|
f354fdfbf4 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,3 +1,15 @@
|
||||
## 2.2.8 [01e28f1](https://github.com/sipeed/NanoKVM/commit/01e28f10ae8b581d484bb6077ddfe7bbe4e57919) (2025-05-22)
|
||||
|
||||
* feat: add AZERTY virtual keyboard Layout (thanks to [@felix068](https://github.com/felix068))
|
||||
* feat: add support for enabling/disabling HDMI output (PCIe version only) (thanks to [@tpretz](https://github.com/tpretz))
|
||||
* feat: add support for custom mouse wheel speed
|
||||
* fix: prevent direct H.264 stream buffer overflow and replay issues
|
||||
* perf: improve keyboard paste performance (thanks to [@ethanperrine](https://github.com/ethanperrine))
|
||||
* Localization
|
||||
* update Korean translation (thanks to [@kmw0410](https://github.com/kmw0410))
|
||||
* update Ukrainian translation (thanks to [@arbdevml](https://github.com/arbdevml))
|
||||
* update Russian translation (thanks to [@arbdevml](https://github.com/arbdevml))
|
||||
|
||||
## 2.2.7 [e18ec22](https://github.com/sipeed/NanoKVM/commit/e18ec2219d22886529575d1fdaad5c320e05f5b2) (2025-05-08)
|
||||
|
||||
* feat: add HTTPS support
|
||||
|
||||
150
README.md
150
README.md
@@ -6,42 +6,77 @@ NanoKVM
|
||||

|
||||
|
||||
<h3>
|
||||
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html"> Quick Start </a>
|
||||
|
|
||||
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM"> Hardware </a>
|
||||
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 Quick Start</a>
|
||||
|
|
||||
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ Hardware Details</a>
|
||||
|
|
||||
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 Firmware Releases</a>
|
||||
</h3>
|
||||
|
||||
English | [中文](./README_ZH.md) | [日本語](./README_JA.md)
|
||||
|
||||
</div>
|
||||
|
||||
> Your NanoKVM is powered by [RISC-V](https://en.wikipedia.org/wiki/RISC-V)!
|
||||
> If you have any issues or suggestions, creating issue here, or tell us with [MaixHub Discussion](https://maixhub.com/discussion/nanokvm).
|
||||
## 🌟 What is NanoKVM?
|
||||
|
||||
## Introduction
|
||||
NanoKVM is a series of compact, open-source IP-KVM devices. Built upon the LicheeRV Nano (RISC-V), NanoKVM allows you to remotely access and control computers as if you were sitting right in front of them – perfect for managing servers, embedded systems, or any headless machine.
|
||||
|
||||
Lichee NanoKVM is an IP-KVM product based on LicheeRV Nano, inheriting the extreme size and powerful features of LicheeRV Nano.
|
||||
## 📦 Product Family
|
||||
|
||||
The Lichee NanoKVM is now available in three versions:
|
||||
We offer several NanoKVM versions to suit your needs:
|
||||
|
||||
* NanoKVM-Cube Lite is a basic configuration that is suitable for individual users with certain DIY capabilities and enterprise users with bulk requirements.
|
||||
* **NanoKVM-Cube Lite:** A barebones kit for DIY enthusiasts and enterprise users needing bulk deployment.
|
||||
* **NanoKVM-Cube Full:** A complete package with a sleek case, accessories, and a pre-flashed system SD card. Ready to use out-of-the-box, ideal for individual users.
|
||||
* **NanoKVM-PCIe:** A unique form factor with a PCIe bracket for internal chassis mounting, drawing power directly from a PCIe slot. And add optional WiFi and PoE functions.
|
||||
|
||||
* NanoKVM-Cube Full is a full version with a sophisticated case and complete accessories, as well as a built-in system mirror card that is ready to use at boot, and is recommended for individual users.
|
||||
### 🚀 NanoKVM-Pro: The 4K Powerhouse
|
||||
|
||||
* NanoKVM-PCle is a new form with a built-in PCle baffle that can be fixed inside the chassis. NanoKVM-PCle adds optional WiFi and PoE functions based on NanoKVM-Cube; it has its own PCle slot and can draw power from the motherboard PCle slot; in addition, the wired connection (ETH) is also more stable, meeting more professional needs.
|
||||
Based on popular demand, we've upgraded NanoKVM to the **NanoKVM-Pro**:
|
||||
|
||||
* **Resolution Upgrade:** From 1080P to stunning **4K@30fps / 2K@60fps**.
|
||||
* **Network Upgrade:** Blazing fast **GbE + PoE + WiFi 6** (up from 100M Ethernet).
|
||||
* **Latency Upgrade:** Encoder accelerated latency reduced from 100-150ms to **50-100ms**.
|
||||
|
||||
> The NanoKVM-Pro is now available for pre-sale! [Click here for product details](https://sipeed.com/nanokvm/pro).
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
> For users concerned about security, you can read this issue: [#301](https://github.com/sipeed/NanoKVM/issues/301). You will find explanations addressing all security concerns.
|
||||
> GitHub, as an open and transparent platform, provides a space for discussing so-called "backdoor" concerns. Open-source makes products more secure!
|
||||
## 🛠️ Technical Specifications
|
||||
|
||||
## Project Structure
|
||||
| Product | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|
||||
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
|
||||
| Core | AX631 2xA53 1.5G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
|
||||
| Memory & Storage | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMc | 256M DDR3 + 16G eMMC |
|
||||
| System | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM |
|
||||
| Resolution | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps |
|
||||
| HDMI Loopout | 4K loopout | x | x | x |
|
||||
| Video Encoding | MJPEG / H.264 / H.265 | MJPEG / H264 | MJPEG / H264 | MJPEG / H264 |
|
||||
| Audio Transmit | ✓ | x | ✓ | x |
|
||||
| UEFI / BIOS | ✓ | ✓ | ✓ | ✓ |
|
||||
| Emulated USB Keyboard & Mouse | ✓ | ✓ | ✓ | ✓ |
|
||||
| Emulated USB ISO | ✓ | ✓ | ✓ | ✓ |
|
||||
| IPMI | ✓ | ✓ | ✓ | x |
|
||||
| Wake-on-LAN | ✓ | ✓ | ✓ | ✓ |
|
||||
| Web Terminal | ✓ | ✓ | ✓ | ✓ |
|
||||
| Serial Terminal | 3 channels | 2 channels | x | 1 channel |
|
||||
| Custom Scripts | ✓ | ✓ | x | x |
|
||||
| Storage | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s |
|
||||
| Ethernet | 1000M | 100M | 1000M | 100M |
|
||||
| PoE | Optional | Optional | x | x |
|
||||
| WiFi | Optional WiFi6 | Optional WiFi6 | x | x |
|
||||
| ATX Power Control | ✓ | ✓ | Extra $15 | Extra $10 |
|
||||
| Display | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | - | 1.68" 280x240 |
|
||||
| More Features | Sync LED Strip / Smart Assistant | - | - | - |
|
||||
| Power Consumption | 0.4A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
|
||||
| Power Input | USB-C or PoE | USB-C | USB-C | USB-C |
|
||||
| Dimensions | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm |
|
||||
| Price | ~~$79~~ $69 ATX / ~~$89~~ $79 Desk | $25 Lite / $50 Full(with ATX) | $89 no ATX / $102 with ATX | $69 no ATX / $79 with ATX |
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
``` shell
|
||||
├── kvmapp # APP update package
|
||||
@@ -58,33 +93,7 @@ The Lichee NanoKVM is now available in three versions:
|
||||
├── ...
|
||||
```
|
||||
|
||||
## Tech Specs
|
||||
|
||||
| Products | NanoKVM (Lite) | NanoKVM (Full) | PiKVM V4 |
|
||||
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |
|
||||
| Compute Units | LicheeRV Nano(RISCV) | LicheeRV Nano(RISCV) | CM4 (ARM) |
|
||||
| Resolution | 1080P @ 60fps | 1080P @ 60fps | 1080P @ 60fps |
|
||||
| Video encoding | MJPEG, H264 | MJPEG, H264 | MJPEG, H264 |
|
||||
| Video delay | 90~230ms | 90~230ms | 100~230ms |
|
||||
| UEFI/BIOS | ✓ | ✓ | ✓ |
|
||||
| Virtual HID | ✓ | ✓ | ✓ |
|
||||
| Virtual CD-ROM | ✓ | ✓ | ✓ |
|
||||
| IPMI | ✓ | ✓ | ✓ |
|
||||
| Wake-on-LAN | ✓ | ✓ | ✓ |
|
||||
| ETH | 100M/10M | 100M/10M | 1000M/100M/10M |
|
||||
| ATX power control | None,Users can connect by their own | USB interface IO control board | RJ-45 interface IO control board |
|
||||
| OLED | None,Users can connect by their own | 128x64 0.96" white | 128x32 0.91" white |
|
||||
| UART | 2 | 2 | 1 |
|
||||
| TF Card | None | ✓ | ✓ |
|
||||
| Expansion | None | PoE | WiFi/LTE |
|
||||
| Power consumption | 0.2A@5V | 0.2A@5V | Peak 2.6A@5V |
|
||||
| Power input | PC USB can be powered | PC USB or auxiliary power supply | DC 5V 3A power supply is required |
|
||||
| Cooling | Silent fanless | Silent fanless | Fan cooling |
|
||||
| Size | 23x37x15mm ~1/30 PiKVM V4 size | 40x36x36mm ~1/7 PiKVM V4 size | 120x68x44mm |
|
||||
|
||||

|
||||
|
||||
## Hardware platform NanoKVM
|
||||
## 🔩 Hardware Platform (NanoKVM Cube/PCIe)
|
||||
|
||||
NanoKVM is based on Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html),you can find specifications, schematics, and dimensional drawings [here](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano).
|
||||
|
||||
@@ -98,58 +107,33 @@ Note: Out of the 256MB memory in SG2002, 158MB is currently allocated for the mu
|
||||
* [NanoKVM-B Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf)
|
||||
* [NanoKVM img](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
|
||||
|
||||
## Roadmap
|
||||

|
||||
|
||||
**Enhancements:**
|
||||
## 🤝 Contributing
|
||||
|
||||
* [x] Refactor the HDMI module and remove the libmaixcam_lib.so dependency
|
||||
* [x] Disable SSH by default and add an enable/disable toggle in the web UI
|
||||
* [x] Disable Tailscale by default and add an enable/disable toggle in the web UI
|
||||
* [x] Add JWT configuration options to the configuration file
|
||||
* [x] Secure password storage with bcrypt
|
||||
We welcome contributions! Here's how you can help:
|
||||
|
||||
**Distribution:**
|
||||
1. Fork the repository.
|
||||
2. Create a feature branch.
|
||||
3. Commit your changes.
|
||||
4. Push to the branch.
|
||||
5. Open a Pull Request.
|
||||
|
||||
* [x] Distribute applications via GitHub
|
||||
* [x] Implement integrity checks for online updates
|
||||
* [ ] Support offline updates
|
||||
Please keep your pull requests small and focused to facilitate easier review and merging.
|
||||
|
||||
**Bug Fixes:**
|
||||
> 🎁 **Contributors who submit high-quality Pull Requests may receive a NanoKVM Cube, PCIe, or Pro as a token of our appreciation!**
|
||||
|
||||
* [x] Fix the DNS issue
|
||||
* [x] Fix the CSRF vulnerability
|
||||
|
||||
**Features**
|
||||
|
||||
* [ ] [79](https://github.com/sipeed/NanoKVM/issues/79) Support Zerotier
|
||||
* [ ] [99](https://github.com/sipeed/NanoKVM/issues/99) Support WireGuard
|
||||
* [ ] [249](https://github.com/sipeed/NanoKVM/issues/249) Add Mouse Jiggler
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Commit your changes
|
||||
4. Push to the branch
|
||||
5. Create a Pull Request
|
||||
|
||||
Thanks for your interest in contributing to NanoKVM!
|
||||
|
||||
Please keep your pull requests small and focused. This will make it easier to review and merge.
|
||||
|
||||
> Developers who have contributed high-quality Pull Requests will receive a NanoKVM Cube, PCIe, or Pro as a token of our appreciation.
|
||||
|
||||
## Where to buy
|
||||
## 🛒 Where to Buy
|
||||
|
||||
* [Aliexpress(global except USA&Russia)](https://www.aliexpress.com/item/1005007369816019.html)
|
||||
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
|
||||
* [Preorder (anyother country that not support in Aliexpress or Taobao)](https://sipeed.com/nanokvm)
|
||||
* [Preorder (any other country that not support in Aliexpress or Taobao)](https://sipeed.com/nanokvm)
|
||||
|
||||
## Community
|
||||
## 💬 Community & Support
|
||||
|
||||
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
|
||||
* QQ group: 703230713
|
||||
|
||||
## License
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the GPL-3.0 License - see the LICENSE file for details.
|
||||
|
||||
118
README_ZH.md
118
README_ZH.md
@@ -6,39 +6,77 @@ NanoKVM
|
||||

|
||||
|
||||
<h3>
|
||||
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html"> Quick Start </a>
|
||||
|
|
||||
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM"> Hardware </a>
|
||||
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 快速开始</a>
|
||||
|
|
||||
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ 硬件信息</a>
|
||||
|
|
||||
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 固件发布</a>
|
||||
</h3>
|
||||
|
||||
[English](./README.md) | 中文 | [日本語](./README_JA.md)
|
||||
|
||||
</div>
|
||||
|
||||
> Your NanoKVM Power by RISC-V !
|
||||
> 如果您在使用过程中有任何问题或建议,请在这里提交 issue, 或在 [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)告诉我们.
|
||||
## 🌟 NanoKVM 是什么?
|
||||
|
||||
## 简介
|
||||
NanoKVM 是一系列紧凑型开源 IP-KVM 设备。NanoKVM 基于 LicheeRV Nano (RISC-V) 构建,让您能够身临其境般的远程访问和控制计算机,是管理服务器、嵌入式系统或任何无头设备的理想之选。
|
||||
|
||||
Lichee NanoKVM 是基于 LicheeRV Nano 的 IP-KVM 产品,继承了 LicheeRV Nano的极致体积 和 强大功能。
|
||||
## 📦 产品系列
|
||||
|
||||
Lichee NanoKVM 目前有三个版本:
|
||||
我们提供多种 NanoKVM 版本以满足您的需求:
|
||||
|
||||
* NanoKVM-Cube Lite 为基础版配置,适合 具有一定DIY能力的个人用户 和 有批量需求的企业用户。
|
||||
* NanoKVM-Cube Full 为完整版配置,带精致外壳和完整配件,内置开机即用的系统镜像卡,推荐个人用户购买。
|
||||
* NanoKVM-PCle 为全新形态,内置 PCle 挡板,可固定在机箱内部。NanoKVM-PCle 在 NanoKVM-Cube 基础上增加了可选的 WiFi 和 PoE 功能;拥有自家的 PCle 插槽,可从主板 PCle 插槽取电;另外有线连接(ETH)也更加稳定,满足更多专业需求。
|
||||
* **NanoKVM-Cube Lite**:为 DIY 爱好者和需要批量部署的企业用户提供的准系统套件。
|
||||
* **NanoKVM-Cube Full**:包含外壳、配件以及预装系统 SD 卡的完整套装。开箱即用,非常适合个人用户。
|
||||
* **NanoKVM-PCIe**:独特的外形设计,配备 PCIe 支架,可安装在机箱内部,并直接从 PCIe 插槽供电。可选配 WiFi 和 PoE 功能。
|
||||
|
||||
### 🚀 NanoKVM-Pro
|
||||
|
||||
根据大众的需求,我们将 NanoKVM 升级到了 **NanoKVM-Pro**:
|
||||
|
||||
* **分辨率升级**:从 1080P 升级到 **4K@30fps / 2K@60fps**。
|
||||
* **网络升级**:从百兆网口升级到超快的 **GbE + WiFi 6**。
|
||||
* **延迟升级**:编码器加速延迟从 100-150 毫秒减少到 **50-100 毫秒**。
|
||||
|
||||
> NanoKVM-Pro 现已开启预售。
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
> 对于关注安全的用户,可以阅读此问题 [#301](https://github.com/sipeed/NanoKVM/issues/301),您会找到有关所有安全问题的解释。GitHub 作为一个开放和透明的平台,为讨论所谓的“后门”担忧提供了空间。开源使产品更加安全!
|
||||
## 🛠️ 技术规格
|
||||
|
||||
## 项目结构
|
||||
| 产品 | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|
||||
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
|
||||
| 核心 | AX631 2xA53 1.5G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
|
||||
| 内存&存储 | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMc | 256M DDR3 + 16G eMMC |
|
||||
| 系统 | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM |
|
||||
| 分辨率 | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps |
|
||||
| HDMI 环出 | 4K 环出 | x | x | x |
|
||||
| 视频编码 | MJPEG / H.264 / H.265 | MJPEG / H264 | MJPEG / H264 | MJPEG / H264 |
|
||||
| 音频输出 | ✓ | x | ✓ | x |
|
||||
| UEFI / BIOS | ✓ | ✓ | ✓ | ✓ |
|
||||
| 模拟 USB 键鼠 | ✓ | ✓ | ✓ | ✓ |
|
||||
| 模拟 USB ISO | ✓ | ✓ | ✓ | ✓ |
|
||||
| IPMI | ✓ | ✓ | ✓ | x |
|
||||
| 局域网唤醒 | ✓ | ✓ | ✓ | ✓ |
|
||||
| 网页终端 | ✓ | ✓ | ✓ | ✓ |
|
||||
| 串口终端 | 3 channels | 2 channels | x | 1 channel |
|
||||
| 用户脚本 | ✓ | ✓ | x | x |
|
||||
| 存储 | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s |
|
||||
| 网口 | 1000M | 100M | 1000M | 100M |
|
||||
| PoE | 可选 | 可选 | x | x |
|
||||
| WiFi | 可选 WiFi6 | 可选 WiFi6 | x | x |
|
||||
| ATX 电源控制 | ✓ | ✓ | ✓ | ✓ |
|
||||
| 显示屏 | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | - | 1.68" 280x240 |
|
||||
| 特色功能 | LED 同步灯带 / 智能助手 | - | - | - |
|
||||
| 功耗 | 0.4A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
|
||||
| 电源输入 | USB-C or PoE | USB-C | USB-C | USB-C |
|
||||
| 尺寸 | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm |
|
||||
| 价格 | ~~$79~~ $69 ATX / ~~$89~~ $79 Desk | $25 Lite / $50 Full(带ATX) | $89 无ATX / $102 带ATX | $69 无ATX / $79 带ATX |
|
||||
|
||||
## 📂 项目结构
|
||||
|
||||
``` shell
|
||||
├── kvmapp # APP更新包
|
||||
@@ -59,33 +97,7 @@ Lichee NanoKVM 目前有三个版本:
|
||||
└── CHANGELOG.md
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 产品 | NanoKVM (Lite) | NanoKVM (Full) | PiKVM V4 |
|
||||
| --- | --- | --- | --- |
|
||||
| 计算单元 | LicheeRV Nano(RISCV) | LicheeRV Nano(RISCV) | CM4 (ARM) |
|
||||
| 分辨率 | 1080P @ 60fps | 1080P @ 60fps | 1080P @ 60fps |
|
||||
| 视频编码 | MJPEG, H264 | MJPEG, H264 | MJPEG, H264 |
|
||||
| 视频延迟 | 90~230ms | 90~230ms | 100~230ms |
|
||||
| UEFI/BIOS | ✓ | ✓ | ✓ |
|
||||
| 模拟USB键鼠 | ✓ | ✓ | ✓ |
|
||||
| 模拟USB存储 | ✓ | ✓ | ✓ |
|
||||
| IPMI | ✓ | ✓ | ✓ |
|
||||
| Wake-on-LAN | ✓ | ✓ | ✓ |
|
||||
| ETH | 100M/10M | 100M/10M | 1000M/100M/10M |
|
||||
| ATX电源控制 | 无,用户可自行连接 | USB接口IO控制板 | RJ45接口IO控制板 |
|
||||
| OLED显示 | 无,用户可自行扩展 | 128x64 0.96" white | 128x32 0.91" white |
|
||||
| 外接串口 | 2路 | 2路 | 1路 |
|
||||
| TF卡 | 无,用户自备 | 有,开机即用 | 有 |
|
||||
| 扩展配件 | 无 | PoE | WiFi/LTE |
|
||||
| 功耗 | 0.2A@5V | 0.2A@5V | Peak 2.6A@5V |
|
||||
| 电源输入 | PC USB即可供电 | PC USB即可供电 <br> 也支持额外辅助供电 | 需要DC 5V 3A供电 |
|
||||
| 散热 | 静音无风扇 | 静音无风扇 | 需要风扇主动散热 |
|
||||
| 尺寸 | 23x37x15mm <br> ~1/30 PiKVM V4 体积 | 40x36x36mm <br/> ~1/7 PiKVM V4 体积 | 120x68x44mm |
|
||||
|
||||

|
||||
|
||||
## NanoKVM 硬件平台
|
||||
## 🔩 硬件平台(NanoKVM Cube/PCIe)
|
||||
|
||||
NanoKVM 基于 Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html) 核心板搭建,这部分硬件的规格书、原理图、尺寸图等均可在这里找到:[点击这里](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano)
|
||||
|
||||
@@ -99,13 +111,29 @@ NanoKVM 镜像在LicheeRV Nano SDK 和 MaixCDK 基础上构建,可以兼容使
|
||||
* [NanoKVM-B 原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_HDMI_MIPI_31011.pdf)
|
||||
* [NanoKVM 镜像下载](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
|
||||
|
||||
## 购买渠道
|
||||

|
||||
|
||||
## 🤝 贡献代码
|
||||
|
||||
我们十分欢迎任何人来贡献代码!您可以通过以下方式提供帮助:
|
||||
|
||||
1. Fork 本仓库.
|
||||
2. 创建一个分支.
|
||||
3. 提交你的代码.
|
||||
4. 推送代码到分支.
|
||||
5. 创建一个PR.
|
||||
|
||||
请保持你的 PR 尽量简单且集中,以便于更轻松地审查和合并。
|
||||
|
||||
> 🎁 **提交高质量 PR 的贡献者可能会收到 NanoKVM Cube、PCIe 或 Pro 作为感谢礼物!**
|
||||
|
||||
## 🛒 购买渠道
|
||||
|
||||
* [Aliexpress(全球,除了美国和俄罗斯)](https://www.aliexpress.com/item/1005007369816019.html)
|
||||
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
|
||||
* [Preorder (其它不支持淘宝和速卖通的国家)](https://sipeed.com/nanokvm)
|
||||
|
||||
## 社区
|
||||
## 💬 社区
|
||||
|
||||
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
|
||||
* QQ group: 703230713
|
||||
|
||||
@@ -87,6 +87,10 @@ type GetOLEDRsp struct {
|
||||
Sleep int `json:"sleep"`
|
||||
}
|
||||
|
||||
type GetGetHdmiStateRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type GetSSHStateRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ func vmRouter(r *gin.Engine) {
|
||||
api.GET("/vm/oled", service.GetOLED) // get OLED configuration
|
||||
api.POST("/vm/oled", service.SetOLED) // set OLED configuration
|
||||
|
||||
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi (pcie only)
|
||||
// Only supported by PCIe version
|
||||
api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
|
||||
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
|
||||
api.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
|
||||
api.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
|
||||
|
||||
api.GET("/vm/ssh", service.GetSSHState) // get SSH state
|
||||
api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
|
||||
|
||||
@@ -21,152 +21,94 @@ type PasteReq struct {
|
||||
func (s *Service) Paste(c *gin.Context) {
|
||||
var req PasteReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Content) > 1024 {
|
||||
rsp.ErrRsp(c, -2, "")
|
||||
rsp.ErrRsp(c, -2, "content too long")
|
||||
return
|
||||
}
|
||||
|
||||
s.hid.kbMutex.Lock()
|
||||
defer s.hid.kbMutex.Unlock()
|
||||
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
|
||||
for _, char := range req.Content {
|
||||
key, ok := charMap[char]
|
||||
if !ok {
|
||||
log.Debugf("unknown key %c", char)
|
||||
log.Debugf("unknown key '%c' (rune: %d)", char, char)
|
||||
continue
|
||||
}
|
||||
|
||||
keyDown := []byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
|
||||
// only handle shift. Need to handle all modifiers?
|
||||
if key.Modifiers > 0 {
|
||||
keyShift := []byte{0x00, 0x00, byte(225), 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
s.hid.Write(s.hid.g0, keyShift)
|
||||
}
|
||||
|
||||
s.hid.Write(s.hid.g0, keyDown)
|
||||
s.hid.Write(s.hid.g0, keyUp)
|
||||
|
||||
if key.Modifiers > 0 {
|
||||
s.hid.Write(s.hid.g0, keyUp)
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
s.hid.kbMutex.Unlock()
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("hid paste success, total %d", len(req.Content))
|
||||
log.Debugf("hid paste success, total %d characters processed", len(req.Content))
|
||||
}
|
||||
|
||||
var charMap = map[rune]Char{
|
||||
97: {0, 4}, // a
|
||||
98: {0, 5}, // b
|
||||
99: {0, 6}, // c
|
||||
100: {0, 7}, // d
|
||||
101: {0, 8}, // e
|
||||
102: {0, 9}, // f
|
||||
103: {0, 10}, // g
|
||||
104: {0, 11}, // h
|
||||
105: {0, 12}, // i
|
||||
106: {0, 13}, // j
|
||||
107: {0, 14}, // k
|
||||
108: {0, 15}, // l
|
||||
109: {0, 16}, // m
|
||||
110: {0, 17}, // n
|
||||
111: {0, 18}, // o
|
||||
112: {0, 19}, // p
|
||||
113: {0, 20}, // q
|
||||
114: {0, 21}, // r
|
||||
115: {0, 22}, // s
|
||||
116: {0, 23}, // t
|
||||
117: {0, 24}, // u
|
||||
118: {0, 25}, // v
|
||||
119: {0, 26}, // w
|
||||
120: {0, 27}, // x
|
||||
121: {0, 28}, // y
|
||||
122: {0, 29}, // z
|
||||
// Lowercase letters
|
||||
'a': {0, 4}, 'b': {0, 5}, 'c': {0, 6}, 'd': {0, 7}, 'e': {0, 8},
|
||||
'f': {0, 9}, 'g': {0, 10}, 'h': {0, 11}, 'i': {0, 12}, 'j': {0, 13},
|
||||
'k': {0, 14}, 'l': {0, 15}, 'm': {0, 16}, 'n': {0, 17}, 'o': {0, 18},
|
||||
'p': {0, 19}, 'q': {0, 20}, 'r': {0, 21}, 's': {0, 22}, 't': {0, 23},
|
||||
'u': {0, 24}, 'v': {0, 25}, 'w': {0, 26}, 'x': {0, 27}, 'y': {0, 28},
|
||||
'z': {0, 29},
|
||||
|
||||
49: {0, 30}, // 1
|
||||
50: {0, 31}, // 2
|
||||
51: {0, 32}, // 3
|
||||
52: {0, 33}, // 4
|
||||
53: {0, 34}, // 5
|
||||
54: {0, 35}, // 6
|
||||
55: {0, 36}, // 7
|
||||
56: {0, 37}, // 8
|
||||
57: {0, 38}, // 9
|
||||
48: {0, 39}, // 0
|
||||
// Uppercase letters (Modifier 2 typically means Left Shift)
|
||||
'A': {2, 4}, 'B': {2, 5}, 'C': {2, 6}, 'D': {2, 7}, 'E': {2, 8},
|
||||
'F': {2, 9}, 'G': {2, 10}, 'H': {2, 11}, 'I': {2, 12}, 'J': {2, 13},
|
||||
'K': {2, 14}, 'L': {2, 15}, 'M': {2, 16}, 'N': {2, 17}, 'O': {2, 18},
|
||||
'P': {2, 19}, 'Q': {2, 20}, 'R': {2, 21}, 'S': {2, 22}, 'T': {2, 23},
|
||||
'U': {2, 24}, 'V': {2, 25}, 'W': {2, 26}, 'X': {2, 27}, 'Y': {2, 28},
|
||||
'Z': {2, 29},
|
||||
|
||||
10: {0, 40}, // Enter
|
||||
9: {0, 43}, // Tab
|
||||
32: {0, 44}, // Space
|
||||
45: {0, 45}, // -
|
||||
61: {0, 46}, // =
|
||||
91: {0, 47}, // [
|
||||
93: {0, 48}, // ]
|
||||
92: {0, 49}, // \
|
||||
// Numbers
|
||||
'1': {0, 30}, '2': {0, 31}, '3': {0, 32}, '4': {0, 33}, '5': {0, 34},
|
||||
'6': {0, 35}, '7': {0, 36}, '8': {0, 37}, '9': {0, 38}, '0': {0, 39},
|
||||
|
||||
59: {0, 51}, // ;
|
||||
39: {0, 52}, // '
|
||||
96: {0, 53}, // `
|
||||
44: {0, 54}, // ,
|
||||
46: {0, 55}, // .
|
||||
47: {0, 56}, // /
|
||||
// Shifted numbers / Symbols
|
||||
'!': {2, 30}, // Shift + 1
|
||||
'@': {2, 31}, // Shift + 2
|
||||
'#': {2, 32}, // Shift + 3
|
||||
'$': {2, 33}, // Shift + 4
|
||||
'%': {2, 34}, // Shift + 5
|
||||
'^': {2, 35}, // Shift + 6
|
||||
'&': {2, 36}, // Shift + 7
|
||||
'*': {2, 37}, // Shift + 8
|
||||
'(': {2, 38}, // Shift + 9
|
||||
')': {2, 39}, // Shift + 0
|
||||
|
||||
65: {2, 4}, // A
|
||||
66: {2, 5}, // B
|
||||
67: {2, 6}, // C
|
||||
68: {2, 7}, // D
|
||||
69: {2, 8}, // E
|
||||
70: {2, 9}, // F
|
||||
71: {2, 10}, // G
|
||||
72: {2, 11}, // H
|
||||
73: {2, 12}, // I
|
||||
74: {2, 13}, // J
|
||||
75: {2, 14}, // K
|
||||
76: {2, 15}, // L
|
||||
77: {2, 16}, // M
|
||||
78: {2, 17}, // N
|
||||
79: {2, 18}, // O
|
||||
80: {2, 19}, // P
|
||||
81: {2, 20}, // Q
|
||||
82: {2, 21}, // R
|
||||
83: {2, 22}, // S
|
||||
84: {2, 23}, // T
|
||||
85: {2, 24}, // U
|
||||
86: {2, 25}, // V
|
||||
87: {2, 26}, // W
|
||||
88: {2, 27}, // X
|
||||
89: {2, 28}, // Y
|
||||
90: {2, 29}, // Z
|
||||
// Other common characters
|
||||
'\n': {0, 40}, // Enter (Return)
|
||||
'\t': {0, 43}, // Tab
|
||||
' ': {0, 44}, // Space
|
||||
'-': {0, 45}, // Hyphen / Minus
|
||||
'=': {0, 46}, // Equals
|
||||
'[': {0, 47}, // Left Square Bracket
|
||||
']': {0, 48}, // Right Square Bracket
|
||||
'\\': {0, 49}, // Backslash
|
||||
|
||||
33: {2, 30}, // !
|
||||
64: {2, 31}, // @
|
||||
35: {2, 32}, // #
|
||||
36: {2, 33}, // $
|
||||
37: {2, 34}, // %
|
||||
94: {2, 35}, // ^
|
||||
38: {2, 36}, // &
|
||||
42: {2, 37}, // *
|
||||
40: {2, 38}, // (
|
||||
41: {2, 39}, // )
|
||||
';': {0, 51}, // Semicolon
|
||||
'\'': {0, 52}, // Apostrophe / Single Quote
|
||||
'`': {0, 53}, // Grave Accent / Backtick
|
||||
',': {0, 54}, // Comma
|
||||
'.': {0, 55}, // Period / Dot
|
||||
'/': {0, 56}, // Slash
|
||||
|
||||
95: {2, 45}, // _
|
||||
43: {2, 46}, // +
|
||||
123: {2, 47}, // {
|
||||
125: {2, 48}, // }
|
||||
124: {2, 49}, // |
|
||||
// Shifted symbols
|
||||
'_': {2, 45}, // Underscore (Shift + Hyphen)
|
||||
'+': {2, 46}, // Plus (Shift + Equals)
|
||||
'{': {2, 47}, // Left Curly Brace (Shift + Left Square Bracket)
|
||||
'}': {2, 48}, // Right Curly Brace (Shift + Right Square Bracket)
|
||||
'|': {2, 49}, // Pipe (Shift + Backslash)
|
||||
|
||||
58: {2, 51}, // :
|
||||
34: {2, 52}, // "
|
||||
126: {2, 53}, // ~
|
||||
60: {2, 54}, // <
|
||||
62: {2, 55}, // >
|
||||
63: {2, 56}, // ?
|
||||
':': {2, 51}, // Colon (Shift + Semicolon)
|
||||
'"': {2, 52}, // Double Quote (Shift + Apostrophe)
|
||||
'~': {2, 53}, // Tilde (Shift + Grave Accent)
|
||||
'<': {2, 54}, // Less Than (Shift + Comma)
|
||||
'>': {2, 55}, // Greater Than (Shift + Period)
|
||||
'?': {2, 56}, // Question Mark (Shift + Slash)
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ type Frame struct {
|
||||
}
|
||||
|
||||
var (
|
||||
mutex = sync.Mutex{}
|
||||
wsMap = make(map[*websocket.Conn]bool)
|
||||
upgrader = websocket.Upgrader{
|
||||
mutex = sync.Mutex{}
|
||||
wsMap = make(map[*websocket.Conn]bool)
|
||||
isSending = false
|
||||
upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
@@ -47,7 +48,7 @@ func Connect(c *gin.Context) {
|
||||
|
||||
mutex.Lock()
|
||||
wsMap[ws] = true
|
||||
if len(wsMap) == 1 {
|
||||
if len(wsMap) == 1 && !isSending {
|
||||
go send()
|
||||
}
|
||||
mutex.Unlock()
|
||||
@@ -62,6 +63,7 @@ func Connect(c *gin.Context) {
|
||||
}
|
||||
|
||||
func send() {
|
||||
isSending = true
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
|
||||
@@ -76,6 +78,7 @@ func send() {
|
||||
|
||||
for range ticker.C {
|
||||
if len(wsMap) == 0 {
|
||||
isSending = false
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/common"
|
||||
"NanoKVM-Server/proto"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var hdmiEnabled = true
|
||||
|
||||
func (s *Service) ResetHdmi(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
@@ -17,7 +20,42 @@ func (s *Service) ResetHdmi(c *gin.Context) {
|
||||
vision.SetHDMI(false)
|
||||
time.Sleep(1 * time.Second)
|
||||
vision.SetHDMI(true)
|
||||
hdmiEnabled = true
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debug("reset hdmi")
|
||||
}
|
||||
|
||||
func (s *Service) EnableHdmi(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
|
||||
vision.SetHDMI(true)
|
||||
hdmiEnabled = true
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debug("enable hdmi")
|
||||
}
|
||||
|
||||
func (s *Service) DisableHdmi(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
|
||||
vision.SetHDMI(false)
|
||||
hdmiEnabled = false
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debug("disable hdmi")
|
||||
}
|
||||
|
||||
func (s *Service) GetHdmiState(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetGetHdmiStateRsp{
|
||||
Enabled: hdmiEnabled,
|
||||
})
|
||||
|
||||
log.Debug("get hdmi state")
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"react-simple-keyboard": "^3.8.19",
|
||||
"semver": "^7.6.3",
|
||||
"vaul": "^0.9.9",
|
||||
"websocket": "^1.0.35"
|
||||
"websocket": "^1.0.35",
|
||||
"yocto-queue": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
||||
|
||||
9
web/pnpm-lock.yaml
generated
9
web/pnpm-lock.yaml
generated
@@ -80,6 +80,9 @@ importers:
|
||||
websocket:
|
||||
specifier: ^1.0.35
|
||||
version: 1.0.35
|
||||
yocto-queue:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
devDependencies:
|
||||
'@ianvs/prettier-plugin-sort-imports':
|
||||
specifier: ^4.3.1
|
||||
@@ -2916,6 +2919,10 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yocto-queue@1.2.1:
|
||||
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
|
||||
engines: {node: '>=12.20'}
|
||||
|
||||
yoctocolors-cjs@2.1.2:
|
||||
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5899,4 +5906,6 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yocto-queue@1.2.1: {}
|
||||
|
||||
yoctocolors-cjs@2.1.2: {}
|
||||
|
||||
@@ -62,6 +62,29 @@ export function resetHdmi() {
|
||||
return http.post('/api/vm/hdmi/reset');
|
||||
}
|
||||
|
||||
// get HDMI state
|
||||
export function getHdmiState() {
|
||||
return http.get('/api/vm/hdmi');
|
||||
}
|
||||
|
||||
// enable HDMI
|
||||
export function enableHdmi() {
|
||||
return http.post('/api/vm/hdmi/enable');
|
||||
}
|
||||
|
||||
// disable HDMI
|
||||
export function disableHdmi() {
|
||||
return http.post('/api/vm/hdmi/disable');
|
||||
}
|
||||
|
||||
// set HDMI state
|
||||
export function setHdmiState(enabled: boolean) {
|
||||
if (enabled) {
|
||||
return enableHdmi();
|
||||
}
|
||||
return disableHdmi();
|
||||
}
|
||||
|
||||
// get SSH state
|
||||
export function getSSHState() {
|
||||
return http.get('/api/vm/ssh');
|
||||
|
||||
@@ -103,3 +103,7 @@
|
||||
.keyboard-header {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
.hg-candidate-box {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ const en = {
|
||||
mode: 'Mouse mode',
|
||||
absolute: 'Absolute mode',
|
||||
relative: 'Relative mode',
|
||||
speed: 'Wheel speed',
|
||||
fast: 'Fast',
|
||||
slow: 'Slow',
|
||||
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.',
|
||||
resetHid: 'Reset HID',
|
||||
hidOnly: {
|
||||
@@ -258,6 +261,9 @@ const en = {
|
||||
description: 'Enable mDNS discovery service',
|
||||
tip: "Turning it off if it's not needed"
|
||||
},
|
||||
hdmi: {
|
||||
description: 'Enable HDMI/monitor output'
|
||||
},
|
||||
hidOnly: 'HID-Only Mode',
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
|
||||
@@ -40,9 +40,9 @@ const ko = {
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Wi-Fi 설정 for NanoKVM',
|
||||
description: 'NanoKVM Wi-Fi 설정',
|
||||
success: 'NanoKVM의 네트워크 상태를 확인하고 새 IP 주소로 접속하세요.',
|
||||
failed: '작업이 실패했습니다. 다시 시도하세요.',
|
||||
failed: '작업에 실패했습니다. 다시 시도하세요.',
|
||||
confirmBtn: '확인',
|
||||
finishBtn: '완료'
|
||||
},
|
||||
|
||||
@@ -14,8 +14,7 @@ const ru = {
|
||||
placeholderPassword2: 'Введите пароль снова',
|
||||
noEmptyUsername: 'Имя пользователя не может быть пустым',
|
||||
noEmptyPassword: 'Пароль не может быть пустым',
|
||||
noAccount:
|
||||
'Не удалось получить информацию о пользователе, пожалуйста, обновите веб-страницу или сбросьте пароль',
|
||||
noAccount: 'Не удалось получить информацию о пользователе, пожалуйста, обновите веб-страницу или сбросьте пароль',
|
||||
invalidUser: 'Неверное имя пользователя или пароль',
|
||||
error: 'Непредвиденная ошибка',
|
||||
changePassword: 'Изменить пароль',
|
||||
@@ -50,6 +49,7 @@ const ru = {
|
||||
screen: {
|
||||
title: 'Экран',
|
||||
video: 'Видеорежим',
|
||||
videoDirectTips: 'Включите HTTPS в "Настройки > Устройство", чтобы использовать этот режим',
|
||||
resolution: 'Разрешение',
|
||||
auto: 'Автоматическое',
|
||||
autoTips:
|
||||
@@ -236,6 +236,10 @@ const ru = {
|
||||
description: 'Включить удаленный доступ по SSH',
|
||||
tip: 'Задайте сильный пароль перед включением (Аккаунт - Пароль - Обновить)'
|
||||
},
|
||||
tls: {
|
||||
description: 'Включить HTTPS Protocol',
|
||||
tip: 'Имейте в виду: использование HTTPS может увеличить задержку, особенно в режиме видео MJPEG.'
|
||||
},
|
||||
advanced: 'Дополнительные настройки',
|
||||
swap: {
|
||||
title: 'Файл подкачки',
|
||||
@@ -286,7 +290,8 @@ const ru = {
|
||||
upTailscale: 'Переместите tailscale в каталог /usr/bin/ на NanoKVM',
|
||||
upTailscaled: 'Переместите tailscaled в каталог /usr/sbin/ на NanoKVM',
|
||||
refresh: 'Обновите текущую страницу',
|
||||
notLogin: 'Устройство не привязано. Войдите, чтобы привязать его к аккаунту.',
|
||||
notLogin:
|
||||
'Устройство не привязано. Войдите, чтобы привязать его к аккаунту.',
|
||||
urlPeriod: 'Этот адрес действителен в течение 10 минут',
|
||||
login: 'Войти',
|
||||
loginSuccess: 'Вход выполнен',
|
||||
@@ -295,7 +300,7 @@ const ru = {
|
||||
deviceIP: 'IP адрес устройства',
|
||||
account: 'Аккаунт',
|
||||
logout: 'Выход',
|
||||
logout2: 'Вы действительно хотите выйти?',
|
||||
logoutDesc: 'Вы действительно хотите выйти?',
|
||||
uninstall: 'Удалить Tailscale',
|
||||
okBtn: 'Да',
|
||||
cancelBtn: 'Нет'
|
||||
|
||||
@@ -14,13 +14,11 @@ const uk = {
|
||||
placeholderPassword2: 'Введіть пароль ще раз',
|
||||
noEmptyUsername: "Ім'я користувача не може бути порожнім",
|
||||
noEmptyPassword: 'Пароль не може бути порожнім',
|
||||
noAccount:
|
||||
'Не вдалося отримати інформацію про користувача, оновіть веб-сторінку або скиньте пароль',
|
||||
noAccount: 'Не вдалося отримати інформацію про користувача, оновіть веб-сторінку або скиньте пароль',
|
||||
invalidUser: "Недійсне ім'я користувача або пароль",
|
||||
error: 'Якась халепа! Непередбачена помилка :(',
|
||||
changePassword: 'Змінити пароль',
|
||||
changePasswordDesc:
|
||||
'Для безпеки вашого пристрою, будь ласка, змініть пароль для входу в веб-інтерфейс.',
|
||||
changePasswordDesc: 'Для безпеки вашого пристрою, будь ласка, змініть пароль для входу в веб-інтерфейс.',
|
||||
differentPassword: 'Паролі не збігаються',
|
||||
illegalUsername: "Ім'я користувача містить недопустимі символи",
|
||||
illegalPassword: 'Пароль містить недопустимі символи',
|
||||
@@ -51,6 +49,7 @@ const uk = {
|
||||
screen: {
|
||||
title: 'Екран',
|
||||
video: 'Відеорежим',
|
||||
videoDirectTips: 'Увімкніть HTTPS у "Налаштування > Пристрій", щоб використовувати цей режим',
|
||||
resolution: 'Роздільна здатність',
|
||||
auto: 'Автоматично',
|
||||
autoTips:
|
||||
@@ -88,27 +87,27 @@ const uk = {
|
||||
mode: 'Режим миші',
|
||||
absolute: 'Абсолютний режим',
|
||||
relative: 'Відносний режим',
|
||||
requestPointer:
|
||||
'Використовується відносний режим. Будь ласка, натисніть на робочий стіл, щоб отримати курсор миші.',
|
||||
requestPointer: 'Використовується відносний режим. Будь ласка, натисніть на робочий стіл, щоб отримати курсор миші.',
|
||||
resetHid: 'Скинути HID',
|
||||
hidOnly: {
|
||||
title: 'Режим лише HID',
|
||||
desc: 'Якщо ваша миша та клавіатура перестають відповідати та скинути HID, це не допомагає, це може бути проблемою сумісності між NanoKVM та пристроєм. Спробуйте ввімкнути режим "лише HID" лише для кращої сумісності.',
|
||||
tip1: 'Увімкнення режиму "лише HID" демонтує віртуальний U-диск і віртуальну мережу',
|
||||
tip2: 'У режимі "лише HID" монтування іміджей буде вимкнено',
|
||||
tip2: 'У режимі "лише HID" монтування образів буде вимкнено',
|
||||
tip3: 'NANOKVM автоматично перезавантажиться після перемикання режимів',
|
||||
enable: 'Увімкнути режим "лише HID"',
|
||||
disable: 'Ввимкнути режим "лише HID"'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Іміджи',
|
||||
title: 'Образи',
|
||||
loading: 'Завантаження...',
|
||||
empty: 'Нічого не знайдено',
|
||||
mountFailed: 'Не вдалося змонтувати імідж у режимі CD-ROM',
|
||||
cdrom: 'Встановіть образ в режимі CD-ROM',
|
||||
mountFailed: 'Не вдалося змонтувати образ у режимі CD-ROM',
|
||||
mountDesc:
|
||||
'У деяких системах необхідно витягнути віртуальний диск на віддаленому хості перед монтуванням файлу образа.',
|
||||
refresh: 'Оновіть список іміджей',
|
||||
refresh: 'Оновіть список образів',
|
||||
tips: {
|
||||
title: 'Як завантажити',
|
||||
usb1: "Під'єднайте NanoKVM до вашого комп'ютера через USB.",
|
||||
@@ -165,15 +164,15 @@ const uk = {
|
||||
ok: 'Ок'
|
||||
},
|
||||
download: {
|
||||
title: 'Завантажувач іміджей',
|
||||
input: 'Введіть URL-адресу віддаленого іміджа',
|
||||
title: 'Завантажувач образів',
|
||||
input: 'Введіть URL-адресу віддаленого образу',
|
||||
ok: 'Так',
|
||||
disabled: 'розділ даних /data у режіме RO, тому ми не можемо завантажити імідж'
|
||||
disabled: 'Розділ даних /data у режимі лише для читання, тому ми не можемо завантажити образ'
|
||||
},
|
||||
power: {
|
||||
title: 'Живлення',
|
||||
showConfirm: 'Confirmation',
|
||||
showConfirmTip: 'Power operations require an extra confirmation',
|
||||
showConfirm: 'Підтвердження',
|
||||
showConfirmTip: 'Енергетичні операції потребують додаткового підтвердження',
|
||||
reset: 'Скидання',
|
||||
power: 'Живлення',
|
||||
powerShort: 'Живлення (коротке натискання)',
|
||||
@@ -191,19 +190,24 @@ const uk = {
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: 'Версія додатка',
|
||||
applicationTip: 'Версія веб-додатка NANOKVM',
|
||||
applicationTip: 'Версія веб-додатка NanoKVM',
|
||||
image: 'Версія образу',
|
||||
imageTip: 'Версія системного іміджу Nanokvm',
|
||||
imageTip: 'Версія системного образу NanoKVM',
|
||||
deviceKey: 'Ключ пристрою',
|
||||
community: 'Спільнота',
|
||||
hostname: "Ім'я вузла",
|
||||
hostnameUpdated: "Ім'я вузла оновлено. Перезавантажте систему, щоб застосувати зміни."
|
||||
hostnameUpdated: "Ім'я вузла оновлено. Перезавантажте систему, щоб застосувати зміни.",
|
||||
ipType: {
|
||||
Wired: 'Дротове',
|
||||
Wireless: 'Бездротове',
|
||||
Other: 'Інший'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
title: 'Зовнішній вигляд',
|
||||
display: 'Відображення',
|
||||
language: 'Мова',
|
||||
menuBar: 'Menu Bar',
|
||||
menuBar: 'Панель меню',
|
||||
menuBarDesc: 'Піктограми відображення в панелі меню',
|
||||
webTitle: 'Веб-назва',
|
||||
webTitleDesc: 'Налаштуйте назву веб-сторінки'
|
||||
@@ -232,11 +236,16 @@ const uk = {
|
||||
description: 'Увімкніть віддалений доступ по SSH',
|
||||
tip: 'Перед ввімкненням встановіть надійний пароль (Обліковий запис - Зміна пароля)'
|
||||
},
|
||||
tls: {
|
||||
description: 'Увімкнути протокол HTTPS',
|
||||
tip: 'Будьте в курсі: Використання HTTPS може збільшити затримку, особливо в режимі відео MJPEG.'
|
||||
},
|
||||
advanced: 'Розширені налаштування',
|
||||
swap: {
|
||||
title: 'Swap (файл підкачки)',
|
||||
disable: 'Відключено',
|
||||
description: 'Встановіть розмір файлу свопу',
|
||||
tip: "Увімкнення цієї функції може скоротити корисне життя вашої SD-карти!"
|
||||
tip: "Увімкнення цієї опції може вивести з ладу SD карту!"
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: 'Налаштування пацюка',
|
||||
|
||||
@@ -85,6 +85,9 @@ const zh = {
|
||||
mode: '鼠标模式',
|
||||
absolute: '绝对模式',
|
||||
relative: '相对模式',
|
||||
speed: '滚轮速度',
|
||||
fast: '快',
|
||||
slow: '慢',
|
||||
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。',
|
||||
resetHid: '重置 HID',
|
||||
hidOnly: {
|
||||
@@ -238,6 +241,9 @@ const zh = {
|
||||
description: '启用 mDNS 发现服务',
|
||||
tip: '如果您未使用此功能,建议将其关闭'
|
||||
},
|
||||
hdmi: {
|
||||
description: '启用 HDMI/显示器 输出功能'
|
||||
},
|
||||
hidOnly: 'HID-Only 模式',
|
||||
disk: '虚拟U盘',
|
||||
diskDesc: '在远程主机中挂载虚拟U盘',
|
||||
|
||||
@@ -6,5 +6,8 @@ export const mouseStyleAtom = atom('cursor-default');
|
||||
// mouse mode: absolute or relative
|
||||
export const mouseModeAtom = atom('absolute');
|
||||
|
||||
// mouse scroll interval (unit: ms)
|
||||
export const scrollIntervalAtom = atom(0);
|
||||
|
||||
// hid mode: normal or hid-only
|
||||
export const hidModeAtom = atom<'normal' | 'hid-only'>('normal');
|
||||
|
||||
@@ -2,6 +2,8 @@ import { atom } from 'jotai';
|
||||
|
||||
import { Resolution } from '@/types';
|
||||
|
||||
export const isHdmiEnabledAtom = atom(true);
|
||||
|
||||
// video mode
|
||||
// direct: stream H.264 over HTTP
|
||||
// h264: stream H.264 over WebRTC
|
||||
|
||||
@@ -9,8 +9,10 @@ const GOP_KEY = 'nano-kvm-gop';
|
||||
const FRAME_DETECT_KEY = 'nano-kvm-frame-detect';
|
||||
const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style';
|
||||
const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode';
|
||||
const MOUSE_SCROLL_INTERVAL_KEY = 'nanokvm-kvm-mouse-scroll-interval';
|
||||
const SKIP_UPDATE_KEY = 'nano-kvm-check-update';
|
||||
const KEYBOARD_LAYOUT_KEY = 'nano-kvm-keyboard-layout';
|
||||
const KEYBOARD_SYSTEM_KEY = 'nano-kvm-keyboard-system';
|
||||
const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language';
|
||||
const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password';
|
||||
const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items';
|
||||
const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm';
|
||||
@@ -129,6 +131,15 @@ export function setMouseMode(mouse: string) {
|
||||
localStorage.setItem(MOUSE_MODE_KEY, mouse);
|
||||
}
|
||||
|
||||
export function getMouseScrollInterval() {
|
||||
const interval = localStorage.getItem(MOUSE_SCROLL_INTERVAL_KEY);
|
||||
return interval ? Number(interval) : null;
|
||||
}
|
||||
|
||||
export function setMouseScrollInterval(interval: number): void {
|
||||
localStorage.setItem(MOUSE_SCROLL_INTERVAL_KEY, String(interval));
|
||||
}
|
||||
|
||||
export function getSkipUpdate() {
|
||||
const skip = getWithExpiry(SKIP_UPDATE_KEY);
|
||||
return skip === 'true';
|
||||
@@ -139,12 +150,20 @@ export function setSkipUpdate(skip: boolean) {
|
||||
setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry);
|
||||
}
|
||||
|
||||
export function setKeyboardLayout(layout: string) {
|
||||
localStorage.setItem(KEYBOARD_LAYOUT_KEY, layout);
|
||||
export function setKeyboardSystem(system: string) {
|
||||
localStorage.setItem(KEYBOARD_SYSTEM_KEY, system);
|
||||
}
|
||||
|
||||
export function getKeyboardLayout() {
|
||||
return localStorage.getItem(KEYBOARD_LAYOUT_KEY);
|
||||
export function getKeyboardSystem() {
|
||||
return localStorage.getItem(KEYBOARD_SYSTEM_KEY);
|
||||
}
|
||||
|
||||
export function setKeyboardLanguage(language: string) {
|
||||
localStorage.setItem(KEYBOARD_LANGUAGE_KEY, language);
|
||||
}
|
||||
|
||||
export function getKeyboardLanguage() {
|
||||
return localStorage.getItem(KEYBOARD_LANGUAGE_KEY);
|
||||
}
|
||||
|
||||
export function setSkipModifyPassword(skip: boolean) {
|
||||
|
||||
@@ -84,6 +84,7 @@ export const KeyboardCodes: Map<string, number> = new Map([
|
||||
['BracketRight', 48],
|
||||
['Backslash', 49],
|
||||
['IntlBackslash', 49],
|
||||
['Backquote_azerty', 100],
|
||||
|
||||
['Semicolon', 51],
|
||||
['Quote', 52],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AppleOutlined, WindowsOutlined } from '@ant-design/icons';
|
||||
import { ConfigProvider, Segmented, theme } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { XIcon } from 'lucide-react';
|
||||
@@ -10,9 +9,10 @@ import { Drawer } from 'vaul';
|
||||
import 'react-simple-keyboard/build/css/index.css';
|
||||
import '@/assets/styles/keyboard.css';
|
||||
|
||||
import { ConfigProvider, Segmented, Select, theme } from 'antd';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { getKeyboardLayout, setKeyboardLayout, getLanguage } from '@/lib/localstorage.ts';
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
@@ -31,18 +31,56 @@ export const VirtualKeyboard = () => {
|
||||
|
||||
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom);
|
||||
|
||||
const [layout, setLayout] = useState('default');
|
||||
const [keyboardLayout, setKeyboardLayout] = useState('default');
|
||||
const [keyboardSystem, setKeyboardSystem] = useState('win');
|
||||
const [keyboardLanguage, setKeyboardLanguage] = useState('en');
|
||||
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([]);
|
||||
|
||||
const keyboardRef = useRef<any>(null);
|
||||
|
||||
const systems = [
|
||||
{ value: 'win', icon: <WindowsOutlined /> },
|
||||
{ value: 'mac', icon: <AppleOutlined /> }
|
||||
];
|
||||
|
||||
const languages = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'French' },
|
||||
{ value: 'ru', label: 'Russian' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardLayout = getKeyboardLayout();
|
||||
if (keyboardLayout && ['default', 'mac', 'rus'].includes(keyboardLayout)) {
|
||||
setLayout(keyboardLayout);
|
||||
const system = storage.getKeyboardSystem();
|
||||
if (system && ['win', 'mac'].includes(system)) {
|
||||
setKeyboardSystem(system);
|
||||
}
|
||||
|
||||
const language = storage.getKeyboardLanguage();
|
||||
if (language && languages.some((lng) => lng.value === language)) {
|
||||
setKeyboardLanguage(language);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const layoutMap = new Map([
|
||||
['en', 'default'],
|
||||
['ru', 'rus'],
|
||||
['fr', 'azerty']
|
||||
]);
|
||||
|
||||
if (keyboardLanguage === 'en' && keyboardSystem === 'mac') {
|
||||
setKeyboardLayout('mac');
|
||||
return;
|
||||
}
|
||||
|
||||
if (layoutMap.has(keyboardLanguage)) {
|
||||
setKeyboardLayout(layoutMap.get(keyboardLanguage)!);
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyboardLayout('default');
|
||||
}, [keyboardSystem, keyboardLanguage]);
|
||||
|
||||
function onKeyPress(key: string) {
|
||||
if (modifierKeys.includes(key)) {
|
||||
if (activeModifierKeys.includes(key)) {
|
||||
@@ -66,8 +104,7 @@ export const VirtualKeyboard = () => {
|
||||
}
|
||||
|
||||
function sendKeydown(key: string) {
|
||||
const specialKey = specialKeyMap.get(key);
|
||||
const code = KeyboardCodes.get(specialKey ? specialKey : key);
|
||||
const code = getKeyCode(key);
|
||||
if (!code) {
|
||||
console.log('unknown code: ', key);
|
||||
return;
|
||||
@@ -78,6 +115,26 @@ export const VirtualKeyboard = () => {
|
||||
client.send([1, code, ...modifiers]);
|
||||
}
|
||||
|
||||
function getKeyCode(key: string) {
|
||||
// AZERTY: swap A↔Q and Z↔W on French physical positions
|
||||
if (keyboardLanguage === 'fr' && key.endsWith('_azerty')) {
|
||||
const base = key.replace('_azerty', '');
|
||||
if (base === 'KeyA') return KeyboardCodes.get('KeyQ');
|
||||
if (base === 'KeyQ') return KeyboardCodes.get('KeyA');
|
||||
if (base === 'KeyZ') return KeyboardCodes.get('KeyW');
|
||||
if (base === 'KeyW') return KeyboardCodes.get('KeyZ');
|
||||
// all other labels use their own code
|
||||
return KeyboardCodes.get(base);
|
||||
}
|
||||
|
||||
const specialKey = specialKeyMap.get(key);
|
||||
if (specialKey) {
|
||||
return KeyboardCodes.get(specialKey);
|
||||
}
|
||||
|
||||
return KeyboardCodes.get(key);
|
||||
}
|
||||
|
||||
function sendKeyup() {
|
||||
sendModifierKeyUp();
|
||||
client.send([1, 0, 0, 0, 0, 0]);
|
||||
@@ -121,6 +178,16 @@ export const VirtualKeyboard = () => {
|
||||
setActiveModifierKeys([]);
|
||||
}
|
||||
|
||||
function selectSystem(system: string) {
|
||||
setKeyboardSystem(system);
|
||||
storage.setKeyboardSystem(system);
|
||||
}
|
||||
|
||||
function selectLanguage(language: string) {
|
||||
setKeyboardLanguage(language);
|
||||
storage.setKeyboardLanguage(language);
|
||||
}
|
||||
|
||||
function getButtonTheme(): KeyboardButtonTheme[] {
|
||||
const theme = [{ class: 'hg-double', buttons: doubleKeys.join(' ') }];
|
||||
|
||||
@@ -132,11 +199,6 @@ export const VirtualKeyboard = () => {
|
||||
return theme;
|
||||
}
|
||||
|
||||
function selectLayout(value: string) {
|
||||
setKeyboardLayout(value);
|
||||
setLayout(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer.Root open={isKeyboardOpen} onOpenChange={setIsKeyboardOpen} modal={false}>
|
||||
<Drawer.Portal>
|
||||
@@ -148,23 +210,30 @@ export const VirtualKeyboard = () => {
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between px-3 py-1">
|
||||
<div className="flex flex-row">
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm
|
||||
}}
|
||||
>
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: 'Win', value: 'default', icon: <WindowsOutlined /> },
|
||||
{ label: 'Mac', value: 'mac', icon: <AppleOutlined /> },
|
||||
...(getLanguage() === 'ru' ? [{ label: 'Rus', value: 'rus', icon: <WindowsOutlined /> }] : [])
|
||||
]}
|
||||
value={layout}
|
||||
onChange={selectLayout}
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-5">
|
||||
<Select
|
||||
size="small"
|
||||
style={{ minWidth: 90 }}
|
||||
defaultValue={keyboardLanguage}
|
||||
options={languages}
|
||||
onChange={selectLanguage}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</div>
|
||||
|
||||
{keyboardLanguage === 'en' && (
|
||||
<Segmented
|
||||
size="small"
|
||||
options={systems}
|
||||
value={keyboardSystem}
|
||||
onChange={selectSystem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
|
||||
<div className="flex w-[100px] items-center justify-end">
|
||||
<div
|
||||
@@ -185,7 +254,7 @@ export const VirtualKeyboard = () => {
|
||||
keyboardRef={(r) => (keyboardRef.current = r)}
|
||||
onKeyPress={onKeyPress}
|
||||
onKeyReleased={onKeyReleased}
|
||||
layoutName={layout}
|
||||
layoutName={keyboardLayout}
|
||||
{...keyboardOptions}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// TODO: refactor it
|
||||
|
||||
// main keys
|
||||
export const keyboardOptions = {
|
||||
theme: 'simple-keyboard hg-theme-default',
|
||||
@@ -26,6 +28,14 @@ export const keyboardOptions = {
|
||||
'{capslock} RusA RusS RusD RusF RusG RusH RusJ RusK RusL RusSemicolon RusQuote {enter}',
|
||||
'{shiftleft} RusZ RusX RusC RusV RusB RusN RusM RusComma RusPeriod RusSlash {shiftright}',
|
||||
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
|
||||
],
|
||||
azerty: [
|
||||
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
|
||||
'Backquote_azerty Digit1_azerty Digit2_azerty Digit3_azerty Digit4_azerty Digit5_azerty Digit6_azerty Digit7_azerty Digit8_azerty Digit9_azerty Digit0_azerty Minus_azerty Equal_azerty {backspace}',
|
||||
'{tab} KeyA_azerty KeyZ_azerty KeyE_azerty KeyR_azerty KeyT_azerty KeyY_azerty KeyU_azerty KeyI_azerty KeyO_azerty KeyP_azerty BracketLeft_azerty BracketRight_azerty Backslash_azerty',
|
||||
'{capslock} KeyQ_azerty KeyS_azerty KeyD_azerty KeyF_azerty KeyG_azerty KeyH_azerty KeyJ_azerty KeyK_azerty KeyL_azerty Semicolon_azerty Quote_azerty {enter}',
|
||||
'{shiftleft} KeyW_azerty KeyX_azerty KeyC_azerty KeyV_azerty KeyB_azerty KeyN_azerty KeyM_azerty Comma_azerty Period_azerty Slash_azerty {shiftright}',
|
||||
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
|
||||
]
|
||||
},
|
||||
display: {
|
||||
@@ -111,7 +121,7 @@ export const keyboardOptions = {
|
||||
RusBracketLeft: 'Х',
|
||||
RusBracketRight: 'Ъ',
|
||||
RusBackslash: '/<br>\\',
|
||||
|
||||
|
||||
RusA: 'Ф',
|
||||
RusS: 'Ы',
|
||||
RusD: 'В',
|
||||
@@ -134,7 +144,71 @@ export const keyboardOptions = {
|
||||
RusComma: 'Б',
|
||||
RusPeriod: 'Ю',
|
||||
RusSlash: ',<br/>.',
|
||||
|
||||
// AZERTY specific display keys
|
||||
// Row 1
|
||||
Backquote_azerty: '<<br/>>',
|
||||
Digit1_azerty: '&<br/>1',
|
||||
Digit2_azerty: 'é<br/>2',
|
||||
Digit3_azerty: '"<br/>#',
|
||||
Digit4_azerty: "'<br/>{",
|
||||
Digit5_azerty: '(<br/>[',
|
||||
Digit6_azerty: '-<br/>|',
|
||||
Digit7_azerty: 'è<br/>`',
|
||||
Digit8_azerty: '_<br/>\\',
|
||||
Digit9_azerty: 'ç<br/>^',
|
||||
Digit0_azerty: 'à<br/>@',
|
||||
Minus_azerty: ')<br/>]',
|
||||
Equal_azerty: '=<br/>}',
|
||||
|
||||
// Row 2
|
||||
KeyA_azerty: 'A',
|
||||
KeyZ_azerty: 'Z',
|
||||
KeyE_azerty: 'E<br/>€',
|
||||
KeyR_azerty: 'R',
|
||||
KeyT_azerty: 'T',
|
||||
KeyY_azerty: 'Y',
|
||||
KeyU_azerty: 'U',
|
||||
KeyI_azerty: 'I',
|
||||
KeyO_azerty: 'O',
|
||||
KeyP_azerty: 'P',
|
||||
BracketLeft_azerty: '¨<br/>^',
|
||||
BracketRight_azerty: '£<br/>$',
|
||||
Backslash_azerty: 'µ<br/>*',
|
||||
|
||||
// Row 3
|
||||
KeyQ_azerty: 'Q',
|
||||
KeyS_azerty: 'S',
|
||||
KeyD_azerty: 'D',
|
||||
KeyF_azerty: 'F',
|
||||
KeyG_azerty: 'G',
|
||||
KeyH_azerty: 'H',
|
||||
KeyJ_azerty: 'J',
|
||||
KeyK_azerty: 'K',
|
||||
KeyL_azerty: 'L',
|
||||
Semicolon_azerty: 'M',
|
||||
Quote_azerty: '%<br/>ù',
|
||||
|
||||
// Row 4
|
||||
KeyW_azerty: 'W',
|
||||
KeyX_azerty: 'X',
|
||||
KeyC_azerty: 'C',
|
||||
KeyV_azerty: 'V',
|
||||
KeyB_azerty: 'B',
|
||||
KeyN_azerty: 'N',
|
||||
KeyM_azerty: '?<br/>,',
|
||||
Comma_azerty: '.<br/>;',
|
||||
Period_azerty: '/<br/>:',
|
||||
Slash_azerty: '§<br/>!'
|
||||
},
|
||||
// Enable layout-specific display
|
||||
mergeDisplay: true,
|
||||
layoutCandidates: {
|
||||
default: 'default',
|
||||
shift: 'shift',
|
||||
azerty: 'azerty'
|
||||
}
|
||||
// ...remaining options...
|
||||
};
|
||||
|
||||
// control keys
|
||||
|
||||
@@ -5,18 +5,21 @@ import { MouseIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as ls from '@/lib/localstorage';
|
||||
import { mouseModeAtom, mouseStyleAtom } from '@/jotai/mouse';
|
||||
import { mouseModeAtom, mouseStyleAtom, scrollIntervalAtom } from '@/jotai/mouse';
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { Cursor } from './cursor.tsx';
|
||||
import { HidMode } from './hid-mode.tsx';
|
||||
import { MouseMode } from './mouse-mode.tsx';
|
||||
import { ResetHid } from './reset-hid.tsx';
|
||||
import { Speed } from './speed.tsx';
|
||||
|
||||
export const Mouse = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setMouseStyle = useSetAtom(mouseStyleAtom);
|
||||
const setMouseMode = useSetAtom(mouseModeAtom);
|
||||
const setScrollInterval = useSetAtom(scrollIntervalAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const mouseStyle = ls.getMouseStyle();
|
||||
@@ -28,12 +31,18 @@ export const Mouse = () => {
|
||||
if (mouseMode) {
|
||||
setMouseMode(mouseMode);
|
||||
}
|
||||
|
||||
const interval = ls.getMouseScrollInterval();
|
||||
if (interval) {
|
||||
setScrollInterval(interval);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Cursor />
|
||||
<MouseMode />
|
||||
<Speed />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
|
||||
<HidMode />
|
||||
|
||||
66
web/src/pages/desktop/menu/mouse/speed.tsx
Normal file
66
web/src/pages/desktop/menu/mouse/speed.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Popover, Slider } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { GaugeIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { scrollIntervalAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
const MAX_INTERVAL = 300;
|
||||
|
||||
export const Speed = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [scrollInterval, setScrollInterval] = useAtom(scrollIntervalAtom);
|
||||
|
||||
const [scrollSpeed, setScrollSpeed] = useState(100);
|
||||
|
||||
useEffect(() => {
|
||||
const speed = interval2Speed(scrollInterval);
|
||||
setScrollSpeed(speed);
|
||||
}, [scrollInterval]);
|
||||
|
||||
function update(speed: number): void {
|
||||
const interval = speed2Interval(speed);
|
||||
setScrollInterval(interval);
|
||||
storage.setMouseScrollInterval(interval);
|
||||
}
|
||||
|
||||
function interval2Speed(interval: number) {
|
||||
if (interval === MAX_INTERVAL) {
|
||||
return 0;
|
||||
}
|
||||
return ((MAX_INTERVAL - interval) * 100) / MAX_INTERVAL;
|
||||
}
|
||||
|
||||
function speed2Interval(speed: number) {
|
||||
return MAX_INTERVAL - speed * (MAX_INTERVAL / 100);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className="h-[150px] w-[60px] py-3">
|
||||
<Slider
|
||||
vertical
|
||||
marks={{
|
||||
0: <span>{t('mouse.slow')}</span>,
|
||||
100: <span>{t('mouse.fast')}</span>
|
||||
}}
|
||||
range={false}
|
||||
included={false}
|
||||
step={10}
|
||||
defaultValue={scrollSpeed}
|
||||
onChange={update}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
|
||||
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
|
||||
<GaugeIcon size={18} />
|
||||
<span>{t('mouse.speed')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -32,7 +32,7 @@ export const Reset = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPcie ? (
|
||||
{isPcie && (
|
||||
<div
|
||||
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70"
|
||||
onClick={reset}
|
||||
@@ -43,8 +43,6 @@ export const Reset = () => {
|
||||
/>
|
||||
<span className="select-none text-sm">{t('screen.resetHdmi')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
77
web/src/pages/desktop/menu/settings/device/hdmi.tsx
Normal file
77
web/src/pages/desktop/menu/settings/device/hdmi.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/vm.ts';
|
||||
import { isHdmiEnabledAtom } from '@/jotai/screen.ts';
|
||||
|
||||
export const Hdmi = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isHdmiEnabled, setIsHdmiEnabled] = useAtom(isHdmiEnabledAtom);
|
||||
|
||||
const [isPcie, setIsPcie] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getHardware();
|
||||
getHdmiState();
|
||||
}, []);
|
||||
|
||||
async function getHardware() {
|
||||
const rsp = await api.getHardware();
|
||||
if (rsp.code !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPcie(rsp.data?.version === 'PCIE');
|
||||
}
|
||||
|
||||
async function getHdmiState() {
|
||||
setIsLoading(true);
|
||||
|
||||
const rsp = await api.getHdmiState();
|
||||
if (rsp.code === 0) {
|
||||
setIsHdmiEnabled(rsp.data.enabled);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function setHdmiState() {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
const enabled = !isHdmiEnabled;
|
||||
|
||||
const rsp = await api.setHdmiState(enabled);
|
||||
if (rsp.code !== 0) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setIsHdmiEnabled(enabled);
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPcie && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span>HDMI</span>
|
||||
|
||||
<span className="text-xs text-neutral-500">
|
||||
{t('settings.device.hdmi.description')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Switch checked={isHdmiEnabled} loading={isLoading} onChange={setHdmiState} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import * as api from '@/api/hid.ts';
|
||||
import { hidModeAtom } from '@/jotai/mouse.ts';
|
||||
|
||||
import { Advanced } from './advanced';
|
||||
import { Hdmi } from './hdmi.tsx';
|
||||
import { HidMode } from './hid-mode.tsx';
|
||||
import { Mdns } from './mdns.tsx';
|
||||
import { MouseJiggler } from './mouse-jiggler.tsx';
|
||||
@@ -39,6 +40,7 @@ export const Device = () => {
|
||||
<Tls />
|
||||
<Ssh />
|
||||
<Mdns />
|
||||
<Hdmi />
|
||||
|
||||
{hidMode === 'normal' ? <VirtualDevices /> : <HidMode />}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { scrollIntervalAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { MouseButton, MouseEvent } from './constants';
|
||||
|
||||
export const Absolute = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const scrollInterval = useAtomValue(scrollIntervalAtom);
|
||||
|
||||
const lastScrollTimeRef = useRef(0);
|
||||
|
||||
// listen mouse events
|
||||
useEffect(() => {
|
||||
@@ -74,6 +78,12 @@ export const Absolute = () => {
|
||||
const delta = Math.floor(event.deltaY);
|
||||
if (delta === 0) return;
|
||||
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
|
||||
return;
|
||||
}
|
||||
lastScrollTimeRef.current = currentTime;
|
||||
|
||||
const data = [2, MouseEvent.Scroll, 0, 0, delta];
|
||||
client.send(data);
|
||||
}
|
||||
@@ -82,10 +92,11 @@ export const Absolute = () => {
|
||||
canvas.removeEventListener('mousemove', handleMouseMove);
|
||||
canvas.removeEventListener('mousedown', handleMouseDown);
|
||||
canvas.removeEventListener('mouseup', handleMouseUp);
|
||||
canvas.removeEventListener('wheel', handleWheel);
|
||||
canvas.removeEventListener('click', disableEvent);
|
||||
canvas.removeEventListener('contextmenu', disableEvent);
|
||||
};
|
||||
}, [resolution]);
|
||||
}, [resolution, scrollInterval]);
|
||||
|
||||
// disable default events
|
||||
function disableEvent(event: any) {
|
||||
|
||||
@@ -4,19 +4,21 @@ import { useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { scrollIntervalAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom } from '@/jotai/screen.ts';
|
||||
|
||||
import { MouseButton, MouseEvent } from './constants';
|
||||
|
||||
export const Relative = () => {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const scrollInterval = useAtomValue(scrollIntervalAtom);
|
||||
|
||||
const isLockedRef = useRef(false);
|
||||
const buttonRef = useRef<MouseButton>(MouseButton.None);
|
||||
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const lastScrollTimeRef = useRef(0);
|
||||
|
||||
// listen mouse events
|
||||
useEffect(() => {
|
||||
@@ -113,6 +115,12 @@ export const Relative = () => {
|
||||
const delta = Math.floor(event.deltaY);
|
||||
if (delta === 0) return;
|
||||
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
|
||||
return;
|
||||
}
|
||||
lastScrollTimeRef.current = currentTime;
|
||||
|
||||
const data = [2, MouseEvent.Scroll, 0, 0, delta];
|
||||
client.send(data);
|
||||
}
|
||||
@@ -124,9 +132,10 @@ export const Relative = () => {
|
||||
canvas.removeEventListener('mousemove', handleMouseMove);
|
||||
canvas.removeEventListener('mousedown', handleMouseDown);
|
||||
canvas.removeEventListener('mouseup', handleMouseUp);
|
||||
canvas.removeEventListener('wheel', handleWheel);
|
||||
canvas.removeEventListener('contextmenu', disableEvent);
|
||||
};
|
||||
}, [resolution]);
|
||||
}, [resolution, scrollInterval]);
|
||||
|
||||
// disable default events
|
||||
function disableEvent(event: any) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { w3cwebsocket as W3cWebSocket } from 'websocket';
|
||||
import Queue from 'yocto-queue';
|
||||
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse';
|
||||
@@ -11,11 +12,13 @@ export const H264Direct = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
|
||||
const canvasRef = useRef<any>(null);
|
||||
const decoderRef = useRef<any>(null);
|
||||
const frameQueueRef = useRef<VideoFrame[]>([]);
|
||||
const canvasRef = useRef<any>();
|
||||
const renderingRef = useRef(false);
|
||||
const decoderRef = useRef<VideoDecoder | null>(null);
|
||||
|
||||
const frameQueue = new Queue<VideoFrame>();
|
||||
|
||||
// init websocket
|
||||
useEffect(() => {
|
||||
if (!window.VideoDecoder) {
|
||||
console.log('Error: WebCodecs API not supported.');
|
||||
@@ -42,11 +45,9 @@ export const H264Direct = () => {
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.log(err);
|
||||
ws.onerror = () => {
|
||||
resetDecoder();
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
resetDecoder();
|
||||
};
|
||||
@@ -59,6 +60,7 @@ export const H264Direct = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// init video decoder
|
||||
function initializeDecoder() {
|
||||
if (!window.VideoDecoder) {
|
||||
return;
|
||||
@@ -69,7 +71,11 @@ export const H264Direct = () => {
|
||||
|
||||
const init = {
|
||||
output: (frame: VideoFrame) => {
|
||||
frameQueueRef.current.push(frame);
|
||||
frameQueue.enqueue(frame);
|
||||
if (frameQueue.size >= 10) {
|
||||
frameQueue.dequeue()?.close();
|
||||
}
|
||||
|
||||
if (!renderingRef.current) {
|
||||
requestAnimationFrame(processFrameQueue);
|
||||
}
|
||||
@@ -94,6 +100,7 @@ export const H264Direct = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// decode video chunk
|
||||
function decode(message: any) {
|
||||
const byteString = atob(message.data);
|
||||
const byteArray = new Uint8Array(byteString.length);
|
||||
@@ -108,7 +115,7 @@ export const H264Direct = () => {
|
||||
});
|
||||
|
||||
try {
|
||||
decoderRef.current.decode(chunk);
|
||||
decoderRef.current?.decode(chunk);
|
||||
} catch (err: any) {
|
||||
if (err.name === 'TypeError' || err.message.includes('configured')) {
|
||||
resetDecoder();
|
||||
@@ -116,22 +123,18 @@ export const H264Direct = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const processFrameQueue = () => {
|
||||
function processFrameQueue() {
|
||||
renderingRef.current = true;
|
||||
const frame = frameQueueRef.current.shift();
|
||||
|
||||
const frame = frameQueue.dequeue();
|
||||
if (frame) {
|
||||
renderFrame(frame);
|
||||
}
|
||||
|
||||
if (frameQueueRef.current.length === 0) {
|
||||
renderingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(processFrameQueue);
|
||||
};
|
||||
}
|
||||
|
||||
const renderFrame = (frame: any) => {
|
||||
function renderFrame(frame: VideoFrame) {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx) {
|
||||
@@ -146,9 +149,10 @@ export const H264Direct = () => {
|
||||
|
||||
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
||||
frame.close();
|
||||
};
|
||||
}
|
||||
|
||||
const resetDecoder = () => {
|
||||
// reset video decoder
|
||||
function resetDecoder() {
|
||||
if (decoderRef.current && decoderRef.current.state !== 'closed') {
|
||||
try {
|
||||
decoderRef.current.close();
|
||||
@@ -160,9 +164,8 @@ export const H264Direct = () => {
|
||||
decoderRef.current = null;
|
||||
renderingRef.current = false;
|
||||
|
||||
frameQueueRef.current.forEach((frame) => frame.close());
|
||||
frameQueueRef.current = [];
|
||||
};
|
||||
Array.from(frameQueue.drain()).forEach((frame) => frame.close());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
|
||||
|
||||
@@ -9,6 +9,8 @@ import '@xterm/xterm/css/xterm.css';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { Head } from '@/components/head.tsx';
|
||||
|
||||
import { validatePicocomParameters } from './validater.ts';
|
||||
|
||||
export const Terminal = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -54,7 +56,13 @@ export const Terminal = () => {
|
||||
const stopBits = searchParams.get('stopBits');
|
||||
if (!port || !baud) return;
|
||||
|
||||
ws.send(`picocom ${port} --baud ${baud} --parity ${parity} --flow ${flowControl} --databits ${dataBits} --stopbits ${stopBits}\r`);
|
||||
if (!validatePicocomParameters({ port, baud, parity, flowControl, dataBits, stopBits })) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(
|
||||
`picocom ${port} --baud ${baud} --parity ${parity} --flow ${flowControl} --databits ${dataBits} --stopbits ${stopBits}\r`
|
||||
);
|
||||
};
|
||||
|
||||
const resizeScreen = () => {
|
||||
|
||||
120
web/src/pages/terminal/validater.ts
Normal file
120
web/src/pages/terminal/validater.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
type PicocomParameters = {
|
||||
port: string | null;
|
||||
baud: string | null;
|
||||
parity: string | null;
|
||||
flowControl: string | null;
|
||||
dataBits: string | null;
|
||||
stopBits: string | null;
|
||||
};
|
||||
|
||||
const ALLOWED_BAUD_RATES = [
|
||||
'50',
|
||||
'75',
|
||||
'110',
|
||||
'134',
|
||||
'150',
|
||||
'200',
|
||||
'300',
|
||||
'600',
|
||||
'1200',
|
||||
'1800',
|
||||
'2400',
|
||||
'4800',
|
||||
'9600',
|
||||
'19200',
|
||||
'38400',
|
||||
'57600',
|
||||
'115200',
|
||||
'230400',
|
||||
'460800',
|
||||
'500000',
|
||||
'576000',
|
||||
'921600',
|
||||
'1000000',
|
||||
'1152000',
|
||||
'1500000',
|
||||
'2000000',
|
||||
'2500000',
|
||||
'3000000',
|
||||
'3500000',
|
||||
'4000000'
|
||||
];
|
||||
const ALLOWED_PARITIES = ['n', 'o', 'e', 'm', 's', 'none', 'even', 'odd'];
|
||||
const ALLOWED_FLOW_CONTROLS = ['n', 'h', 's', 'x', 'none', 'soft', 'hard'];
|
||||
const ALLOWED_DATA_BITS = ['5', '6', '7', '8'];
|
||||
const ALLOWED_STOP_BITS = ['1', '2'];
|
||||
|
||||
const PROHIBITED_PORT_CHARS_REGEX = /[;&|`$()<>!*?"'\s\\]/;
|
||||
|
||||
export function validatePicocomParameters(params: PicocomParameters) {
|
||||
if (!params.port || params.port.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const port = params.port.trim();
|
||||
if (!port.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
if (port.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
if (PROHIBITED_PORT_CHARS_REGEX.test(port)) {
|
||||
return false;
|
||||
}
|
||||
if (!/^\/dev\/[a-zA-Z0-9.-_]+$/.test(port)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (params.baud !== undefined && params.baud !== null && String(params.baud).trim() !== '') {
|
||||
const baudStr = String(params.baud).trim();
|
||||
if (!ALLOWED_BAUD_RATES.includes(baudStr)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
params.parity !== undefined &&
|
||||
params.parity !== null &&
|
||||
String(params.parity).trim() !== ''
|
||||
) {
|
||||
const parityStr = String(params.parity).trim().toLowerCase();
|
||||
if (!ALLOWED_PARITIES.includes(parityStr)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
params.flowControl !== undefined &&
|
||||
params.flowControl !== null &&
|
||||
String(params.flowControl).trim() !== ''
|
||||
) {
|
||||
const flowStr = String(params.flowControl).trim().toLowerCase();
|
||||
if (!ALLOWED_FLOW_CONTROLS.includes(flowStr)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
params.dataBits !== undefined &&
|
||||
params.dataBits !== null &&
|
||||
String(params.dataBits).trim() !== ''
|
||||
) {
|
||||
const dataBitsStr = String(params.dataBits).trim();
|
||||
if (!ALLOWED_DATA_BITS.includes(dataBitsStr)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
params.stopBits !== undefined &&
|
||||
params.stopBits !== null &&
|
||||
String(params.stopBits).trim() !== ''
|
||||
) {
|
||||
const stopBitsStr = String(params.stopBits).trim();
|
||||
if (!ALLOWED_STOP_BITS.includes(stopBitsStr)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user