Merge branch 'main' into usb-disable

This commit is contained in:
wenjie
2025-12-01 17:51:28 +08:00
committed by GitHub
105 changed files with 6508 additions and 2432 deletions

View File

@@ -1,3 +1,55 @@
## 2.3.0 [0e30a26](https://github.com/sipeed/NanoKVM/commit/0e30a26db42cbc08416662b49a88a5d16ad93424) (2025-11-26)
### Features
* Added an EDID editor in the terminal with a built-in 1080P EDID template
* Added German virtual keyboard and German clipboard support (thanks to [@Alexander-Ger-Reich](https://github.com/Alexander-Ger-Reich))
* Resetting the HID also resets the USB hardware
* Added support for deleting ISO images
### Refactoring & Improvements
* Video (H.264 WebRTC): Refactored the module to significantly reduce video latency
* Video (H.264 Direct): Refactored to optimize data transmission and support data parsing even when the page is in the background
* Video (MJPEG): Refactored and ensure correct data length transmission
* Added locking before resetting USB to prevent HID deadlock (thanks to [@scpcom](https://github.com/scpcom))
* Optimized HID writing logic
* Made HDMI toggle state persist across reboots (thanks to [@tpretz](https://github.com/tpretz))
* Enhanced the paste feature with browser clipboard API integration (thanks to [@patrickpilon](https://github.com/patrickpilon))
* Set the inquiry string for the virtual storage device (thanks to [@scpcom](https://github.com/scpcom))
* Hostname now updates `/etc/hosts` simultaneously and takes effect immediately (thanks to [@scpcom](https://github.com/scpcom))
* Improved compatibility for static IP configuration on Windows
* Optimized the web page title update logic
* Added a confirmation dialog when uninstalling Tailscale
* Improved UI for Clipboard, Image Mounting, and settings pages
### Bug Fixes
* Tailscale: Fixed an issue where Tailscale would turn the device into a router
* Tailscale: Fixed an issue where Tailscale would disable IPv6
* Tailscale: Added a swap memory option to prevent Tailscale Out-Of-Memory errors
* Tailscale: Fixed Tailscale accept-dns configuration (thanks to [@lazydba247](https://github.com/lazydba247))
* Fixed an issue where certain modifier keys were not recognized
* Fixed vertical mouse drift when the page is zoomed in or out
### Localization
* Update Korean translation (thanks to [@xenix4845](https://github.com/xenix4845))
* Update Traditional Chinese translation (thanks to [@protonchang](https://github.com/protonchang))
* Add Brazilian Portuguese translation (thanks to [@chiconws](https://github.com/chiconws) and [@Luccas-LF](https://github.com/Luccas-LF))
* Add Swedish translation (thanks to [@acidflash](https://github.com/acidflash))
* Add Catalan translation (thanks to [@Zagur](https://github.com/Zagur))
* Add Turkish translation (thanks to [@Keylem](https://github.com/Keylem))
### Security
* Implemented a delay after failed login attempts to mitigate brute-force attacks
* Upgraded dependencies to fix known security vulnerabilities
## 2.2.9 [c77981c](https://github.com/sipeed/NanoKVM/commit/c77981cc0ceebd8f6705b6c5d8c3cf4edf4f6717) (2025-06-13)
* fix(security): resolve parameter injection in serial port terminal
## 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))

193
README.md
View File

@@ -1,92 +1,88 @@
NanoKVM
======
# NanoKVM
<div align="center">
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<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>
</h3>
English | [中文](./README_ZH.md) | [日本語](./README_JA.md)
<br>
<img src="https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png" alt="NanoKVM" style="margin: 20px 0;">
<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 Details</a>
|
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 Firmware Releases</a>
</h3>
<br>
</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 based on 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 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-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.
* **NanoKVM-Cube Lite:** A barebones kit for DIY enthusiasts and enterprise users requiring bulk deployment.
* **NanoKVM-Cube Full:** A complete package featuring 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. It draws power directly from the PCIe slot and supports optional WiFi and PoE functions.
* **[NanoKVM-Pro:](https://github.com/sipeed/NanoKVM-Pro)** Based on popular demand, the **Pro version** delivers significant performance upgrades:
* **Resolution:** Upgraded from 1080P to stunning **4K@30fps / 2K@60fps**.
* **Network:** Blazing fast **1Gbps Ethernet + PoE + WiFi 6** ((upgraded from 100Mbps).
* **Latency:** Encoder accelerated latency reduced from 100-150ms to **50-100ms**.
<div align="center">
![NanoKVM-Cube](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_1.jpg)
![NanoKVM-PCIe](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM-PCIe.png)
<img src="https://cdn.sipeed.com/public/nanokvm-products.jpg" alt="NanoKVM Product Family" width="80%" style="margin: 20px 0;">
</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!
> If you are looking for a USB-based KVM solution, check out [NanoKVM-USB](https://github.com/sipeed/NanoKVM-USB).
## Project Structure
## 🛠️ Technical Specifications
| Feature | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
| Core | AX630C 2xA53 1.2G | 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 | 2 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.6A@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 |
## 📂 Project Structure
``` shell
├── kvmapp # APP update package
│ ├── jpg_stream # Compatible for direct updates from very old versions
│ ├── kvm_new_app # Triggers necessary components for kvm_system update
│ ├── kvm_system # kvm_system application
│ ├── server # NanoKVM front-end and back-end applications
│ └── system # Necessary system components
├── web # NanoKVM front-end
├── server # NanoKVM back-end
├── support # Auxiliary functions (image subsystem, system status, system updates, screen, keys, etc.)
│ ├── sg2002 # NanoKVM-Lite/Full/PCIe
│ └── h618 # NanoKVM-Pro
│ ├── jpg_stream # Legacy support for direct updates from older versions
│ ├── kvm_new_app # Triggers components for kvm_system updates
│ ├── kvm_system # Core KVM application
│ ├── server # Front-end and back-end integration
│ └── system # Essential system components
├── web # NanoKVM Front-end (UI)
├── server # NanoKVM Back-end (Service)
├── support # Auxiliary modules (Image subsystem, status, updates, OLED, HID, etc.)
├── ...
```
## Tech Specs
## 🔩 Hardware Platform (NanoKVM Cube/PCIe)
| 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 | 90230ms | 90230ms | 100230ms |
| UEFI/BIOS | ✓ | ✓ | ✓ |
| Virtual HID | ✓ | ✓ | ✓ |
| Virtual CD-ROM | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ |
| Wake-on-LAN | ✓ | ✓ | ✓ |
| ETH | 100M/10M | 100M/10M | 1000M/100M/10M |
| ATX power control | NoneUsers can connect by their own | USB interface IO control board | RJ-45 interface IO control board |
| OLED | NoneUsers 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 |
![NanoKVM PCB Pinout](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
## Hardware platform NanoKVM
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).
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 in the [download station](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano).
The NanoKVM Lite is constructed by the LicheeRV Nano and HDMItoCSI board, and the NanoKVM FULL adds the NanoKVM-A/B board and shell to the NanoKVM Lite. The HDMItoCSI board is used to convert the HDMI signal; NanoKVM-A, including OLED, ATX control output (USB Type-C interface), auxiliary power supply and ATX power on/off and reset buttons; The NanoKVM-B is connected to the plate at one end and the computer at the other end is connected to the computer ATX-Pin, which is used to remotely control the power of the computer.
@@ -98,58 +94,37 @@ 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
<div align="center">
<img src="https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg" alt="NanoKVM PCB Pinout" width="80%" style="margin: 20px 0;">
</div>
**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)
* [Discord](https://discord.gg/V4sAZ9XWpN)
* QQ group: 703230713
* email: [support@sipeed.com](mailto:support@sipeed.com)
* [FAQ](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/faq.html)
## License
## 📜 License
This project is licensed under the GPL-3.0 License - see the LICENSE file for details.

View File

@@ -1,129 +0,0 @@
NanoKVM
======
<div align="center">
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<h3>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html"> クイックスタート </a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM"> ハードウェア </a>
</h3>
[English](./README.md) | [中文](./README_ZH.md) | 日本語
</div>
> NanoKVM は [RISC-V](https://en.wikipedia.org/wiki/RISC-V) を搭載しています!
> 使用中に問題や提案がある場合は、ここで issue を提出するか、[MaixHub Discussion](https://maixhub.com/discussion/nanokvm) でお知らせください。
## オープンソース & コントリビューション
### 2025.02.19 更新
NanoKVM のすべてのコンポーネントはオープンソース化されており、[front-end](https://github.com/sipeed/NanoKVM/tree/main/web)、[back-end](https://github.com/sipeed/NanoKVM/tree/main/server)、[kvm_vision](https://github.com/sipeed/NanoKVM/tree/main/vision/components/kvm)、[kvm_mmf](https://github.com/sipeed/NanoKVM/tree/main/vision/components/kvm_mmf)、[kvm_system](https://github.com/sipeed/NanoKVM/tree/main/support)、[kvmapp update package](https://github.com/sipeed/NanoKVM-System/tree/main/kvmapp)、[system sdk](https://github.com/sipeed/LicheeRV-Nano-Build/tree/NanoKVM)、[packaging methods](https://github.com/sipeed/LicheeRV-Nano-Build/blob/NanoKVM/kvm/NanoKVM_img.sh)が含まれます。
### 2025.02.14 更新
最新のアプリケーションバージョン v2.1.6 には、多くのセキュリティ強化とバグ修正が追加されているため、一般のユーザーはこのバージョン以降へのアップグレードをお勧めします。
### 2025.02.05 更新
セキュリティを懸念しているユーザーは、この issue をお読みください: <https://github.com/sipeed/NanoKVM/issues/301>、セキュリティに関するすべての懸念事項について説明されています。
GitHub は、オープンで透明性のあるプラットフォームとして、いわゆる「バックドア」の懸念事項を議論する場を提供しています。オープンソースであることにより、製品のセキュリティがさらに強化されます!
### 2024.10.18 更新
10/08にバックエンドコードをオープンソース化しました。すぐにPRを提出してくれたCivilに感謝します。最新のNanoKVM-PCIeを報酬として提供します
10月中旬のバッチはテストとパッケージングを開始しており、10/01以前のほとんどの注文は次週と次々週に出荷されます。
10/01以降、アリエクスプレスの出荷日は12月に変更されました。これは保守的な納期です。10月の注文は11/15〜12/15の間に発送される予定です。
## 紹介
Lichee NanoKVMは、LicheeRV NanoをベースにしたIP-KVM製品で、LicheeRV Nanoの極小サイズと強力な機能を継承しています。
Lichee NanoKVMは2つのバージョンがあります
NanoKVM Liteは基本構成で、一定のDIY能力を持つ個人ユーザーや大量のニーズを持つ企業ユーザーに適しています。
NanoKVM Fullは完全版で、洗練されたケースと完全なアクセサリーを備え、起動時にすぐに使用できるシステムミラーカードが内蔵されており、個人ユーザーに推奨されます。
![NanoKVM 分解画像](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_1.jpg)
## 技術仕様
| 製品 | 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 |
| ビデオ遅延 | 90230ms | 90230ms | 100230ms |
| UEFI/BIOS | ✓ | ✓ | ✓ |
| 仮想HID | ✓ | ✓ | ✓ |
| 仮想CD-ROM | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ |
| Wake-on-LAN | ✓ | ✓ | ✓ |
| ETH | 100M/10M | 100M/10M | 1000M/100M/10M |
| ATX電源制御 | なし、ユーザーが自分で接続 | USBインターフェースIO制御ボード | RJ-45インターフェースIO制御ボード |
| OLED | なし、ユーザーが自分で接続 | 128x64 0.96" 白 | 128x32 0.91" 白 |
| UART | 2 | 2 | 1 |
| TFカード | なし | ✓ | ✓ |
| 拡張 | なし | PoE | WiFi/LTE |
| 消費電力 | 0.2A@5V | 0.2A@5V | ピーク時 2.6A@5V |
| 電源入力 | PC USBで給電可能 | PC USBまたは補助電源 | DC 5V 3A電源が必要 |
| 冷却 | 静音ファンレス | 静音ファンレス | ファン冷却 |
| サイズ | 23x37x15mm 1/30 PiKVM V4サイズ | 40x36x36mm 1/7 PiKVM V4サイズ | 120x68x44mm |
![NanoKVM PCB ピン配置](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
## NanoKVM ハードウェアプラットフォーム
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)で確認できます。
NanoKVM LiteはLicheeRV NanoとHDMItoCSIボードで構成され、NanoKVM FULLはNanoKVM LiteにNanoKVM-A/Bボードとシェルを追加しています。HDMItoCSIボードはHDMI信号を変換するために使用されます。NanoKVM-AにはOLED、ATX制御出力USB Type-Cインターフェース、補助電源供給およびATX電源オン/オフおよびリセットボタンが含まれています。NanoKVM-Bは一端がAボードに接続され、他端がコンピュータのATXピンに接続され、コンピュータの電源をリモートで制御します。
NanoKVMイメージはLicheeRV Nano SDKおよびMaixCDKに基づいて構築されており、LicheeRV Nanoを使用する資料と互換性があります。逆に、KVMソフトウェアはLicheeRV Nanoや他のSG2002製品では使用できません。LicheeRV NanoやMaixCamでHDMI入力アプリケーションを構築したい場合は、技術サポートを受けるためにお問い合わせください。
SG2002の256MBメモリのうち、現在158MBがマルチメディアサブシステムに割り当てられており、NanoKVMはビデオ画像の取得と処理にこのメモリを使用します。
+ [NanoKVM-A 回路図](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
+ [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)
## ロードマップ
### Q1 2025
**改善:**
- [x] HDMI モジュールをリファクタリングし、libmaixcam_lib.so の依存関係を削除
- [x] SSH をデフォルトで無効にし、Web UI に有効/無効の切り替えを追加
- [x] Tailscale をデフォルトで無効にし、Web UI に有効/無効の切り替えを追加
- [x] 設定ファイルに JWT 設定オプションを追加
- [x] bcrypt を利用したパスワードストレージのセキュリティ保護
**配布:**
- [x] GitHub 経由でのアプリケーション配布
- [x] オンラインでの更新の際に整合性チェックを実施
- [ ] オフラインでの更新をサポート
**バグ修正:**
- [x] DNS の問題を修正
- [x] CSRF の脆弱性を修正
**機能**
- [ ] [79](https://github.com/sipeed/NanoKVM/issues/79) Zerotier をサポート
- [ ] [99](https://github.com/sipeed/NanoKVM/issues/99) WireGuard をサポート
- [ ] [249](https://github.com/sipeed/NanoKVM/issues/249) Mouse Jiggler を追加
## 購入方法
* [Aliexpress(グローバル、米国とロシアを除く)](https://www.aliexpress.com/item/1005007369816019.html)
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
* [予約注文 (淘宝とアリエクスプレスがサポートされていない国)](https://sipeed.com/nanokvm)
## コミュニティ
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
* QQグループ: 703230713

View File

@@ -1,111 +0,0 @@
NanoKVM
======
<div align="center">
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<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>
</h3>
[English](./README.md) | 中文 | [日本語](./README_JA.md)
</div>
> Your NanoKVM Power by RISC-V !
> 如果您在使用过程中有任何问题或建议,请在这里提交 issue 或在 [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)告诉我们.
## 简介
Lichee NanoKVM 是基于 LicheeRV Nano 的 IP-KVM 产品,继承了 LicheeRV Nano的极致体积 和 强大功能。
Lichee NanoKVM 目前有三个版本:
* NanoKVM-Cube Lite 为基础版配置,适合 具有一定DIY能力的个人用户 和 有批量需求的企业用户。
* NanoKVM-Cube Full 为完整版配置,带精致外壳和完整配件,内置开机即用的系统镜像卡,推荐个人用户购买。
* NanoKVM-PCle 为全新形态,内置 PCle 挡板可固定在机箱内部。NanoKVM-PCle 在 NanoKVM-Cube 基础上增加了可选的 WiFi 和 PoE 功能;拥有自家的 PCle 插槽,可从主板 PCle 插槽取电另外有线连接ETH也更加稳定满足更多专业需求。
<div align="center">
![NanoKVM-Cube](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_1.jpg)
![NanoKVM-PCIe](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM-PCIe.png)
</div>
> 对于关注安全的用户,可以阅读此问题 [#301](https://github.com/sipeed/NanoKVM/issues/301)您会找到有关所有安全问题的解释。GitHub 作为一个开放和透明的平台,为讨论所谓的“后门”担忧提供了空间。开源使产品更加安全!
## 项目结构
``` shell
├── kvmapp # APP更新包
│ ├── jpg_stream # 兼容从非常老的版本中直接更新
│ ├── kvm_new_app # 触发 kvm_system 更新必要组件
│ ├── kvm_system # kvm_system 应用
│ ├── server # NanoKVM 前后端应用
│ └── system # 必要系统组件
├── server # NanoKVM 后端
├── support # 辅助功能(图像子系统、系统状态、系统更新、屏幕、按键……)
│ ├── sg2002 # NanoKVM-Lite/Full/PCIe
│ └── h618 # NanoKVM-Pro
├── web # NanoKVM 前端
├── LICENSE
├── README_JA.md
├── README.md
├── README_ZH.md
└── 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 |
| 视频延迟 | 90230ms | 90230ms | 100230ms |
| 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 |
![](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
## NanoKVM 硬件平台
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)
NanoKVM Lite 由 LicheeRV Nano E 和 HDMItoCSI 小板构成NanoKVM FULL 在 NanoKVM Lite 基础上增加 NanoKVM-A/B 板和外壳。HDMItoCSI板用于转换HDMI信号NanoKVM-A 包含 OLED、ATX控制输出TypeC接口形式、辅助供电TypeC接口以及ATX开关机、复位按键NanoKVM-B 一端连接A板一端连接电脑ATX针脚用于电脑的远程开关机。
NanoKVM 镜像在LicheeRV Nano SDK 和 MaixCDK 基础上构建,可以兼容使用 LicheeRV Nano 的资料反之LicheeRV Nano 或其他 SG2002 产品无法使用KVM软件。如果您想在 LicheeRV Nano 或 MaixCam 上构建 HDMI输入相关应用请与我们联系以获得技术支持。
注: SG2002的256MB内存中, 目前划分105MB用于多媒体子系统, NanoKVM会在视频图像采集和处理中使用这部分内存.
* [NanoKVM-A 原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
* [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)
## 购买渠道
* [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

View File

@@ -9,8 +9,20 @@ start_usb_dev(){
mkdir g0
cd g0
echo 0x3346 > idVendor
echo 0x1009 > idProduct
# echo 0x3346 > idVendor
# echo 0x1009 > idProduct
if [ -e /boot/usb.vid ]
then
cat /boot/usb.vid > idVendor
else
echo 0x3346 > idVendor
fi
if [ -e /boot/usb.pid ]
then
cat /boot/usb.pid > idProduct
else
echo 0x1009 > idProduct
fi
mkdir strings/0x409
echo '0123456789ABCDEF' > strings/0x409/serialnumber
echo 'sipeed' > strings/0x409/manufacturer
@@ -82,6 +94,7 @@ start_usb_dev(){
ln -s functions/hid.GS2 configs/c.1
fi
# dedicated for media
if [ -e /boot/usb.disk0 ]
then
mkdir functions/mass_storage.disk0
@@ -92,6 +105,7 @@ start_usb_dev(){
echo 1 > functions/mass_storage.disk0/lun.0/ro
echo 0 > functions/mass_storage.disk0/lun.0/cdrom
fi
echo "NanoKVM USB Mass Storage0520" > functions/mass_storage.disk0/lun.0/inquiry_string
disk=$(cat /boot/usb.disk0)
if [ -z "${disk}" ]
then

View File

@@ -11,8 +11,20 @@ start_usb_dev(){
echo 0x0101 > bcdUSB
echo 0x0623 > bcdDevice
echo 0x3346 > idVendor
echo 0x1009 > idProduct
# echo 0x3346 > idVendor
# echo 0x1009 > idProduct
if [ -e /boot/usb.vid ]
then
cat /boot/usb.vid > idVendor
else
echo 0x3346 > idVendor
fi
if [ -e /boot/usb.pid ]
then
cat /boot/usb.pid > idProduct
else
echo 0x1009 > idProduct
fi
mkdir strings/0x409
# echo '0' > strings/0x409/serialnumber
echo 'sipeed' > strings/0x409/manufacturer

View File

@@ -1,119 +1,67 @@
#!/bin/sh
. /etc/profile
RESERVE_INET="192.168.90.1/22"
# /boot/eth.nodhcp for example
# ipaddr/net gw[optional]
# 192.168.0.101/24 192.168.0.1
# 192.168.3.116/22
clean_line() {
echo "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/\r//g'
}
RESERVE_INET="192.168.0.1/24"
calc_gw() {
addr="$1"
netid="$2"
IFS=. read a b c d <<EOF
$addr
EOF
ipnum=$(( (a<<24) + (b<<16) + (c<<8) + d ))
mask=$(( 0xFFFFFFFF << (32-netid) & 0xFFFFFFFF ))
gwnum=$(( (ipnum & mask) | 1 ))
echo "$(( (gwnum>>24)&255 )).$(( (gwnum>>16)&255 )).$(( (gwnum>>8)&255 )).$(( gwnum&255 ))"
}
start() {
printf "start ethernet: "
if [ -e /boot/eth.nodhcp ]
then
[ -e /boot/eth.nodhcp ] &&
cat /boot/eth.nodhcp | while read inet gw
do
addr=${inet%/*}
netid=${inet#*/}
[ -z $gw ] &&
gw=$( echo $addr| ( IFS='.' read a b c d; echo $((
(((((($a<<8)+$b)<<8)+$c)<<8)+$d)
& (((1<<$netid)-1)<<(32-$netid))
))
)) &&
gw=$(($gw>>24&0xff)).$(($gw>>16&0xff)).$(($gw>>8&0xff)).$((1+( $gw>>0&0xff )))
if [ -e /boot/eth.nodhcp ]; then
while IFS= read -r line || [ -n "$line" ]; do
line=$(clean_line "$line")
[ -z "$line" ] && continue
set -- $line
inet="$1"
gw="$2"
[ -z "$inet" ] && continue
addr=${inet%/*}
netid=${inet#*/}
[ -z "$gw" ] && gw=$(calc_gw "$addr" "$netid")
arping -Dqc2 -Ieth0 "$addr" || continue
ip a add "$inet" brd + dev eth0
ip r add default via "$gw" dev eth0
echo -e "nameserver $gw" >> /etc/resolv.conf
break
done < /boot/eth.nodhcp
arping -Dqc2 -Ieth0 $addr || continue
ip a add $inet brd + dev eth0
ip r add default via $gw dev eth0
cp -f /etc/resolv.conf /etc/resolv.conf.old
cat > /etc/resolv.conf << EOF
nameserver $gw
nameserver 8.8.4.4
nameserver 8.8.8.8
nameserver 114.114.114.114
nameserver 119.29.29.29
nameserver 223.5.5.5
EOF
break
done &&
ip a show dev eth0|grep inet || (
udhcpc -i eth0 -t 3 -T 1 -A 5 -b -p /run/udhcpc.eth0.pid &>/dev/null
ip a show dev eth0|grep inet
) || (
# failed to apply dynamic addr, need a available static addr to visit the LAN
inet=$RESERVE_INET
addr=${inet%/*}
ip a add $inet brd + dev eth0
) || exit 1
ip a show dev eth0 | grep inet > /dev/null || {
udhcpc -i eth0 -t 3 -T 1 -A 5 -b -p /run/udhcpc.eth0.pid &>/dev/null
ip a show dev eth0 | grep inet > /dev/null
} || {
inet=$RESERVE_INET
addr=${inet%/*}
ip a add "$inet" brd + dev eth0
} || exit 1
else
(udhcpc -i eth0 -t 10 -T 1 -A 5 -b -p /run/udhcpc.eth0.pid) &
udhcpc -i eth0 -t 10 -T 1 -A 5 -b -p /run/udhcpc.eth0.pid &
fi
echo "OK"
}
stop() {
[[ ! -e "/run/udhcpc.eth0.pid" ]] && echo "udhcpc is not running..." && exit 1
kill `cat /run/udhcpc.eth0.pid`
[ ! -e "/run/udhcpc.eth0.pid" ] && exit 1
kill "$(cat /run/udhcpc.eth0.pid)"
rm /run/udhcpc.eth0.pid
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart|reload)
$0 stop
$0 start
;;
doc)
cat <<EOF
If you want to set your own static ip address
rather then fetch it by dhcp.
Then you need to create a file \`/boot/eth.nodhcp\`,
and edit it following the format below:
\`addr/netid gw[optional]\` in one line
for example (support muti definations, checks with arp):
1. 192.168.0.101/24 192.168.0.1
2. 192.168.3.116/22
Delete this file if you change to use dhcp default.
ATTENTION: If all the STATIC address you set in the file is
already in use on this LAN checked with arp, then none
of them will be yours. And it will trigger dhcp, try to
fetch DYNAMIC one. If it's also failed, your address will
be the REVERSE one, 192.168.0.1/24. Just to be sure that
you can visit your NanoKVM device through eth to fix some
problems by ssh, unless you must disassemble it and modify
or rewrite the TFCard once again.
EOF
echo ""
cat <<EOF
如果您想自行设置静态 ip 地址而不是使用 dhcp 来获取它。
那么您需要新建一个文件 \`/boot/eth.nodhcp\`,然后按照以下格式编辑它:
\`addr/netid gw[optional]\` 在一行内
示例(支持多个预设,会通过 arp 检查是否占用):
1. 192.168.0.101/24 192.168.0.1
2. 192.168.3.116/22
如果您想改回默认 dhcp 获取 ip 地址的方式,删除该文件后即可恢复。
注意:如果您预设的所有静态 ip 地址都被 arp 检测到已占用,则不会成功设置。
此时会触发 dhcp 尝试获取 ip 地址,如果仍失败,那么最终会强制设置成
192.168.90.1/24。这是为了保证您的 NanoKVM 设备总是能有一个可用的
ip 地址,方便您通过局域网 ssh 连接设备进行一些修复,否则您可能需要
拆开外壳以手动修改 TF 卡或重新烧卡。
EOF
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
esac
start) start ;;
stop) stop ;;
restart|reload)
$0 stop
$0 start
;;
*) exit 1 ;;
esac

View File

@@ -21,94 +21,69 @@ PKG_URL_LATEST="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
exit 1
VERSION=$(/usr/sbin/$DAEMON --version|sed -n '1p'|xargs echo -n)
[ -x /usr/bin/tailscale ] || echo "/usr/bin/tailscale not found, your installation of tailscale may be broken"
[ -x /usr/bin/tailscale ] ||
(echo "/usr/bin/tailscale not found, your installation of tailscale may be broken" && exit 1)
# just for those need forwarding
[ ! -f /etc/sysctl.d/99-tailscale.conf ] &&
mkdir -p /etc/sysctl.d/ &&
echo "missing /etc/sysctl.d/99-tailscale.conf, try make it below:" &&
(tee /etc/sysctl.d/99-tailscale.conf <<EOF
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
) &&
echo "if this message repeats showing, please look at $0 if you need forwarding"
# Clean up old forwarding configuration if it exists
if [ -f /etc/sysctl.d/99-tailscale.conf ]; then
echo "Found old forwarding configuration, cleaning up..."
rm -f /etc/sysctl.d/99-tailscale.conf
sysctl -w net.ipv4.ip_forward=0 >/dev/null 2>&1
sysctl -w net.ipv6.conf.all.forwarding=0 >/dev/null 2>&1
echo "Cleanup completed. IPv4/IPv6 forwarding disabled."
# Trigger IPv6 address renewal on all interfaces
for iface in $(ls /sys/class/net/ | grep -v lo); do
echo 0 > /proc/sys/net/ipv6/conf/$iface/forwarding 2>/dev/null
sysctl -w net.ipv6.conf.$iface.accept_ra=2 >/dev/null 2>&1
done
echo "If you need exit node features, please configure manually."
fi
case "$1" in
start)
tailscale set --accept-dns=false
if [ -f /etc/kvm/GOMEMLIMIT ]; then
value=$(cat /etc/kvm/GOMEMLIMIT)
export GOMEMLIMIT="${value}MiB"
else
export GOMEMLIMIT=1024MiB
export GOMEMLIMIT=512MiB
fi
echo "GOMEMLIMIT set to ${GOMEMLIMIT}"
# Ensure directories exist
mkdir -p /var/lib/tailscale /var/run/tailscale
printf "Starting $DAEMON[$VERSION]: "
start-stop-daemon -S -bmq -p "$PIDFILE" -x "/usr/sbin/$DAEMON" -- \
--state=/var/lib/tailscale/tailscaled.state \
--socket=/var/run/tailscale/tailscaled.sock \
--port=${PORT} \
$FLAGS
[ $? = 0 ] && echo "OK" || echo "FAIL"
if [ $? = 0 ]; then
sleep 5
echo "OK"
tailscale set --accept-dns=false
else
echo "FAIL"
fi
;;
stop)
printf "Stopping $DAEMON: "
start-stop-daemon -K -p "$PIDFILE"
[ $? = 0 ] && (echo "OK"; rm -f "$PIDFILE") || echo "FAIL"
printf "cleaning tailscaled: "
/usr/sbin/$DAEMON --cleanup &>/dev/null
/usr/sbin/$DAEMON --cleanup >/dev/null 2>&1
[ $? = 0 ] && echo "OK" || echo "FAIL"
;;
restart|reload)
$0 stop
$0 start
;;
doc)
cat <<EOF
To use tailscale, you need to meet these conditions below:
1. Both tailscale and tailscaled were installed.
2. At least one tailscale's account were registered.
(https://login.tailscale.com/start)
3. Your NanoKVM device must keep online.
Then, follow the steps below:
1. Login in https://login.tailscale.com on your PC
to enter into yout admin console.
2. Run 'tailscale login' on your device, and copy the link showed
to open on your PC, and confirm the auth.
3. Congratulations, your NanoKVM device is now on the tailnet, try to
visit it from all your device which on the same tailnet.
4. run 'tailscale set --webclient' and manage your device
on the web interface from localnet and tailenet.
Added: no more action to be repeat after your device reboot.
EOF
echo ""
cat <<EOF
为了正常使用 tailscale, 你需要满足以下几个条件:
1. tailscale 和 tailscaled 这两个静态链接的二进制程序
都已经正确安装在系统上。
2. 至少注册一个 tailscale 的帐号才能使用其服务。
(https://login.tailscale.com/start)
3. 你的 NanoKVM 设备必须保持在线。
然后,跟着以下步骤操作:
1. 在你的电脑上登录 https://login.tailscale.com 以进入控制台。
2. 在你的 NanoKVM ssh 终端上执行 'tailscale login', 然后复制
出现的链接到浏览器上,登录并通过验证。
3. 祝贺,你的 NanoKVM 现在已经加入了 tailnet 然后你可以从任意
同样已加入同一 tailnet 的设备去访问你的 NanoKVM。
4. 执行 'tailscale set --webclient' 然后你就可以从 '局域网' 和
'tailnet' 来访问特定网页,用来管理你的设备。
补充: 不需要重复上面的操作,哪怕你的设备重启过。
EOF
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
esac
exit 0
exit 0

64
server/build.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/bin/bash
set -e
# Configuration Variables
BINARY_NAME="NanoKVM-Server"
CC_COMPILER="riscv64-unknown-linux-musl-gcc"
CGO_CFLAGS_OPTS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d"
# Define colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Helper function to check if a command exists
check_dependency() {
if ! command -v "$1" &> /dev/null; then
echo -e "${RED}[ERROR] Required command '$1' not found.${NC}"
echo "Please install it or ensure it is in your PATH."
exit 1
fi
}
# ------------------------------------------------------------------------------
# Step 1: Check Prerequisites
# ------------------------------------------------------------------------------
echo -e "${YELLOW}[INFO] Checking build environment...${NC}"
check_dependency "go"
check_dependency "patchelf"
check_dependency "$CC_COMPILER"
echo -e "${GREEN}[OK] All dependencies found.${NC}"
# ------------------------------------------------------------------------------
# Step 2: Build the Binary
# ------------------------------------------------------------------------------
echo -e "${YELLOW}[INFO] Starting cross-compilation for RISC-V 64-bit (BoringCrypto enabled)...${NC}"
export CGO_ENABLED=1
export GOOS=linux
export GOARCH=riscv64
export GOEXPERIMENT=boringcrypto
export CC="$CC_COMPILER"
export CGO_CFLAGS="$CGO_CFLAGS_OPTS"
go build -o "$BINARY_NAME" -v
if [ -f "$BINARY_NAME" ]; then
echo -e "${GREEN}[SUCCESS] Binary '$BINARY_NAME' created successfully.${NC}"
else
echo -e "${RED}[ERROR] Build failed. Binary not found.${NC}"
exit 1
fi
# ------------------------------------------------------------------------------
# Step 3: Patch RPATH
# ------------------------------------------------------------------------------
echo -e "${YELLOW}[INFO] Patching RPATH with patchelf...${NC}"
patchelf --add-rpath '$ORIGIN/dl_lib' "$BINARY_NAME"
echo -e "${GREEN}[DONE] Build script completed successfully!${NC}"

View File

@@ -7,8 +7,6 @@ package common
*/
import "C"
import (
"NanoKVM-Server/config"
"strings"
"sync"
"unsafe"
@@ -26,15 +24,8 @@ func GetKvmVision() *KvmVision {
kvmVisionOnce.Do(func() {
kvmVision = &KvmVision{}
conf := config.GetInstance()
logLevel := strings.ToLower(conf.Logger.Level)
logEnable := C.uint8_t(0)
if logLevel == "debug" {
logEnable = C.uint8_t(1)
}
C.kvmv_init(logEnable)
logLevel := C.uint8_t(0)
C.kvmv_init(logLevel)
log.Debugf("kvm vision initialized")
})
@@ -62,8 +53,6 @@ func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data
defer C.free_kvmv_data(&kvmData)
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
log.Debugf("read kvm image: %v", result)
return
}
@@ -88,8 +77,6 @@ func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data
defer C.free_kvmv_data(&kvmData)
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
log.Debugf("read kvm image: %v", result)
return
}

View File

@@ -54,24 +54,27 @@ func (h HWVersion) String() string {
}
}
func getHwVersion() HWVersion {
func GetHwVersion() HWVersion {
content, err := os.ReadFile(HWVersionFile)
if err != nil {
return HWVersionAlpha
}
version := strings.ReplaceAll(string(content), "\n", "")
if version == "beta" {
switch version {
case "alpha":
return HWVersionAlpha
case "beta":
return HWVersionBeta
} else if version == "pcie" {
case "pcie":
return HWVersionPcie
default:
return HWVersionAlpha
}
return HWVersionAlpha
}
func getHardware() (h Hardware) {
version := getHwVersion()
version := GetHwVersion()
switch version {
case HWVersionAlpha:

View File

@@ -1,8 +1,6 @@
module NanoKVM-Server
go 1.23.0
toolchain go1.23.2
go 1.24.0
require (
github.com/creack/pty v1.1.24
@@ -12,12 +10,14 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
github.com/pion/dtls/v3 v3.0.3
github.com/pion/rtp v1.8.18
github.com/pion/webrtc/v4 v4.0.1
github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692
github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.19.0
github.com/unrolled/secure v1.15.0
golang.org/x/crypto v0.37.0
golang.org/x/crypto v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -44,14 +44,12 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/dtls/v3 v3.0.3 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.37 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/interceptor v0.1.39 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.14 // indirect
github.com/pion/rtp v1.8.9 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/sctp v1.8.33 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect
@@ -73,9 +71,9 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

View File

@@ -34,8 +34,6 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
@@ -80,18 +78,18 @@ github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/interceptor v0.1.39 h1:Y6k0bN9Y3Lg/Wb21JBWp480tohtns8ybJ037AGr9UuA=
github.com/pion/interceptor v0.1.39/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk=
github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
@@ -141,8 +139,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -160,35 +159,19 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -16,6 +16,7 @@ import (
"NanoKVM-Server/middleware"
"NanoKVM-Server/router"
"NanoKVM-Server/service/vm/jiggler"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
cors "github.com/rs/cors/wrapper/gin"
@@ -38,7 +39,9 @@ func initialize() {
vision := common.GetKvmVision()
vision.SetHDMI(false)
time.Sleep(10 * time.Millisecond)
vision.SetHDMI(true)
if !utils.IsHdmiDisabled() {
vision.SetHDMI(true)
}
// run mouse jiggler
jiggler.GetJiggler().Run()

View File

@@ -16,3 +16,7 @@ type GetMountedImageRsp struct {
type GetCdRomRsp struct {
Cdrom int64 `json:"cdrom"`
}
type DeleteImageReq struct {
File string `json:"file" validate:"required"`
}

View File

@@ -57,6 +57,7 @@ type DeleteScriptReq struct {
type GetVirtualDeviceRsp struct {
Network bool `json:"network"`
Media bool `json:"media"`
Disk bool `json:"disk"`
}

View File

@@ -11,9 +11,9 @@ func hidRouter(r *gin.Engine) {
service := hid.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api.POST("/hid/reset", service.Reset) // reset hid
api.POST("/hid/paste", service.Paste) // paste
api.GET("/hid/mode", service.GetHidMode) // get hid mode
api.POST("/hid/mode", service.SetHidMode) // set hid mode
api.POST("/hid/reset", service.ResetHid) // reset hid
}

View File

@@ -15,4 +15,5 @@ func storageRouter(r *gin.Engine) {
api.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image
api.POST("/storage/image/mount", service.MountImage) // mount image
api.GET("/storage/cdrom", service.GetCdRom) // get CD-ROM flag
api.POST("/storage/image/delete", service.DeleteImage) // delete image
}

View File

@@ -3,8 +3,8 @@ package router
import (
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/stream/direct"
"NanoKVM-Server/service/stream/h264"
"NanoKVM-Server/service/stream/mjpeg"
"NanoKVM-Server/service/stream/webrtc"
"github.com/gin-gonic/gin"
)
@@ -16,6 +16,6 @@ func streamRouter(r *gin.Engine) {
api.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect
api.POST("/stream/mjpeg/detect/stop", mjpeg.StopFrameDetect) // temporary stop frame detect
api.GET("/stream/h264", h264.Connect) // h264 stream (webrtc)
api.GET("/stream/h264", webrtc.Connect) // h264 stream (webrtc)
api.GET("/stream/h264/direct", direct.Connect) // h264 stream (http)
}

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/exec"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -21,9 +22,29 @@ const (
maxTries = 3
)
var (
updateMutex sync.Mutex
isUpdating bool
)
func (s *Service) Update(c *gin.Context) {
var rsp proto.Response
updateMutex.Lock()
if isUpdating {
updateMutex.Unlock()
rsp.ErrRsp(c, -1, "update already in progress")
return
}
isUpdating = true
updateMutex.Unlock()
defer func() {
updateMutex.Lock()
isUpdating = false
updateMutex.Unlock()
}()
if err := update(); err != nil {
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
return

View File

@@ -4,6 +4,7 @@ import (
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -23,17 +24,20 @@ func (s *Service) Login(c *gin.Context) {
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(3 * time.Second)
rsp.ErrRsp(c, -1, "invalid parameters")
return
}
if ok := CompareAccount(req.Username, req.Password); !ok {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -2, "invalid username or password")
return
}
token, err := middleware.GenerateJWT(req.Username)
if err != nil {
time.Sleep(1 * time.Second)
rsp.ErrRsp(c, -3, "generate token failed")
return
}

View File

@@ -69,8 +69,6 @@ func (c *Cli) Stop() error {
return err
}
_ = os.Remove(ConfigPath)
return os.Remove(ScriptPath)
}

View File

@@ -15,7 +15,6 @@ type Service struct{}
const (
TailscalePath = "/usr/bin/tailscale"
TailscaledPath = "/usr/sbin/tailscaled"
ConfigPath = "etc/sysctl.d/99-tailscale.conf"
GoMemLimit int64 = 75
)
@@ -40,6 +39,8 @@ func (s *Service) Install(c *gin.Context) {
rsp.ErrRsp(c, -1, "install failed")
return
}
_ = NewCli().Start()
}
rsp.OkRsp(c)
@@ -54,7 +55,6 @@ func (s *Service) Uninstall(c *gin.Context) {
_ = os.Remove(TailscalePath)
_ = os.Remove(TailscaledPath)
_ = os.Remove(ConfigPath)
rsp.OkRsp(c)
log.Debugf("uninstall tailscale successfully")

View File

@@ -1,13 +1,12 @@
package hid
import (
"errors"
"os"
"sync"
)
"time"
var (
hid *Hid
hidOnce sync.Once
log "github.com/sirupsen/logrus"
)
type Hid struct {
@@ -18,6 +17,24 @@ type Hid struct {
mouseMutex sync.Mutex
}
const (
HID0 = "/dev/hidg0"
HID1 = "/dev/hidg1"
HID2 = "/dev/hidg2"
)
var (
hid *Hid
hidOnce sync.Once
)
func GetHid() *Hid {
hidOnce.Do(func() {
hid = &Hid{}
})
return hid
}
func (h *Hid) Lock() {
h.kbMutex.Lock()
h.mouseMutex.Lock()
@@ -28,9 +45,117 @@ func (h *Hid) Unlock() {
h.mouseMutex.Unlock()
}
func GetHid() *Hid {
hidOnce.Do(func() {
hid = &Hid{}
})
return hid
func (h *Hid) OpenNoLock() {
var err error
h.CloseNoLock()
h.g0, err = os.OpenFile(HID0, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open %s failed: %s", HID0, err)
}
h.g1, err = os.OpenFile(HID1, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open %s failed: %s", HID1, err)
}
h.g2, err = os.OpenFile(HID2, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open %s failed: %s", HID2, err)
}
}
func (h *Hid) CloseNoLock() {
for _, file := range []*os.File{h.g0, h.g1, h.g2} {
if file != nil {
_ = file.Sync()
_ = file.Close()
}
}
}
func (h *Hid) Open() {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
h.mouseMutex.Lock()
defer h.mouseMutex.Unlock()
h.CloseNoLock()
h.OpenNoLock()
}
func (h *Hid) Close() {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
h.mouseMutex.Lock()
defer h.mouseMutex.Unlock()
h.CloseNoLock()
}
func (h *Hid) WriteHid0(data []byte) {
h.kbMutex.Lock()
_, err := h.g0.Write(data)
h.kbMutex.Unlock()
if err != nil {
if errors.Is(err, os.ErrClosed) {
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
} else {
log.Debugf("write to %s failed: %s", HID0, err)
}
return
}
log.Debugf("write to %s: %v", HID0, data)
}
func (h *Hid) WriteHid1(data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
h.mouseMutex.Lock()
_ = h.g1.SetWriteDeadline(deadline)
_, err := h.g1.Write(data)
h.mouseMutex.Unlock()
if err != nil {
switch {
case errors.Is(err, os.ErrClosed):
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to %s timeout", HID1)
default:
log.Errorf("write to %s failed: %s", HID1, err)
}
return
}
log.Debugf("write to %s: %v", HID1, data)
}
func (h *Hid) WriteHid2(data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
h.mouseMutex.Lock()
_ = h.g2.SetWriteDeadline(deadline)
_, err := h.g2.Write(data)
h.mouseMutex.Unlock()
if err != nil {
switch {
case errors.Is(err, os.ErrClosed):
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to %s timeout", HID2)
default:
log.Errorf("write to %s failed: %s", HID2, err)
}
return
}
log.Debugf("write to %s: %v", HID2, data)
}

View File

@@ -2,20 +2,14 @@ package hid
func (h *Hid) Keyboard(queue <-chan []int) {
for event := range queue {
h.kbMutex.Lock()
h.writeKeyboard(event)
h.kbMutex.Unlock()
code := byte(event[0])
var modifier byte = 0x00
if code > 0 {
modifier = byte(event[1]) | byte(event[2]) | byte(event[3]) | byte(event[4])
}
data := []byte{modifier, 0x00, code, 0x00, 0x00, 0x00, 0x00, 0x00}
h.WriteHid0(data)
}
}
func (h *Hid) writeKeyboard(event []int) {
code := byte(event[0])
var modifier byte = 0x00
if code > 0 {
modifier = byte(event[1]) | byte(event[2]) | byte(event[3]) | byte(event[4])
}
data := []byte{modifier, 0x00, code, 0x00, 0x00, 0x00, 0x00, 0x00}
h.Write(h.g0, data)
}

View File

@@ -2,9 +2,6 @@ package hid
import (
"encoding/binary"
"errors"
"os"
"time"
log "github.com/sirupsen/logrus"
)
@@ -25,7 +22,6 @@ var mouseButtonMap = map[byte]bool{
func (h *Hid) Mouse(queue <-chan []int) {
for event := range queue {
h.mouseMutex.Lock()
switch event[0] {
case MouseDown:
@@ -39,10 +35,8 @@ func (h *Hid) Mouse(queue <-chan []int) {
case MouseScroll:
h.mouseScroll(event)
default:
log.Debugf("invalid mouse event: %+v", event)
log.Debugf("invalid mouse event: %v", event)
}
h.mouseMutex.Unlock()
}
}
@@ -50,17 +44,17 @@ func (h *Hid) mouseDown(event []int) {
button := byte(event[1])
if _, ok := mouseButtonMap[button]; !ok {
log.Debugf("invalid mouse button: %+v", event)
log.Errorf("invalid mouse button: %v", event)
return
}
data := []byte{button, 0, 0, 0}
h.writeWithTimeout(h.g1, data)
h.WriteHid1(data)
}
func (h *Hid) mouseUp() {
data := []byte{0, 0, 0, 0}
h.writeWithTimeout(h.g1, data)
h.WriteHid1(data)
}
func (h *Hid) mouseScroll(event []int) {
@@ -70,9 +64,8 @@ func (h *Hid) mouseScroll(event []int) {
}
data := []byte{0, 0, 0, byte(direction)}
h.writeWithTimeout(h.g1, data)
h.WriteHid1(data)
}
func (h *Hid) mouseMoveAbsolute(event []int) {
x := make([]byte, 2)
y := make([]byte, 2)
@@ -80,32 +73,10 @@ func (h *Hid) mouseMoveAbsolute(event []int) {
binary.LittleEndian.PutUint16(y, uint16(event[3]))
data := []byte{0, x[0], x[1], y[0], y[1], 0}
h.writeWithTimeout(h.g2, data)
h.WriteHid2(data)
}
func (h *Hid) mouseMoveRelative(event []int) {
data := []byte{byte(event[1]), byte(event[2]), byte(event[3]), 0}
h.writeWithTimeout(h.g1, data)
}
func (h *Hid) writeWithTimeout(file *os.File, data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
_ = file.SetWriteDeadline(deadline)
_, err := file.Write(data)
if err != nil {
switch {
case errors.Is(err, os.ErrClosed):
log.Debugf("hid already closed, reopen it...")
h.OpenNoLock()
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to hid timeout")
default:
log.Errorf("write to hid failed: %s", err)
}
return
}
log.Debugf("write to hid: %+v", data)
h.WriteHid1(data)
}

View File

@@ -1,73 +0,0 @@
package hid
import (
"errors"
"os"
log "github.com/sirupsen/logrus"
)
func (h *Hid) OpenNoLock() {
var err error
h.CloseNoLock()
h.g0, err = os.OpenFile("/dev/hidg0", os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open /dev/hidg0 failed: %s", err)
}
h.g1, err = os.OpenFile("/dev/hidg1", os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open /dev/hidg1 failed: %s", err)
}
h.g2, err = os.OpenFile("/dev/hidg2", os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open /dev/hidg2 failed: %s", err)
}
}
func (h *Hid) Open() {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
h.mouseMutex.Lock()
defer h.mouseMutex.Unlock()
h.CloseNoLock()
h.OpenNoLock()
}
func (h *Hid) CloseNoLock() {
for _, file := range []*os.File{h.g0, h.g1, h.g2} {
if file != nil {
_ = file.Sync()
_ = file.Close()
}
}
}
func (h *Hid) Close() {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
h.mouseMutex.Lock()
defer h.mouseMutex.Unlock()
h.CloseNoLock()
}
func (h *Hid) Write(file *os.File, data []byte) {
_, err := file.Write(data)
if err != nil {
if errors.Is(err, os.ErrClosed) {
log.Debugf("hid already closed, reopen it...")
h.OpenNoLock()
} else {
log.Errorf("write to hid failed: %s", err)
}
return
}
log.Debugf("write to hid: %+v", data)
}

View File

@@ -16,38 +16,121 @@ type Char struct {
type PasteReq struct {
Content string `form:"content" validate:"required"`
Langue string `form:"langue"`
}
func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
// wenn kein lang angegeben → Standardmap zurück
if lang == "" {
return base
}
// immer Kopie erstellen
m := copyMap(base)
switch lang {
case "de":
// Y tauschen
m['y'] = Char{0, 29}
m['Y'] = Char{2, 29}
// Z tauschen
m['z'] = Char{0, 28}
m['Z'] = Char{2, 28}
// deutsche Sonderzeichen hinzufügen oder remappen
m['\u00E4'] = Char{0, 52} // ä
m['\u00C4'] = Char{2, 52} // Ä
m['\u00F6'] = Char{0, 51} // ö
m['\u00D6'] = Char{2, 51} // Ö
m['\u00FC'] = Char{0, 47} // ü
m['\u00DC'] = Char{2, 47} // Ü
m['\u00DF'] = Char{0, 45} // ß
//Tauschen
m['^'] = Char{0, 53} // muss doppelt sein
m['/'] = Char{2, 36} // Shift + 7
m['('] = Char{2, 37} // Shift + 8
m['&'] = Char{2, 35} // Shift + 6
m[')'] = Char{2, 38} // Shift + 9
m['`'] = Char{2, 46} // Grave Accent / Backtick
m['"'] = Char{2, 31} // Shift + 2
m['?'] = Char{2, 45} // Shift + ß
m['{'] = Char{0x40, 36} // ALt Gr + 7
m['['] = Char{0x40, 37} // ALt Gr + 8
m[']'] = Char{0x40, 38} // ALt Gr + 6
m['}'] = Char{0x40, 39} // ALt Gr + 0
m['\\'] = Char{0x40, 45} // ALt Gr + ß
m['@'] = Char{0x40, 20} // ALt Gr + q
m['+'] = Char{0, 48} // Shift + +
m['*'] = Char{2, 48} // Shift + +
m['~'] = Char{0x40, 48} // Shift + +
m['#'] = Char{0, 49} // Shift + #
m['\''] = Char{2, 49} // Shift + #
m['<'] = Char{0, 100} // Shift + <
m['>'] = Char{2, 100} // Shift + <
m['|'] = Char{0x40, 100} // ALt Gr + <
m[';'] = Char{2, 54} // Shift + ,
m[':'] = Char{2, 55} // Shift + .
m['-'] = Char{0, 56} // Shift + -
m['_'] = Char{2, 56} // Shift + -
//neu
m['\u00B4'] = Char{0, 46} // ´
m['\u00B0'] = Char{2, 53} // °
m['\u00A7'] = Char{2, 32} // §
m['\u20AC'] = Char{0x40, 8} // €
m['\u00B2'] = Char{0x40, 31} // ²
m['\u00B3'] = Char{0x40, 32} // ³
}
return m
}
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, "content too long")
return
}
s.hid.kbMutex.Lock()
defer s.hid.kbMutex.Unlock()
charMapLocal := LangueSwitch(charMap, req.Langue)
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
for _, char := range req.Content {
key, ok := charMap[char]
key, ok := charMapLocal[char]
if !ok {
log.Debugf("unknown key '%c' (rune: %d)", char, char)
continue
}
keyDown := []byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00}
s.hid.Write(s.hid.g0, keyDown)
s.hid.Write(s.hid.g0, keyUp)
time.Sleep(50 * time.Millisecond)
hid.WriteHid0(keyDown)
hid.WriteHid0(keyUp)
time.Sleep(30 * time.Millisecond)
}
rsp.OkRsp(c)
log.Debugf("hid paste success, total %d characters processed", len(req.Content))
}
func copyMap(src map[rune]Char) map[rune]Char {
dst := make(map[rune]Char, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
var charMap = map[rune]Char{
// Lowercase letters
'a': {0, 4}, 'b': {0, 5}, 'c': {0, 6}, 'd': {0, 7}, 'e': {0, 8},
@@ -99,16 +182,16 @@ var charMap = map[rune]Char{
'/': {0, 56}, // Slash
// 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)
'_': {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)
':': {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)
':': {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)
}

View File

@@ -1,74 +0,0 @@
package hid
import (
"NanoKVM-Server/proto"
"os"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
func (s *Service) Reset(c *gin.Context) {
var rsp proto.Response
// reset USB
f, err := os.OpenFile("/sys/kernel/config/usb_gadget/g0/UDC", os.O_WRONLY, 0644)
if err != nil {
log.Errorf("open /sys/kernel/config/usb_gadget/g0/UDC failed: %s", err)
rsp.ErrRsp(c, -1, "open usb gadget file failed")
return
}
err = f.Truncate(0)
if err != nil {
_ = f.Close()
log.Errorf("truncate /sys/kernel/config/usb_gadget/g0/UDC failed: %s", err)
rsp.ErrRsp(c, -1, "truncate usb gadget file failed")
return
}
_, err = f.Seek(0, 0)
if err != nil {
_ = f.Close()
log.Errorf("seek to 0 failed: %s", err)
rsp.ErrRsp(c, -1, "seek to 0 in usb gadget file failed")
return
}
_, err = f.WriteString("\n")
if err != nil {
_ = f.Close()
log.Errorf("write to /sys/kernel/config/usb_gadget/g0/UDC failed: %s", err)
rsp.ErrRsp(c, -1, "write to usb gadget file failed")
return
}
_ = f.Close()
time.Sleep(1 * time.Second)
devices, err := os.ReadDir("/sys/class/udc/")
if err != nil {
log.Errorf("read udc directory failed: %s", err)
rsp.ErrRsp(c, -1, "read udc directory failed")
return
}
f, err = os.OpenFile("/sys/kernel/config/usb_gadget/g0/UDC", os.O_WRONLY, 0644)
if err != nil {
log.Errorf("open /sys/kernel/config/usb_gadget/g0/UDC failed: %s", err)
rsp.ErrRsp(c, -1, "open usb gadget file failed")
return
}
for _, device := range devices {
_, err = f.WriteString(device.Name() + "\n")
if err != nil {
_ = f.Close()
log.Errorf("write to /sys/kernel/config/usb_gadget/g0/UDC failed: %s", err)
rsp.ErrRsp(c, -1, "write to usb gadget file failed")
return
}
}
_ = f.Close()
rsp.OkRsp(c)
log.Debugf("reset hid success")
}

View File

@@ -3,6 +3,7 @@ package hid
import (
"NanoKVM-Server/proto"
"errors"
"fmt"
"io"
"os"
"os/exec"
@@ -16,11 +17,12 @@ import (
const (
ModeNormal = "normal"
ModeHidOnly = "hid-only"
ModeFlag = "/sys/kernel/config/usb_gadget/g0/bcdDevice"
ModeFlag = "/sys/kernel/config/usb_gadget/g0/bcdDevice"
NormalModeScript = "/kvmapp/system/init.d/S03usbdev"
HidOnlyModeScript = "/kvmapp/system/init.d/S03usbhid"
TargetModeScript = "/etc/init.d/S03usbdev"
ModeNormalScript = "/kvmapp/system/init.d/S03usbdev"
ModeHidOnlyScript = "/kvmapp/system/init.d/S03usbhid"
USBDevScript = "/etc/init.d/S03usbdev"
)
var modeMap = map[string]string{
@@ -61,9 +63,17 @@ func (s *Service) SetHidMode(c *gin.Context) {
return
}
srcScript := NormalModeScript
h := GetHid()
h.Lock()
h.CloseNoLock()
defer func() {
h.OpenNoLock()
h.Unlock()
}()
srcScript := ModeNormalScript
if req.Mode == ModeHidOnly {
srcScript = HidOnlyModeScript
srcScript = ModeHidOnlyScript
}
if err := copyModeFile(srcScript); err != nil {
@@ -78,6 +88,29 @@ func (s *Service) SetHidMode(c *gin.Context) {
_ = exec.Command("reboot").Run()
}
func (s *Service) ResetHid(c *gin.Context) {
var rsp proto.Response
h := GetHid()
h.Lock()
h.CloseNoLock()
defer func() {
h.OpenNoLock()
h.Unlock()
}()
command := fmt.Sprintf("%s restart_phy", USBDevScript)
err := exec.Command("sh", "-c", command).Run()
if err != nil {
log.Errorf("failed to reset hid: %v", err)
rsp.ErrRsp(c, -1, "failed to reset hid")
return
}
rsp.OkRsp(c)
log.Debugf("reset hid success")
}
func copyModeFile(srcScript string) error {
// open the source file
srcFile, err := os.Open(srcScript)
@@ -98,7 +131,7 @@ func copyModeFile(srcScript string) error {
// create and copy to temporary file
tmpFile, err := os.CreateTemp("/etc/init.d/", ".S03usbdev-")
if err != nil {
log.Errorf("failed to create temp %s: %s", TargetModeScript, err)
log.Errorf("failed to create temp %s: %s", USBDevScript, err)
return err
}
tmpPath := tmpFile.Name()
@@ -131,12 +164,12 @@ func copyModeFile(srcScript string) error {
}
// replace the target file with the temporary file
if err := os.Rename(tmpPath, TargetModeScript); err != nil {
if err := os.Rename(tmpPath, USBDevScript); err != nil {
log.Errorf("failed to rename %s: %s", tmpPath, err)
return err
}
log.Debugf("copy %s to %s successful", srcScript, TargetModeScript)
log.Debugf("copy %s to %s successful", srcScript, USBDevScript)
return nil
}

View File

@@ -1,6 +1,7 @@
package storage
import (
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -12,14 +13,16 @@ import (
log "github.com/sirupsen/logrus"
"NanoKVM-Server/proto"
"NanoKVM-Server/service/hid"
)
const (
imageDirectory = "/data"
imageNone = "/dev/mmcblk0p3"
cdromFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/cdrom"
mountDevice = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/file"
inquiryString = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/inquiry_string"
roFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/ro"
usbNoRstMarker = "/boot/usb.norst"
)
func (s *Service) GetImages(c *gin.Context) {
@@ -91,31 +94,59 @@ func (s *Service) MountImage(c *gin.Context) {
}
}
// mount
image := req.File
if image == "" {
image = imageNone
inquiryVen := "NanoKVM"
inquiryPrd := "USB Mass Storage"
inquiryVer := 0x0520
if req.Cdrom {
inquiryPrd = "USB CD/DVD-ROM"
}
inquiryData := fmt.Sprintf("%-8s%-16s%04x", inquiryVen, inquiryPrd, inquiryVer)
if err := os.WriteFile(mountDevice, []byte(image), 0o666); err != nil {
log.Errorf("mount file %s failed: %s", image, err)
rsp.ErrRsp(c, -2, "mount image failed")
if err := os.WriteFile(inquiryString, []byte(inquiryData), 0o666); err != nil {
log.Errorf("set inquiry %s failed: %s", inquiryData, err)
rsp.ErrRsp(c, -2, "set inquiry failed")
return
}
// reset usb
commands := []string{
"echo > /sys/kernel/config/usb_gadget/g0/UDC",
"ls /sys/class/udc/ | cat > /sys/kernel/config/usb_gadget/g0/UDC",
}
for _, command := range commands {
err := exec.Command("sh", "-c", command).Run()
if err != nil {
rsp.ErrRsp(c, -2, "execute command failed")
// mount if file provided
image := req.File
if image != "" {
if err := os.WriteFile(mountDevice, []byte(image), 0o666); err != nil {
log.Errorf("mount file %s failed: %s", image, err)
rsp.ErrRsp(c, -2, "mount image failed")
return
}
time.Sleep(100 * time.Millisecond)
}
// check to see if usb gadget reset is disabled
resetUsb := true
if _, err := os.Stat(usbNoRstMarker); err == nil {
resetUsb = false
}
h := hid.GetHid()
h.Lock()
h.CloseNoLock()
defer func() {
h.OpenNoLock()
h.Unlock()
}()
// reset usb
if resetUsb {
commands := []string{
"echo > /sys/kernel/config/usb_gadget/g0/UDC",
"ls /sys/class/udc/ | cat > /sys/kernel/config/usb_gadget/g0/UDC",
}
for _, command := range commands {
err := exec.Command("sh", "-c", command).Run()
if err != nil {
rsp.ErrRsp(c, -2, "execute command failed")
return
}
time.Sleep(100 * time.Millisecond)
}
}
rsp.OkRsp(c)
@@ -132,9 +163,6 @@ func (s *Service) GetMountedImage(c *gin.Context) {
}
image := strings.ReplaceAll(string(content), "\n", "")
if image == imageNone {
image = ""
}
data := &proto.GetMountedImageRsp{
File: image,
@@ -165,3 +193,31 @@ func (s *Service) GetCdRom(c *gin.Context) {
rsp.OkRspWithData(c, data)
}
func (s *Service) DeleteImage(c *gin.Context) {
var req proto.DeleteImageReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
filename := strings.ToLower(req.File)
validPrefix := strings.HasPrefix(filename, imageDirectory)
validSuffix := strings.HasSuffix(filename, ".iso") || strings.HasSuffix(filename, ".img")
if !validPrefix || !validSuffix {
rsp.ErrRsp(c, -2, "invalid arguments")
return
}
if err := os.Remove(req.File); err != nil {
rsp.ErrRsp(c, -3, "remove file failed")
log.Errorf("failed to remove file %s: %s", req.File, err)
return
}
rsp.OkRsp(c)
log.Debugf("delete image %s success", req.File)
}

View File

@@ -1,30 +1,17 @@
package direct
import (
"encoding/base64"
"encoding/json"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/common"
)
type Frame struct {
IsKeyFrame bool `json:"isKeyFrame"`
Data string `json:"data"`
Timestamp int64 `json:"timestamp"`
}
var (
mutex = sync.Mutex{}
wsMap = make(map[*websocket.Conn]bool)
isSending = false
upgrader = websocket.Upgrader{
streamer = newStreamer()
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
@@ -34,74 +21,24 @@ var (
func Connect(c *gin.Context) {
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("failed to create websocket: %s", err)
log.Errorf("failed to upgrade to websocket: %s", err)
return
}
defer func() {
_ = ws.Close()
log.Debugf("h264 websocket disconnected")
log.Debugf("h264 websocket disconnected: %s", ws.RemoteAddr())
}()
log.Debugf("h264 websocket connected: %s", ws.RemoteAddr())
var zeroTime time.Time
_ = ws.SetReadDeadline(zeroTime)
_ = ws.SetReadDeadline(time.Time{})
mutex.Lock()
wsMap[ws] = true
if len(wsMap) == 1 && !isSending {
go send()
}
mutex.Unlock()
streamer.addClient(ws)
defer streamer.removeClient(ws)
_, _, err = ws.ReadMessage()
if err != nil {
mutex.Lock()
delete(wsMap, ws)
mutex.Unlock()
log.Debugf("failed to read message: %s", err)
}
}
func send() {
isSending = true
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
duration := time.Second / time.Duration(fps)
ticker := time.NewTicker(duration)
defer ticker.Stop()
vision := common.GetKvmVision()
startTime := time.Now()
for range ticker.C {
if len(wsMap) == 0 {
isSending = false
for {
if _, _, err := ws.ReadMessage(); err != nil {
log.Debugf("failed to read message (client disconnected): %s", err)
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 {
continue
}
frameMsg := Frame{
IsKeyFrame: result == 3,
Data: base64.StdEncoding.EncodeToString(data),
Timestamp: time.Since(startTime).Microseconds(),
}
frameJSON, err := json.Marshal(frameMsg)
if err != nil {
continue
}
for ws := range wsMap {
if err := ws.WriteMessage(websocket.TextMessage, frameJSON); err != nil {
log.Debugf("failed to write message: %s", err)
}
}
}
}

View File

@@ -0,0 +1,12 @@
package direct
import (
"bytes"
"sync"
)
var BufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}

View File

@@ -0,0 +1,123 @@
package direct
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"bytes"
"encoding/binary"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
type Streamer struct {
mutex sync.RWMutex
clients map[*websocket.Conn]bool
running int32
}
func newStreamer() *Streamer {
return &Streamer{
clients: make(map[*websocket.Conn]bool),
}
}
func (s *Streamer) addClient(ws *websocket.Conn) {
s.mutex.Lock()
s.clients[ws] = true
s.mutex.Unlock()
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
go s.run()
log.Debug("h264 stream started")
}
}
func (s *Streamer) removeClient(ws *websocket.Conn) {
s.mutex.Lock()
delete(s.clients, ws)
s.mutex.Unlock()
log.Debugf("h264 websocket disconnected, remaining clients: %d", len(s.clients))
}
func (s *Streamer) getClientCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
return len(s.clients)
}
func (s *Streamer) run() {
defer atomic.StoreInt32(&s.running, 0)
duration := time.Second / time.Duration(120)
ticker := time.NewTicker(duration)
defer ticker.Stop()
screen := common.GetScreen()
vision := common.GetKvmVision()
startTime := time.Now()
for range ticker.C {
if s.getClientCount() == 0 {
log.Debug("h264 stream stopped due to no clients")
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 || len(data) == 0 {
continue
}
isKeyFrame := byte(0)
if result == 3 {
isKeyFrame = byte(1)
}
timestamp := time.Since(startTime).Microseconds()
if err := s.send(isKeyFrame, timestamp, data); err != nil {
continue
}
stream.GetFrameRateCounter().Update()
}
}
func (s *Streamer) send(isKeyFrame byte, timestamp int64, data []byte) error {
buf := BufferPool.Get().(*bytes.Buffer)
defer BufferPool.Put(buf)
buf.Reset()
if err := buf.WriteByte(isKeyFrame); err != nil {
log.Errorf("failed to write keyframe flag: %s", err)
return err
}
tsBytes := make([]byte, 8)
binary.LittleEndian.PutUint64(tsBytes, uint64(timestamp))
if _, err := buf.Write(tsBytes); err != nil {
log.Errorf("failed to write timestamp: %s", err)
return err
}
if _, err := buf.Write(data); err != nil {
log.Errorf("failed to write h264 data: %s", err)
return err
}
for client := range s.clients {
if err := client.WriteMessage(websocket.BinaryMessage, buf.Bytes()); err != nil {
log.Errorf("failed to write message to client %s: %s.", client.RemoteAddr(), err)
s.removeClient(client)
}
}
return nil
}

View File

@@ -25,7 +25,7 @@ func send() {
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 {
if result < 0 || len(data) == 0 {
continue
}
@@ -40,8 +40,6 @@ func send() {
}
}
log.Debugf("send h264 data: %d", len(data))
if screen.FPS != fps {
fps = screen.FPS
duration = time.Second / time.Duration(fps)

View File

@@ -1,99 +1,22 @@
package mjpeg
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"sync"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
var (
chanMap = make(map[*gin.Context]bool)
mutex = sync.Mutex{}
exitSig = make(chan bool, 1)
)
var streamer = NewStreamer()
func Connect(c *gin.Context) {
c.Header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Pragma", "no-cache")
c.Header("X-Server-Date", time.Now().Format(time.RFC1123))
mutex.Lock()
chanMap[c] = true
if len(chanMap) == 1 {
go send()
}
mutex.Unlock()
streamer.AddClient(c)
defer streamer.RemoveClient(c)
<-c.Request.Context().Done()
mutex.Lock()
delete(chanMap, c)
if len(chanMap) == 0 {
exitSig <- true
}
mutex.Unlock()
}
func send() {
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
vision := common.GetKvmVision()
for {
select {
case <-ticker.C:
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
if result < 0 || result == 5 {
continue
}
for c := range chanMap {
if err := write(c, data); err != nil {
log.Debugf("failed to write mjpeg data: %s", err)
}
}
log.Debugf("send mjpeg data: %d", len(data))
stream.GetFrameRateCounter().Update()
if screen.FPS != fps {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
case <-exitSig:
return
}
}
}
func write(c *gin.Context, data []byte) (err error) {
if _, err = c.Writer.Write([]byte("--frame\r\n")); err != nil {
return
}
if _, err = c.Writer.Write([]byte("Content-Type: image/jpeg\r\n\r\n")); err != nil {
return
}
if _, err = c.Writer.Write(data); err != nil {
return
}
if _, err = c.Writer.Write([]byte("\r\n")); err != nil {
return
}
c.Writer.Flush()
return
}

View File

@@ -0,0 +1,131 @@
package mjpeg
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"fmt"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
type Streamer struct {
mutex sync.RWMutex
clients map[*gin.Context]bool
running int32
}
func NewStreamer() *Streamer {
return &Streamer{
clients: make(map[*gin.Context]bool),
}
}
func (s *Streamer) AddClient(c *gin.Context) {
s.mutex.Lock()
s.clients[c] = true
s.mutex.Unlock()
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
go s.run()
log.Debug("mjpeg stream started")
}
}
func (s *Streamer) RemoveClient(c *gin.Context) {
s.mutex.Lock()
delete(s.clients, c)
s.mutex.Unlock()
log.Debugf("mjpeg connection removed, remaining clients: %d", len(s.clients))
}
func (s *Streamer) getClients() []*gin.Context {
s.mutex.RLock()
defer s.mutex.RUnlock()
clients := make([]*gin.Context, 0, len(s.clients))
for c := range s.clients {
clients = append(clients, c)
}
return clients
}
func (s *Streamer) getClientCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
return len(s.clients)
}
func (s *Streamer) run() {
defer atomic.StoreInt32(&s.running, 0)
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
vision := common.GetKvmVision()
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
for range ticker.C {
if s.getClientCount() == 0 {
log.Debug("mjpeg stream stopped due to no clients")
return
}
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
if result < 0 || result == 5 || len(data) == 0 {
continue
}
clients := s.getClients()
for _, client := range clients {
if err := writeFrame(client, data); err != nil {
log.Errorf("failed to write mjpeg frame for client %s: %s", client.Request.RemoteAddr, err)
s.RemoveClient(client)
}
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
stream.GetFrameRateCounter().Update()
}
}
func writeFrame(c *gin.Context, data []byte) (err error) {
defer func() {
if r := recover(); r != nil {
err = c.Request.Context().Err()
if err == nil {
err = fmt.Errorf("panic recovered in writeFrame: %v", r)
}
}
}()
header := "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " + strconv.Itoa(len(data)) + "\r\n\r\n"
if _, err = c.Writer.WriteString(header); err != nil {
return err
}
if _, err = c.Writer.Write(data); err != nil {
return err
}
if _, err = c.Writer.Write([]byte("\r\n")); err != nil {
return err
}
c.Writer.Flush()
return nil
}

View File

@@ -0,0 +1,112 @@
package webrtc
import (
"encoding/json"
"errors"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
"sync"
)
func NewClient(ws *websocket.Conn, videoConn *webrtc.PeerConnection) *Client {
return &Client{
ws: ws,
video: videoConn,
mutex: sync.Mutex{},
}
}
func (c *Client) WriteMessage(event string, data string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
message := &Message{
Event: event,
Data: data,
}
if err := c.ws.WriteJSON(message); err != nil {
log.Errorf("failed to send message %s: %v", event, err)
return err
}
log.Debugf("sent message %s", event)
return nil
}
func (c *Client) ReadMessage() (*Message, error) {
_, raw, err := c.ws.ReadMessage()
if err != nil {
log.Errorf("failed to read message: %v", err)
return nil, err
}
var message Message
if err := json.Unmarshal(raw, &message); err != nil {
log.Errorf("failed to unmarshal message: %v", err)
return nil, nil
}
return &message, nil
}
func (c *Client) AddTrack() error {
// video track
videoTrack, err := webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
"video",
"pion-video",
)
if err != nil {
log.Errorf("failed to create video track: %s", err)
return err
}
videoPacketizer := rtp.NewPacketizer(
1200,
100,
0x1234ABCD,
&codecs.H264Payloader{},
rtp.NewRandomSequencer(),
90000,
)
if videoPacketizer == nil {
err := errors.New("failed to create rtp packetizer")
log.Error(err)
return err
}
videoSender, err := c.video.AddTrack(videoTrack)
if err != nil {
log.Errorf("failed to add video track: %s", err)
return err
}
go startRTCPReader(videoSender)
track := &Track{
videoPacketizer: videoPacketizer,
video: videoTrack,
}
track.updateExtension()
c.mutex.Lock()
c.track = track
c.mutex.Unlock()
return nil
}
func startRTCPReader(sender *webrtc.RTPSender) {
rtcpBuf := make([]byte, 1500)
for {
if _, _, err := sender.Read(rtcpBuf); err != nil {
log.Debugf("RTCP reader error: %v", err)
return
}
}
}

View File

@@ -0,0 +1,158 @@
package webrtc
import (
"NanoKVM-Server/config"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/pion/dtls/v3"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
var (
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
globalManager *WebRTCManager
managerOnce sync.Once
)
func getManager() *WebRTCManager {
managerOnce.Do(func() {
globalManager = NewWebRTCManager()
})
return globalManager
}
func Connect(c *gin.Context) {
// create WebSocket connection
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("failed to create h264 websocket: %s", err)
return
}
defer func() {
_ = wsConn.Close()
log.Debugf("h264 websocket disconnected: %s", c.ClientIP())
}()
log.Debugf("h264 websocket connected: %s", c.ClientIP())
var zeroTime time.Time
_ = wsConn.SetReadDeadline(zeroTime)
// create video connection
iceServers := createICEServers()
mediaEngine, err := createMediaEngine()
if err != nil {
log.Errorf("failed to create h264 media engine: %s", err)
return
}
videoConn, err := createPeerConnection(iceServers, mediaEngine)
if err != nil {
log.Errorf("failed to create h264 video peer connection: %s", err)
return
}
defer func() {
_ = videoConn.Close()
log.Debugf("h264 video peer disconnected: %s", c.ClientIP())
}()
// create client
client := NewClient(wsConn, videoConn)
if err := client.AddTrack(); err != nil {
log.Errorf("failed to add track: %s", err)
return
}
manager := getManager()
manager.AddClient(wsConn, client)
defer manager.RemoveClient(wsConn)
// handle signaling
signalingHandler := NewSignalingHandler(client)
signalingHandler.RegisterCallbacks()
// read and wait
for {
message, err := client.ReadMessage()
if err != nil {
return
}
if message != nil {
if err := signalingHandler.HandleMessage(message); err != nil {
log.Errorf("failed to handle signaling message: %s", err)
}
}
}
}
func createICEServers() []webrtc.ICEServer {
var iceServers []webrtc.ICEServer
conf := config.GetInstance()
if conf.Stun != "" && conf.Stun != "disable" {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{"stun:" + conf.Stun},
})
}
if conf.Turn.TurnAddr != "" && conf.Turn.TurnUser != "" && conf.Turn.TurnCred != "" {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{"turn:" + conf.Turn.TurnAddr},
Username: conf.Turn.TurnUser,
Credential: conf.Turn.TurnCred,
})
}
return iceServers
}
func createMediaEngine() (*webrtc.MediaEngine, error) {
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
log.Errorf("failed to register default codecs: %s", err)
return nil, err
}
if err := mediaEngine.RegisterHeaderExtension(
webrtc.RTPHeaderExtensionCapability{URI: "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay"},
webrtc.RTPCodecTypeVideo,
); err != nil {
log.Errorf("failed to register header extension: %s", err)
return nil, err
}
return mediaEngine, nil
}
func createPeerConnection(iceServers []webrtc.ICEServer, mediaEngine *webrtc.MediaEngine) (*webrtc.PeerConnection, error) {
settingEngine := webrtc.SettingEngine{}
settingEngine.SetSRTPProtectionProfiles(
dtls.SRTP_AEAD_AES_128_GCM,
dtls.SRTP_AES128_CM_HMAC_SHA1_80,
)
apiOptions := []func(api *webrtc.API){
webrtc.WithSettingEngine(settingEngine),
}
if mediaEngine != nil {
apiOptions = append(apiOptions, webrtc.WithMediaEngine(mediaEngine))
}
api := webrtc.NewAPI(apiOptions...)
return api.NewPeerConnection(webrtc.Configuration{
ICEServers: iceServers,
SDPSemantics: webrtc.SDPSemanticsUnifiedPlan,
})
}

View File

@@ -0,0 +1,96 @@
package webrtc
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
func NewWebRTCManager() *WebRTCManager {
return &WebRTCManager{
clients: make(map[*websocket.Conn]*Client),
videoSending: 0,
mutex: sync.RWMutex{},
}
}
func (m *WebRTCManager) AddClient(ws *websocket.Conn, client *Client) {
client.track.updateExtension()
m.mutex.Lock()
m.clients[ws] = client
m.mutex.Unlock()
log.Debugf("added client %s, total clients: %d", ws.RemoteAddr(), len(m.clients))
}
func (m *WebRTCManager) RemoveClient(ws *websocket.Conn) {
m.mutex.Lock()
delete(m.clients, ws)
m.mutex.Unlock()
log.Debugf("removed client %s, total clients: %d", ws.RemoteAddr(), len(m.clients))
}
func (m *WebRTCManager) GetClientCount() int {
m.mutex.RLock()
defer m.mutex.RUnlock()
return len(m.clients)
}
func (m *WebRTCManager) StartVideoStream() {
if atomic.CompareAndSwapInt32(&m.videoSending, 0, 1) {
go m.sendVideoStream()
log.Debugf("start sending h264 stream")
}
}
func (m *WebRTCManager) sendVideoStream() {
defer atomic.StoreInt32(&m.videoSending, 0)
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
duration := time.Second / time.Duration(fps)
vision := common.GetKvmVision()
ticker := time.NewTicker(duration)
defer ticker.Stop()
for range ticker.C {
if m.GetClientCount() == 0 {
log.Debugf("stop sending h264 stream")
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 || len(data) == 0 {
continue
}
sample := media.Sample{
Data: data,
Duration: duration,
}
for _, client := range m.clients {
client.track.writeVideo(sample)
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
duration = time.Second / time.Duration(fps)
ticker.Reset(duration)
}
stream.GetFrameRateCounter().Update()
}
}

View File

@@ -0,0 +1,149 @@
package webrtc
import (
"encoding/json"
"errors"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
func NewSignalingHandler(client *Client) *SignalingHandler {
return &SignalingHandler{
client: client,
}
}
// RegisterCallbacks Register callback functions
func (s *SignalingHandler) RegisterCallbacks() {
// video ICE candidate
s.client.video.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
return
}
candidateByte, err := json.Marshal(candidate.ToJSON())
if err != nil {
log.Errorf("failed to marshal video candidate: %s", err)
return
}
if err := s.client.WriteMessage("video-candidate", string(candidateByte)); err != nil {
log.Errorf("failed to send video candidate: %s", err)
}
})
manager := getManager()
// video connection state change
s.client.video.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
if state == webrtc.ICEConnectionStateConnected {
manager.StartVideoStream()
}
log.Debugf("video connection state changed to %s", state.String())
})
}
// HandleMessage handle the received message
func (s *SignalingHandler) HandleMessage(message *Message) error {
switch message.Event {
case "video-offer":
return s.handleVideoOffer(message.Data)
case "video-candidate":
return s.handleVideoCandidate(message.Data)
case "heartbeat":
return s.handleHeartbeat()
default:
log.Debugf("Unhandled message event: %s", message.Event)
return nil
}
}
func (s *SignalingHandler) handleVideoOffer(data string) error {
if s.client.video.SignalingState() != webrtc.SignalingStateStable {
err := errors.New("video signaling is not stable")
log.Error(err)
return err
}
offer := webrtc.SessionDescription{}
if err := json.Unmarshal([]byte(data), &offer); err != nil {
log.Errorf("failed to unmarshal video offer: %s", err)
return err
}
if err := s.client.video.SetRemoteDescription(offer); err != nil {
log.Errorf("failed to set remote description: %s", err)
return err
}
answer, err := s.client.video.CreateAnswer(nil)
if err != nil {
log.Errorf("failed to create answer: %s", err)
return err
}
if err := s.client.video.SetLocalDescription(answer); err != nil {
log.Errorf("failed to set local description: %s", err)
return err
}
if err := s.updateHeaderExtensionID(); err != nil {
log.Errorf("could not update header extension ID: %v", err)
return err
}
answerByte, err := json.Marshal(answer)
if err != nil {
log.Errorf("failed to marshal answer: %s", err)
return err
}
return s.client.WriteMessage("video-answer", string(answerByte))
}
// set extension ID
func (s *SignalingHandler) updateHeaderExtensionID() error {
receivers := s.client.video.GetReceivers()
if len(receivers) == 0 {
return errors.New("no RTP receiver found for video")
}
params := receivers[0].GetParameters()
if len(params.HeaderExtensions) == 0 {
return errors.New("no header extensions found in negotiated parameters")
}
for _, ext := range params.HeaderExtensions {
if ext.URI == "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay" {
s.client.track.playoutDelayExtensionID = uint8(ext.ID)
log.Debugf("found and set playout delay extension ID to: %d", ext.ID)
return nil
}
}
log.Warnf("no track extension found in negotiated parameters, use default value 5")
return nil
}
// handle video candidate
func (s *SignalingHandler) handleVideoCandidate(data string) error {
candidate := webrtc.ICECandidateInit{}
if err := json.Unmarshal([]byte(data), &candidate); err != nil {
log.Errorf("failed to unmarshal candidate: %s", err)
return err
}
if err := s.client.video.AddICECandidate(candidate); err != nil {
log.Errorf("failed to add ICECandidate: %s", err)
return err
}
return nil
}
// handle heartbeat
func (s *SignalingHandler) handleHeartbeat() error {
return s.client.WriteMessage("heartbeat", "")
}

View File

@@ -0,0 +1,53 @@
package webrtc
import (
"github.com/pion/rtp"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
func (t *Track) updateExtension() {
if t.playoutDelayExtensionID == 0 {
t.playoutDelayExtensionID = 5
}
if t.playoutDelayExtensionData == nil || len(t.playoutDelayExtensionData) == 0 {
playoutDelay := &rtp.PlayoutDelayExtension{
MinDelay: 0,
MaxDelay: 0,
}
playoutDelayExtensionData, err := playoutDelay.Marshal()
if err == nil {
t.playoutDelayExtensionData = playoutDelayExtensionData
}
}
}
func (t *Track) writeVideoSample(sample media.Sample) error {
samples := uint32(sample.Duration.Seconds() * 90000)
packets := t.videoPacketizer.Packetize(sample.Data, samples)
for _, p := range packets {
p.Header.Extension = true
p.Header.ExtensionProfile = 0xBEDE
if err := p.Header.SetExtension(t.playoutDelayExtensionID, t.playoutDelayExtensionData); err != nil {
log.Errorf("Failed to set extension: %v", err)
return err
}
if err := t.video.WriteRTP(p); err != nil {
log.Errorf("failed to write RTP: %v", err)
return err
}
}
return nil
}
func (t *Track) writeVideo(sample media.Sample) {
err := t.writeVideoSample(sample)
if err != nil {
log.Errorf("failed to write h264 video: %s", err)
}
}

View File

@@ -0,0 +1,38 @@
package webrtc
import (
"sync"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
)
type WebRTCManager struct {
clients map[*websocket.Conn]*Client
videoSending int32
mutex sync.RWMutex
}
type Client struct {
ws *websocket.Conn
video *webrtc.PeerConnection
track *Track
mutex sync.Mutex
}
type SignalingHandler struct {
client *Client
}
type Track struct {
playoutDelayExtensionID uint8
playoutDelayExtensionData []byte
videoPacketizer rtp.Packetizer
video *webrtc.TrackLocalStaticRTP
}
type Message struct {
Event string `json:"event"`
Data string `json:"data"`
}

View File

@@ -5,13 +5,12 @@ import (
"NanoKVM-Server/common"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
var hdmiEnabled = true
func (s *Service) ResetHdmi(c *gin.Context) {
var rsp proto.Response
@@ -20,7 +19,7 @@ func (s *Service) ResetHdmi(c *gin.Context) {
vision.SetHDMI(false)
time.Sleep(1 * time.Second)
vision.SetHDMI(true)
hdmiEnabled = true
utils.PersistHDMIEnabled()
rsp.OkRsp(c)
log.Debug("reset hdmi")
@@ -32,7 +31,7 @@ func (s *Service) EnableHdmi(c *gin.Context) {
vision := common.GetKvmVision()
vision.SetHDMI(true)
hdmiEnabled = true
utils.PersistHDMIEnabled()
rsp.OkRsp(c)
log.Debug("enable hdmi")
@@ -44,7 +43,7 @@ func (s *Service) DisableHdmi(c *gin.Context) {
vision := common.GetKvmVision()
vision.SetHDMI(false)
hdmiEnabled = false
utils.PersistHDMIDisabled()
rsp.OkRsp(c)
log.Debug("disable hdmi")
@@ -54,7 +53,7 @@ func (s *Service) GetHdmiState(c *gin.Context) {
var rsp proto.Response
rsp.OkRspWithData(c, &proto.GetGetHdmiStateRsp{
Enabled: hdmiEnabled,
Enabled: !utils.IsHdmiDisabled(),
})
log.Debug("get hdmi state")

View File

@@ -3,6 +3,7 @@ package vm
import (
"fmt"
"os"
"os/exec"
"strings"
"NanoKVM-Server/proto"
@@ -14,6 +15,7 @@ import (
const (
BootHostnameFile = "/boot/hostname"
EtcHostname = "/etc/hostname"
EtcHosts = "/etc/hosts"
)
func (s *Service) SetHostname(c *gin.Context) {
@@ -25,6 +27,29 @@ func (s *Service) SetHostname(c *gin.Context) {
return
}
dataRead, err := os.ReadFile(EtcHostname)
if err != nil {
rsp.ErrRsp(c, -1, "read Hostname failed")
return
}
oldHostname := strings.Replace(string(dataRead), "\n", "", -1)
if (oldHostname != req.Hostname) {
dataRead, err = os.ReadFile(EtcHosts)
if err != nil {
rsp.ErrRsp(c, -1, "read Hosts failed")
return
}
data := []byte(strings.Replace(string(dataRead), oldHostname, req.Hostname, -1))
if err := os.WriteFile(EtcHosts, data, 0o644); err != nil {
rsp.ErrRsp(c, -2, "failed to write data")
return
}
}
data := []byte(fmt.Sprintf("%s", req.Hostname))
if err := os.WriteFile(BootHostnameFile, data, 0o644); err != nil {
@@ -39,6 +64,8 @@ func (s *Service) SetHostname(c *gin.Context) {
rsp.OkRsp(c)
log.Debugf("set Hostname: %s", req.Hostname)
_ = exec.Command("hostname", "-F", EtcHostname).Run()
}
func (s *Service) GetHostname(c *gin.Context) {

View File

@@ -1,6 +1,7 @@
package jiggler
import (
"NanoKVM-Server/service/hid"
"os"
"strings"
"sync"
@@ -117,3 +118,17 @@ func (j *Jiggler) IsEnabled() bool {
func (j *Jiggler) GetMode() string {
return j.mode
}
func move(mode string) {
h := hid.GetHid()
if mode == "absolute" {
h.WriteHid2([]byte{0x00, 0x00, 0x3f, 0x00, 0x3f, 0x00})
time.Sleep(100 * time.Millisecond)
h.WriteHid2([]byte{0x00, 0xff, 0x3f, 0xff, 0x3f, 0x00})
} else {
h.WriteHid1([]byte{0x00, 0xa, 0xa, 0x00})
time.Sleep(100 * time.Millisecond)
h.WriteHid1([]byte{0x00, 0xf6, 0xf6, 0x00})
}
}

View File

@@ -1,55 +0,0 @@
package jiggler
import (
"os"
"time"
log "github.com/sirupsen/logrus"
)
func move(mode string) {
var (
hid string
data [][]byte
)
if mode == "absolute" {
hid = "/dev/hidg2"
data = [][]byte{
{0x00, 0x00, 0x3f, 0x00, 0x3f, 0x00},
{0x00, 0xff, 0x3f, 0xff, 0x3f, 0x00},
}
} else {
hid = "/dev/hidg1"
data = [][]byte{
{0x00, 0x0a, 0x0a, 0x00},
{0x00, 0xf6, 0xf6, 0x00},
}
}
write(hid, data)
}
func write(hid string, data [][]byte) {
file, err := os.OpenFile(hid, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("failed to open %s: %s", hid, err)
return
}
defer func() {
_ = file.Close()
}()
for _, b := range data {
deadline := time.Now().Add(8 * time.Millisecond)
if err := file.SetWriteDeadline(deadline); err != nil {
log.Errorf("failed to set deadline: %s", err)
return
}
if _, err := file.Write(b); err != nil {
log.Errorf("failed to write: %s", err)
return
}
}
}

View File

@@ -14,7 +14,8 @@ import (
const (
virtualNetwork = "/boot/usb.rndis0"
virtualDisk = "/boot/usb.disk0"
virtualMedia = "/boot/usb.disk0"
virtualDisk = "/boot/usb.disk1"
)
var (
@@ -31,16 +32,29 @@ var (
"/etc/init.d/S03usbdev start",
}
mountDiskCommands = []string{
mountMediaCommands = []string{
"touch /boot/usb.disk0",
"/etc/init.d/S03usbdev stop",
"/etc/init.d/S03usbdev start",
}
unmountMediaCommands = []string{
"/etc/init.d/S03usbdev stop",
"rm -rf /sys/kernel/config/usb_gadget/g0/configs/c.1/mass_storage.disk0",
"rm /boot/usb.disk0",
"/etc/init.d/S03usbdev start",
}
mountDiskCommands = []string{
"touch /boot/usb.disk1",
"/etc/init.d/S03usbdev stop",
"/etc/init.d/S03usbdev start",
}
unmountDiskCommands = []string{
"/etc/init.d/S03usbdev stop",
"rm -rf /sys/kernel/config/usb_gadget/g0/configs/c.1/mass_storage.disk0",
"rm /boot/usb.disk0",
"rm -rf /sys/kernel/config/usb_gadget/g0/configs/c.1/mass_storage.disk1",
"rm /boot/usb.disk1",
"/etc/init.d/S03usbdev start",
}
)
@@ -49,10 +63,12 @@ func (s *Service) GetVirtualDevice(c *gin.Context) {
var rsp proto.Response
network, _ := isDeviceExist(virtualNetwork)
media, _ := isDeviceExist(virtualMedia)
disk, _ := isDeviceExist(virtualDisk)
rsp.OkRspWithData(c, &proto.GetVirtualDeviceRsp{
Network: network,
Media: media,
Disk: disk,
})
log.Debugf("get virtual device success")
@@ -80,6 +96,15 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) {
} else {
commands = unmountNetworkCommands
}
case "media":
device = virtualMedia
exist, _ := isDeviceExist(device)
if !exist {
commands = mountMediaCommands
} else {
commands = unmountMediaCommands
}
case "disk":
device = virtualDisk

38
server/utils/hdmi.go Normal file
View File

@@ -0,0 +1,38 @@
package utils
import (
"os"
log "github.com/sirupsen/logrus"
)
const (
HDMIDisableFile = "/etc/kvm/hdmi_disable"
)
func PersistHDMIDisabled() {
f, err := os.OpenFile(HDMIDisableFile, os.O_CREATE|os.O_RDONLY, 0644)
if err != nil {
log.Error("failed to create hdmi disable file:", err)
return
}
f.Close()
}
func PersistHDMIEnabled() {
if err := os.Remove(HDMIDisableFile); err != nil {
log.Error("failed to remove hdmi disable file:", err)
return
}
}
func IsHdmiDisabled() bool {
if _, err := os.Stat(HDMIDisableFile); err != nil {
if os.IsNotExist(err) {
return false // HDMI is enabled
}
log.Error("failed to check hdmi disable file:", err)
return false // Assume HDMI is enabled on error
}
return true // HDMI is disabled
}

Binary file not shown.

View File

@@ -0,0 +1,22 @@
# CROSS_COMPILE = aarch64-none-linux-gnu-
# CROSS_COMPILE = riscv64-unknown-linux-gnu-
CROSS_COMPILE = riscv64-unknown-linux-musl-
# CROSS_COMPILE = riscv64-unknown-elf-
CFLAGS += -I/usr/local/RISC-V-toolchain/riscv64-linux-musl-x86_64/sysroot/usr/include
CC = $(CROSS_COMPILE)gcc
SRC = nanokvm_update_edid.c
TARGET = nanokvm_update_edid
all: $(TARGET)
$(TARGET): $(SRC)
$(CC) -march=rv64gc -o $@ $^
clean:
rm -f $(TARGET)
.PHONY: all clean

Binary file not shown.

View File

@@ -0,0 +1,749 @@
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <linux/i2c-dev.h>
#include <linux/i2c.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <time.h>
#include "nanokvm_update_edid.h"
int client;
// old offset set to 0xff first
static uint8_t old_offset = 0xff;
// =======================================================================================================
int get_edid_from_file(const char *filename, uint8_t *edid_data, uint16_t *edid_size) {
FILE *file = fopen(filename, "rb");
if (!file) {
perror("Failed to open EDID file");
return -1;
}
*edid_size = fread(edid_data, 1, EDID_BUFFER_SIZE, file);
fclose(file);
return 0;
}
int check_edid(uint8_t *edid_data, uint16_t edid_size)
{
uint16_t i;
// Check if the EDID data length is valid
if (edid_size != EDID_BUFFER_SIZE) {
fprintf(stderr, "EDID data length is not %d bytes\n", EDID_BUFFER_SIZE);
return -1; // EDID data length is not enough
}
// Check EDID header
if (edid_data[0] != 0x00 || edid_data[1] != 0xFF ||
edid_data[2] != 0xFF || edid_data[3] != 0xFF ||
edid_data[4] != 0xFF || edid_data[5] != 0xFF ||
edid_data[6] != 0xFF || edid_data[7] != 0x00) {
fprintf(stderr, "EDID header is invalid\n");
return -1; // EDID header is invalid
}
// First 128 Bytes checksum
uint8_t checksum1 = 0;
for (i = 0; i < 127; i++) {
checksum1 += edid_data[i];
}
checksum1 = 0x100 - checksum1; // Reverse
if (checksum1 != edid_data[127]) {
// Checksum for first 128 bytes is incorrect
fprintf(stderr, "Checksum for first 128 bytes is incorrect\n");
return -1;
}
// Second 128 Bytes checksum
uint8_t checksum2 = 0;
for (i = 128; i < 255; i++) {
checksum2 += edid_data[i];
}
checksum2 = 0x100 - checksum2; // Reverse
if (checksum2 != edid_data[255]) {
// Checksum for second 128 bytes is incorrect
fprintf(stderr, "Checksum for second 128 bytes is incorrect\n");
return -1;
}
return 0; // EDID is valid
}
// =======================================================================================================
void NanoKVM_PCIe_HDMI_Reset(void)
{
system("echo 0 > /sys/class/gpio/gpio451/value");
usleep(100000);
system("echo 1 > /sys/class/gpio/gpio451/value");
usleep(100000);
}
// =======================================================================================================
// I2C write function
int i2c_write_byte(uint8_t offset, uint8_t reg, uint8_t data)
{
// reg buffer
uint8_t reg_buf[2] = {0};
// if offset is changed, write it first
if (offset != old_offset) {
old_offset = offset;
reg_buf[0] = LT6911_REG_OFFSET; // Set the offset register
reg_buf[1] = offset; // Set the register to read
if (write(client, reg_buf, 2) != 2) {
perror("Failed to write offset to the i2c bus");
return -1;
}
}
// write the data to the i2c bus
reg_buf[0] = reg; // Set the register to write
reg_buf[1] = data; // Set the data to write
if (write(client, reg_buf, 2) != 2) {
perror("Failed to write to the i2c bus");
return -1;
}
return 0;
}
int i2c_write_bytes(uint8_t offset, uint8_t reg, const uint8_t *data, size_t len)
{
// check len
if (len == 0) {
fprintf(stderr, "Data length must be greater than 0.\n");
return -1;
}
// reg buffer
uint8_t reg_buf[1 + len];
// if offset is changed, write it first
if (offset != old_offset) {
old_offset = offset;
reg_buf[0] = LT6911_REG_OFFSET; // Set the offset register
reg_buf[1] = offset; // Set the register to read
if (write(client, reg_buf, 2) != 2) {
perror("Failed to write offset to the i2c bus");
return -1;
}
}
// write the data to the i2c bus
reg_buf[0] = reg;
for (size_t i = 0; i < len; i++) {
reg_buf[i + 1] = data[i];
}
// write to the I2C bus
if (write(client, reg_buf, 1 + len) != 1 + len) {
perror("Failed to write to the i2c bus");
return -1;
}
return 0;
}
// I2C read function
int i2c_read_byte(uint8_t offset, uint8_t reg, uint8_t *data)
{
// reg buffer
uint8_t reg_buf[2] = {0};
// if offset is changed, write it first
if (offset != old_offset) {
old_offset = offset;
reg_buf[0] = LT6911_REG_OFFSET; // Set the offset register
reg_buf[1] = offset; // Set the register to read
if (write(client, reg_buf, 2) != 2) {
perror("Failed to write offset to the i2c bus");
return -1;
}
}
// write the register address to read
reg_buf[0] = reg; // Set the register to read
if (write(client, reg_buf, 1) != 1) {
perror("Failed to write register address to the i2c bus");
return -1;
}
// read the data from the i2c bus
if (read(client, data, 1) != 1 ) {
perror("Failed to read from the i2c bus");
return -1;
}
return 0;
}
int i2c_read_bytes(uint8_t offset, uint8_t reg, uint8_t *data, size_t len)
{
// 检查数据长度
if (len == 0) {
fprintf(stderr, "Data length must be greater than 0.\n");
return -1;
}
uint8_t reg_buf[2]; // 创建寄存器缓冲区
// 如果偏移量改变,先写入偏移量
if (offset != old_offset) {
old_offset = offset;
reg_buf[0] = LT6911_REG_OFFSET; // 设置偏移寄存器
reg_buf[1] = offset; // 设置偏移值
if (write(client, reg_buf, 2) != 2) {
perror("Failed to write offset to the i2c bus");
return -1;
}
}
// 写入要读取的寄存器地址
reg_buf[0] = reg; // 设置要读取的寄存器
if (write(client, reg_buf, 1) != 1) {
perror("Failed to write register address to the i2c bus");
return -1;
}
// 从 I2C 总线读取数据
if (read(client, data, len) != len) {
perror("Failed to read from the i2c bus");
return -1;
}
return 0;
}
// =======================================================================================================
int lt6911_enable(void) {
// Enable the LT6911UXC by writing to the appropriate register
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xEE, 0x01) != 0) {
fprintf(stderr, "Failed to enable LT6911UXC\n");
return -1;
}
return 0;
}
int lt6911_disable(void) {
// Disable the LT6911UXC by writing to the appropriate register
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xEE, 0x00) != 0) {
fprintf(stderr, "Failed to disable LT6911UXC\n");
return -1;
}
return 0;
}
// =======================================================================================================
int lt6911uxc_edid_write(uint8_t *edid_data, uint16_t edid_size)
{
uint8_t i;
int ret;
uint8_t chip_data[16] = {0};
uint8_t wr_count = edid_size / LT6911UXC_WR_SIZE + 1;
uint8_t version_str[32] = {0};
fprintf(stdout, "Writing EDID....\n");
// Start
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xFF, 0x80) != 0) return -1;
lt6911_enable();
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5E, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x58, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x59, 0x51) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x10) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x58, 0x21) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xFF, 0x80) != 0) return -1;
lt6911_enable();
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x84) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5B, 0x01) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5C, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5D, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x81) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x80) != 0) return -1;
// Waiting for erasure
usleep(500000);
if (i2c_read_byte(LT6911_SYS3_OFFSET, 0x08, chip_data) != 0) return -1;
if (chip_data[0] != 0xEE) {
fprintf(stderr, "Unsupported chip version\n");
return -1;
}
if (i2c_write_byte(LT6911_SYS3_OFFSET, 0x08, 0xAE) != 0) return -1;
if (i2c_write_byte(LT6911_SYS3_OFFSET, 0x08, 0xEE) != 0) return -1;
// Write
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xFF, 0x80) != 0) return -1;
lt6911_enable();
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x84) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x84) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x80) != 0) return -1;
for (i = 0; i < wr_count; i++) {
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5E, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x20) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x58, 0x21) != 0) return -1;
if (i != wr_count-1) {
if (i2c_write_bytes(LT6911_SYS_OFFSET, 0x59, edid_data+(LT6911UXC_WR_SIZE*i), LT6911UXC_WR_SIZE) != 0) return -1;
} else {
if (i2c_write_bytes(LT6911_SYS_OFFSET, 0x59, version_str, LT6911UXC_WR_SIZE) != 0) return -1;
}
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5B, 0x01) != 0) return -1;
if (i != wr_count-1) {
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5C, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5D, 0x00+(LT6911UXC_WR_SIZE*i)) != 0) return -1;
} else {
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5C, 0x81) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5D, 0x00) != 0) return -1;
}
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5E, 0xC0) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x90) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x80) != 0) return -1;
if (i != wr_count-1) {
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x84) != 0) return -1;
} else {
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x88) != 0) return -1;
}
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x80) != 0) return -1;
}
if (i2c_read_byte(LT6911_SYS3_OFFSET, 0x08, chip_data) != 0) return -1;
if (chip_data[0] != 0xEE) return -1;
if (i2c_write_byte(LT6911_SYS3_OFFSET, 0x08, 0xAE) != 0) return -1;
if (i2c_write_byte(LT6911_SYS3_OFFSET, 0x08, 0xEE) != 0) return -1;
fprintf(stdout, "EDID write completed\n");
return 0;
}
int lt6911uxc_edid_read(uint8_t *edid_data, uint16_t edid_size)
{
uint8_t i;
int ret;
uint8_t chip_data[16] = {0};
uint8_t wr_count = edid_size / LT6911UXC_WR_SIZE;
// Read EDID data from LT6911UXC
fprintf(stdout, "Reading EDID...\n");
// Read
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xFF, 0x80) != 0) return -1;
lt6911_enable();
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x84) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x80) != 0) return -1;
for (i = 0; i < wr_count; i++) {
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5E, 0x5F) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0xA0) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5B, 0x01) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5C, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5D, 0x00+(LT6911UXC_WR_SIZE*i)) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x90) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x58, 0x21) != 0) return -1;
if (i2c_read_bytes(LT6911_SYS_OFFSET, 0x5F, edid_data+(LT6911UXC_WR_SIZE*i), LT6911UXC_WR_SIZE) != 0) return -1;
}
return 0;
}
int lt6911c_edid_write(uint8_t *edid_data, uint16_t edid_size)
{
uint8_t i;
int ret;
uint8_t chip_data[16] = {0};
uint8_t wr_count = edid_size / LT6911C_WR_SIZE;
uint8_t version_str[32] = {0};
fprintf(stdout, "Writing EDID....\n");
// Start
lt6911_enable();
if (i2c_read_bytes(LT6911_SYS4_OFFSET, 0x00, chip_data, 2) != 0) return -1;
if (chip_data[0] != 0x16 || chip_data[1] != 0x05) {
fprintf(stderr, "Unsupported chip version\n");
return -1;
}
lt6911_disable();
usleep(100000);
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xFF, 0x80) != 0) return -1;
lt6911_enable();
if (i2c_read_bytes(LT6911_SYS4_OFFSET, 0x00, chip_data, 2) != 0) return -1;
if (chip_data[0] != 0x16 || chip_data[1] != 0x05) {
fprintf(stderr, "Unsupported chip version\n");
return -1;
}
lt6911_disable();
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xFF, 0x80) != 0) return -1;
lt6911_enable();
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5E, 0xC0) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x58, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x59, 0x51) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x92) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xFF, 0x80) != 0) return -1;
lt6911_enable();
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x86) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5B, 0x01) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5C, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5D, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x83) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
// waiting to clean
usleep(500000);
if (i2c_read_byte(LT6911_SYS2_OFFSET, 0x02, chip_data) != 0) return -1;
if (chip_data[0] != 0xFF) {
fprintf(stderr, "Clean Error\n");
return -1;
}
if (i2c_write_byte(LT6911_SYS2_OFFSET, 0x02, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911_SYS2_OFFSET, 0x02, 0xFF) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xFF, 0x80) != 0) return -1;
lt6911_enable();
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x86) != 0) return -1;
for (i = 0; i < wr_count; i++) {
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x86) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5E, 0xEF) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0xA2) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x58, 0x01) != 0) return -1;
if (i2c_write_bytes(LT6911_SYS_OFFSET, 0x59, edid_data+(LT6911C_WR_SIZE*i), LT6911C_WR_SIZE) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5B, 0x01) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5C, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5D, 0x00+(LT6911C_WR_SIZE*i)) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5E, 0xE0) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x92) != 0) return -1;
}
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x8A) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
if (i2c_read_byte(LT6911_SYS2_OFFSET, 0x02, chip_data) != 0) return -1;
if (chip_data[0] != 0xFF) return -1;
if (i2c_write_byte(LT6911_SYS2_OFFSET, 0x02, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911_SYS2_OFFSET, 0x02, 0xFF) != 0) return -1;
fprintf(stdout, "EDID write completed\n");
return 0;
}
int lt6911c_edid_read(uint8_t *edid_data, uint16_t edid_size)
{
uint8_t i;
int ret;
uint8_t chip_data[16] = {0};
uint8_t wr_count = edid_size / LT6911C_WR_SIZE;
// Read EDID data from LT6911UXC
fprintf(stdout, "Reading EDID...\n");
// Read
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xFF, 0x80) != 0) return -1;
lt6911_enable();
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x86) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
for (i = 0; i < wr_count; i++) {
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5E, 0x6F) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0xA2) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5B, 0x01) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5C, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5D, 0x00+(LT6911C_WR_SIZE*i)) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x92) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x5A, 0x82) != 0) return -1;
if (i2c_write_byte(LT6911_SYS_OFFSET, 0x58, 0x01) != 0) return -1;
if (i2c_read_bytes(LT6911_SYS_OFFSET, 0x5F, edid_data+(LT6911C_WR_SIZE*i), LT6911C_WR_SIZE) != 0) return -1;
}
return 0;
}
// =======================================================================================================
int lt6911_edid_config(chip_version_t chip_version, uint8_t *edid_data, uint16_t edid_size)
{
if (chip_version == CHIP_UNKNOWN)
{
fprintf(stderr, "Unknown chip version\n");
return -1;
}
// open i2c device
if ((client = open(I2C_DEVICE, O_RDWR)) < 0) {
perror("Failed to open the i2c bus");
return -1;
}
// // set the lt6911uxc address
if (ioctl(client, I2C_SLAVE, I2C_ADDRESS) < 0) {
perror("Failed to acquire bus access and/or talk to slave");
close(client);
return -1;
}
// edid write
if (chip_version == CHIP_LT6911UXC) {
if (lt6911uxc_edid_write(edid_data, edid_size) != 0) {
fprintf(stderr, "Failed to write EDID data to LT6911UXC\n");
close(client);
return -1;
}
} else if (chip_version == CHIP_LT6911C) {
if (lt6911c_edid_write(edid_data, edid_size) != 0) {
fprintf(stderr, "Failed to write EDID data to LT6911C\n");
close(client);
return -1;
}
} else {
fprintf(stderr, "Unknown chip version\n");
close(client);
return -1;
}
// sleep 1s
sleep(1);
// read edid data back
uint8_t edid_read_data[EDID_BUFFER_SIZE] = {0};
if (chip_version == CHIP_LT6911UXC) {
if (lt6911uxc_edid_read(edid_read_data, EDID_BUFFER_SIZE) != 0) {
fprintf(stderr, "Failed to read EDID data from LT6911UXC\n");
close(client);
return -1;
}
} else if (chip_version == CHIP_LT6911C) {
if (lt6911c_edid_read(edid_read_data, EDID_BUFFER_SIZE) != 0) {
fprintf(stderr, "Failed to read EDID data from LT6911C\n");
close(client);
return -1;
}
} else {
fprintf(stderr, "Unknown chip version\n");
close(client);
return -1;
}
// check if the read data matches the written data
if (memcmp(edid_data, edid_read_data, EDID_BUFFER_SIZE) != 0) {
fprintf(stderr, "EDID data mismatch after write/read cycle\n");
// print edid with 16*16 hex format
fprintf(stderr, "Written EDID data:\n");
for (int i = 0; i < EDID_BUFFER_SIZE; i++) {
fprintf(stderr, "%02X ", edid_data[i]);
if ((i + 1) % 16 == 0) {
fprintf(stderr, "\n");
}
}
fprintf(stderr, "\nRead EDID data:\n");
for (int i = 0; i < EDID_BUFFER_SIZE; i++) {
fprintf(stderr, "%02X ", edid_read_data[i]);
if ((i + 1) % 16 == 0) {
fprintf(stderr, "\n");
}
}
close(client);
return -1;
} else {
fprintf(stdout, "EDID data verified successfully\n");
}
// close client
close(client);
return 0;
}
// =======================================================================================================
void print_warning(product_version_t product_version) {
printf("\n=========================================================\n");
printf("Incorrect EDID may cause issues such as \n");
printf("inability to display images, please modify with caution\n");
printf("=========================================================\n\n");
if (product_version == PRODUCT_CUBE_A || product_version == PRODUCT_CUBE_B) {
printf("\n==========================================================\n");
printf("⚠️ WARNING: Hardware version detected as Cube/Lite!\n");
printf("==========================================================\n");
printf("After flashing, you MUST manually power cycle the device!\n");
printf("Please ensure you can physically disconnect its power,\n");
printf("NOT just remotely reboot it!!\n");
printf("==========================================================\n\n");
}
}
void print_success(product_version_t product_version) {
printf("\n=========================================================\n");
printf("✅ EDID update successful!\n");
if (product_version == PRODUCT_CUBE_A || product_version == PRODUCT_CUBE_B) {
printf("Please manually power cycle the device to apply changes.\n");
}
printf("=========================================================\n\n");
}
int get_user_confirmation() {
char input[256];
printf("Do you want to continue? (Y/N): \n");
while (1) {
if (fgets(input, sizeof(input), stdin) == NULL) {
printf("\nInput error. Exiting.\n");
return 0;
}
// 去除换行符
input[strcspn(input, "\n")] = '\0';
if (strlen(input) == 0) {
continue; // 空输入,重新提示
}
// 转换为小写方便比较
char choice = tolower(input[0]);
if (choice == 'y') {
return 1;
} else if (choice == 'n') {
return 0;
} else {
printf("Invalid input. Please enter Y or N.\n");
}
}
}
// =======================================================================================================
int main(int argc, char *argv[]) {
uint8_t counter = 0;
// Check version
chip_version_t chip_version = CHIP_UNKNOWN;
product_version_t product_version = PRODUCT_UNKNOWN;
char chip_version_str[32] = {0};
char product_version_str[32] = {0};
FILE *file = fopen(VERSION_PATH, "r");
if (file == NULL) {
fprintf(stderr, "Please upgrade to the latest system\n");
return 1;
}
if (fgets(chip_version_str, sizeof(chip_version_str), file) == NULL) {
fprintf(stderr, "Failed to read chip version\n");
fclose(file);
return 1;
}
fclose(file);
file = fopen(PRODUCT_PATH, "r");
if (file == NULL) {
fprintf(stderr, "Please upgrade to the latest system\n");
return 1;
}
if (fgets(product_version_str, sizeof(product_version_str), file) == NULL) {
fprintf(stderr, "Failed to read product version\n");
fclose(file);
return 1;
}
fclose(file);
chip_version_str[strcspn(chip_version_str, "\n")] = '\0';
product_version_str[strcspn(product_version_str, "\n")] = '\0';
if (strcmp(chip_version_str, "c") == 0) {
fprintf(stdout, "Chip Version: LT6911C\n");
chip_version = CHIP_LT6911C;
} else if (strcmp(chip_version_str, "ux") == 0) {
fprintf(stdout, "Chip Version: LT6911UXC\n");
chip_version = CHIP_LT6911UXC;
} else if (strcmp(chip_version_str, "ue") == 0) {
fprintf(stderr, "Chip Version Error: UE version's edid can't be updated\n");
return 1;
} else {
fprintf(stderr, "Chip Version Error: Unknown version\n");
return 1;
}
if (strcmp(product_version_str, "alpha") == 0) {
fprintf(stdout, "Product Version: CUBE_A\n");
product_version = PRODUCT_CUBE_A;
} else if (strcmp(product_version_str, "beta") == 0) {
fprintf(stdout, "Product Version: CUBE_B\n");
product_version = PRODUCT_CUBE_B;
} else if (strcmp(product_version_str, "pcie") == 0) {
fprintf(stdout, "Product Version : PCIE_A\n");
product_version = PRODUCT_PCIE_A;
} else {
fprintf(stderr, "Product Version Error: Unknown version\n");
return 1;
}
print_warning(product_version);
if (product_version == PRODUCT_CUBE_A || product_version == PRODUCT_CUBE_B) {
if (get_user_confirmation() == 0) {
return EXIT_FAILURE;
}
}
// check command line arguments
if (argc != 2) {
fprintf(stderr, "Please enter the location of the EDID file using \"%s /path/to/edid.bin\"\n", argv[0]);
return EXIT_FAILURE;
}
uint16_t edid_size = 0;
uint8_t edid_data[EDID_BUFFER_SIZE] = {0};
if (get_edid_from_file(argv[1], edid_data, &edid_size) != 0) {
fprintf(stderr, "Failed to read EDID data from file %s\n", argv[1]);
return EXIT_FAILURE;
}
if (check_edid(edid_data, edid_size) != 0) {
fprintf(stderr, "EDID data is invalid\n");
return EXIT_FAILURE;
}
fprintf(stdout, "EDID data loaded successfully from %s\n", argv[1]);
if (product_version == PRODUCT_PCIE_A) NanoKVM_PCIe_HDMI_Reset();
// configure lt6911 edid
if (lt6911_edid_config(chip_version, edid_data, edid_size) != 0) {
fprintf(stderr, "Failed to configure LT6911 EDID\n");
return EXIT_FAILURE;
}
if (product_version == PRODUCT_PCIE_A) NanoKVM_PCIe_HDMI_Reset();
print_success(product_version);
return EXIT_SUCCESS;
}

View File

@@ -0,0 +1,41 @@
#ifndef NANOKVM_UPDATE_EDID_H
#define NANOKVM_UPDATE_EDID_H
#define I2C_DEVICE "/dev/i2c-4" // I2C 设备文件
#define EDID_BUFFER_SIZE 256 // 最大支持的字节数
#define I2C_ADDRESS 0x2b // I2C 设备地址(根据实际设备调整)
#define LT6911_REG_OFFSET 0xFF // LT6911UXC 寄存器偏移地址
#define LT6911_SYS_OFFSET 0x80 // LT6911UXC 寄存器偏移地址
#define LT6911_SYS2_OFFSET 0x90 // LT6911UXC 寄存器偏移地址
#define LT6911_SYS3_OFFSET 0x81 // LT6911UXC 寄存器偏移地址
#define LT6911_SYS4_OFFSET 0xA0 // LT6911UXC 寄存器偏移地址
#define LT6911_CSI_INFO_OFFSET 0x85 // LT6911UXC CSI接口信息寄存器偏移地址
#define LT6911_HDMI_INFO_OFFSET 0x86 // LT6911UXC HDMI信息寄存器偏移地址
#define LT6911_CSI_TOTAL_OFFSET 0xD4 // LT6911UXC CSI总线统计信息
#define LT6911_AUDIO_INFO_OFFSET 0xB0 // LT6911UXC 音频信息寄存器偏移地址
#define LT6911C_HDMI_INFO_OFFSET 0xD2 // LT6911C HDMI信息寄存器偏移地址
#define LT6911C_AUDIO_INFO_OFFSET 0xD1 // LT6911C 音频信息寄存器偏移地址
#define LT6911C_CSI_INFO_OFFSET 0xC2 // LT6911C CSI信息寄存器偏移地址
#define EDID_BUFFER_SIZE 256 // 最大支持的字节数
#define LT6911UXC_WR_SIZE 32 // LT6911UXC单次读写最大字节数
#define LT6911C_WR_SIZE 16 // LT6911C单次读写最大字节数
#define VERSION_PATH "/etc/kvm/hdmi_version"
#define PRODUCT_PATH "/etc/kvm/hw"
typedef enum {
CHIP_LT6911UXC = 0,
CHIP_LT6911C,
CHIP_UNKNOWN
} chip_version_t;
typedef enum {
PRODUCT_CUBE_A = 0,
PRODUCT_CUBE_B,
PRODUCT_PCIE_A,
PRODUCT_UNKNOWN,
} product_version_t;
#endif // NANOKVM_UPDATE_EDID_H

View File

@@ -11,12 +11,12 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.5.1",
"@ant-design/icons": "^5.6.1",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"antd": "^5.21.6",
"axios": "^1.8.4",
"antd": "^5.29.1",
"axios": "1.12.0",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"i18next": "^23.16.4",
@@ -38,18 +38,18 @@
"yocto-queue": "^1.2.1"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/semver": "^7.5.8",
"@types/websocket": "^1.0.10",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"@vitejs/plugin-react": "^4.3.3",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
@@ -58,9 +58,9 @@
"postcss": "^8.4.47",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.14",
"tailwindcss": "^3.4.18",
"typescript": "^5.6.3",
"vite": "^6.2.1",
"vite": "7.1.11",
"vite-tsconfig-paths": "^4.3.2"
},
"msw": {

1851
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,3 +26,4 @@ export function setPreviewUpdates(enable: boolean) {
export function getPreviewUpdates() {
return http.get('/api/application/preview');
}

View File

@@ -1,8 +1,8 @@
import { http } from '@/lib/http.ts';
// paste
export function paste(content: string) {
return http.post('/api/hid/paste', { content });
export function paste(content: string, langue: string) {
return http.post('/api/hid/paste', { content, langue });
}
// reset hid

View File

@@ -23,3 +23,10 @@ export function mountImage(file?: string, cdrom?: boolean) {
export function getCdRom() {
return http.get('/api/storage/cdrom');
}
export function deleteImage(file: string) {
const data = {
file
};
return http.post('/api/storage/image/delete', data);
}

View File

@@ -1,4 +1,5 @@
const languages = [
{ key: 'ca', name: 'Català' },
{ key: 'nl', name: 'Nederlands' },
{ key: 'da', name: 'Danish' },
{ key: 'de', name: 'Deutsch' },
@@ -8,7 +9,9 @@ const languages = [
{ key: 'id', name: 'Indonesia' },
{ key: 'it', name: 'Italian' },
{ key: 'pl', name: 'Polski' },
{ key: 'pt_br', name: 'Português (Brasil)' },
{ key: 'ru', name: 'Русский' },
{ key: 'tr', name: 'Türkçe'},
{ key: 'ko', name: '한국어' },
{ key: 'zh', name: '简体中文' },
{ key: 'zh_tw', name: '繁體中文' },
@@ -18,7 +21,8 @@ const languages = [
{ key: 'cz', name: 'Česky' },
{ key: 'uk', name: 'Українська' },
{ key: 'nb', name: 'Norsk, bokmål' },
{ key: 'th', name: 'ภาษาไทย' }
{ key: 'th', name: 'ภาษาไทย' },
{ key: 'se', name: "Svenska"}
];
languages.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));

347
web/src/i18n/locales/ca.ts Normal file
View File

@@ -0,0 +1,347 @@
const ca = {
translation: {
head: {
desktop: "Escriptori remot",
login: "Inici de sessió",
changePassword: "Canviar contrasenya",
terminal: "Terminal",
wifi: "Xarxa Sense fils"
},
auth: {
login: "Inici de sessió",
placeholderUsername: "Nom d'usuari",
placeholderPassword: "Contrasenya",
placeholderPassword2: "Torna a introduir la contrasenya",
noEmptyUsername: "Cal introduir el nom d'usuari",
noEmptyPassword: "Cal introduir la contrasenya",
noAccount: "No s'ha pogut obtenir la informació de l'usuari, actualitza la pàgina web o restableix la contrasenya",
invalidUser: "Nom d'usuari o contrasenya invàlids",
error: "Error inesperat",
changePassword: "Canviar la contrasenya",
changePasswordDesc: "Per a la seguretat del dispositiu, canvia la contrasenya!",
differentPassword: "Les contrasenyes no coincideixen",
illegalUsername: "El nom d'usuari conté caràcters no permesos",
illegalPassword: "La contrasenya conté caràcters no permesos",
forgetPassword: "Has oblidat la contrasenya",
ok: "D'acord",
cancel: "Cancel·la",
loginButtonText: "Inicia sessió",
tips: {
reset1: "Per restablir les contrasenyes, mantingues premut el botó BOOT del NanoKVM durant 10 segons.",
reset2: "Per veure els passos detallats, consulta aquest document:",
reset3: "Compte web per defecte:",
reset4: "Compte SSH per defecte:",
change1: "Aquesta acció canviarà les següents contrasenyes:",
change2: "Contrasenya d'inici de sessió web",
change3: "Contrasenya root del sistema (inici de sessió SSH)",
change4: "Per restablir les contrasenyes, mantingues premut el botó BOOT del NanoKVM."
}
},
wifi: {
title: "Xarxa Sense fils",
description: "Configura la xarxa sense fils per al NanoKVM",
success: "Comprova l'estat de la xarxa del NanoKVM i visita la nova adreça IP.",
failed: "L'operació ha fallat, torna-ho a intentar.",
confirmBtn: "D'acord",
finishBtn: "Fet"
},
screen: {
title: "Pantalla",
video: "Mode de vídeo",
videoDirectTips: "Activa HTTPS a 'Configuració > Dispositiu' per utilitzar aquest mode",
resolution: "Resolució",
auto: "Automàtic",
autoTips: "Poden aparèixer talls o desajustos del ratolí en certes resolucions. Prova a canviar la resolució de l'amfitrió remot o desactiva el mode automàtic.",
fps: "FPS",
customizeFps: "Personalitzat",
quality: "Qualitat",
qualityLossless: "Sense pèrdua",
qualityHigh: "Alta",
qualityMedium: "Mitjana",
qualityLow: "Baixa",
frameDetect: "Detecció de fotogrames",
frameDetectTip: "Calcula la diferència entre fotogrames. S'atura la transmissió si no hi ha canvis a la pantalla de l'amfitrió remot.",
resetHdmi: "Restablir HDMI"
},
keyboard: {
title: "Teclat",
paste: "Enganxa",
tips: "Només es permeten lletres i símbols estàndard",
placeholder: "Escriu aquí",
submit: "Envia",
virtual: "Teclat",
ctrlaltdel: "Ctrl+Alt+Del"
},
mouse: {
title: "Ratolí",
cursor: "Estil del cursor",
default: "Cursor per defecte",
pointer: "Cursor punter",
cell: "Cursor de cel·la",
text: "Cursor de text",
grab: "Cursor de mà",
hide: "Amaga el cursor",
mode: "Mode de ratolí",
absolute: "Mode absolut",
relative: "Mode relatiu",
speed: "Velocitat de la roda",
fast: "Ràpida",
slow: "Lenta",
requestPointer: "Estàs usant el mode relatiu. Fes clic a l'escriptori per obtenir el punter.",
resetHid: "Restablir HID",
hidOnly: {
title: "Mode només HID",
desc: "Si el teclat o ratolí deixen de funcionar i restablir HID no ajuda, pot ser un problema de compatibilitat. Prova d'activar el mode només HID.",
tip1: "Activar aquest mode desmuntarà el disc virtual i la xarxa virtual",
tip2: "En mode només HID, no es pot muntar imatges",
tip3: "El NanoKVM es reiniciarà automàticament en canviar de mode",
enable: "Activa mode només HID",
disable: "Desactiva mode només HID"
}
},
image: {
title: "Imatges",
loading: "Carregant...",
empty: "No s'ha trobat res",
cdrom: "Munta la imatge en mode CD-ROM",
mountFailed: "Error en muntar",
mountDesc: "En alguns sistemes cal expulsar el disc virtual abans de muntar la imatge.",
refresh: "Actualitza la llista",
tips: {
title: "Com pujar imatges",
usb1: "Connecta el NanoKVM al teu ordinador via USB.",
usb2: "Assegura't que el disc virtual està muntat (Configuració - Disc Virtual).",
usb3: "Obre el disc virtual i copia la imatge a l'arrel.",
scp1: "Comprova que el NanoKVM i el teu ordinador estan a la mateixa xarxa.",
scp2: "Obre un terminal i usa SCP per pujar la imatge al directori /data del NanoKVM.",
scp3: "Exemple: scp ruta-de-la-imatge root@ip-del-nanokvm:/data",
tfCard: "Targeta TF",
tf1: "Mètode disponible a sistemes GNU/Linux",
tf2: "Extreu la targeta TF del NanoKVM (versió FULL, cal obrir la carcassa).",
tf3: "Introdueix la targeta en un lector i connecta'l a l'ordinador.",
tf4: "Copia la imatge al directori /data de la targeta.",
tf5: "Reintrodueix la targeta al NanoKVM."
}
},
script: {
title: "Scripts",
upload: "Puja",
run: "Executa",
runBackground: "Executa en segon pla",
runFailed: "Error en l'execució",
attention: "Atenció",
delDesc: "Estàs segur que vols eliminar aquest fitxer?",
confirm: "Sí",
cancel: "No",
delete: "Esborra",
close: "Tanca"
},
terminal: {
title: "Terminal",
nanokvm: "Terminal NanoKVM",
serial: "Terminal de port sèrie",
serialPort: "Port sèrie",
serialPortPlaceholder: "Introdueix el port sèrie",
baudrate: "Velocitat (baudrate)",
parity: "Paritat",
parityNone: "Cap",
parityEven: "Parell",
parityOdd: "Senar",
flowControl: "Control de flux",
flowControlNone: "Cap",
flowControlSoft: "Suau",
flowControlHard: "Dur",
dataBits: "Bits de dades",
stopBits: "Bits de parada",
confirm: "D'acord"
},
wol: {
title: "Wake-on-LAN",
sending: "Enviant comanda...",
sent: "Comanda enviada",
input: "Introdueix la MAC",
ok: "D'acord"
},
download: {
title: "Descarregador d'imatges",
input: "Introdueix la URL de la imatge",
ok: "D'acord",
disabled: "La partició /data és només lectura. No es pot descarregar la imatge."
},
power: {
title: "Alimentació",
showConfirm: "Confirmació",
showConfirmTip: "Les operacions d'alimentació requereixen confirmació",
reset: "Reinicia",
power: "Encén",
powerShort: "Clic curt",
powerLong: "Clic llarg",
resetConfirm: "Vols realment reiniciar?",
powerConfirm: "Vols realment encendre/apagar?",
okBtn: "Sí",
cancelBtn: "No"
},
settings: {
title: "Configuració",
about: {
title: "Sobre NanoKVM",
information: "Informació",
ip: "IP",
mdns: "mDNS",
application: "Versió aplicació",
applicationTip: "Versió de la interfície web de NanoKVM",
image: "Versió de la imatge",
imageTip: "Versió del sistema NanoKVM",
deviceKey: "Clau del dispositiu",
community: "Comunitat",
hostname: "Nom del dispositiu",
hostnameUpdated: "Nom actualitzat. Reinicia per aplicar.",
ipType: {
Wired: "Cablejada",
Wireless: "Sense fil",
Other: "Altra"
}
},
appearance: {
title: "Aparença",
display: "Pantalla",
language: "Idioma",
menuBar: "Barra de menú",
menuBarDesc: "Mostra icones a la barra de menú",
webTitle: "Títol web",
webTitleDesc: "Personalitza el títol de la pàgina"
},
device: {
title: "Dispositiu",
oled: {
title: "OLED",
description: "Apagar pantalla OLED després de",
0: "Mai",
15: "15 s",
30: "30 s",
60: "1 min",
180: "3 min",
300: "5 min",
600: "10 min",
1800: "30 min",
3600: "1 h"
},
wifi: {
title: "Xarxa sense fils",
description: "Configura la xarxa sense fils",
setBtn: "Configura"
},
ssh: {
description: "Activa accés remot per SSH",
tip: "Configura una contrasenya segura abans (Compte - Canvia contrasenya)"
},
tls: {
description: "Activa el protocol HTTPS",
tip: "Atenció: Usar HTTPS pot augmentar la latència, sobretot amb vídeo MJPEG."
},
advanced: "Configuració avançada",
swap: {
title: "Swap",
disable: "Desactiva",
description: "Defineix la mida del fitxer swap",
tip: "Pot reduir la vida útil de la targeta SD!"
},
mouseJiggler: {
title: "Mou-ratolí automàtic",
description: "Evita que el dispositiu remot entri en repòs",
disable: "Desactiva",
absolute: "Mode absolut",
relative: "Mode relatiu"
},
mdns: {
description: "Activa descobriment mDNS",
tip: "Desactiva-ho si no és necessari"
},
hdmi: {
description: "Activa la sortida HDMI"
},
hidOnly: "Mode només HID",
disk: "Disc virtual",
diskDesc: "Munta un disc U virtual al dispositiu remot",
network: "Xarxa virtual",
networkDesc: "Munta una targeta de xarxa virtual al dispositiu remot",
reboot: "Reinicia",
rebootDesc: "Segur que vols reiniciar el NanoKVM?",
okBtn: "Sí",
cancelBtn: "No"
},
tailscale: {
title: "Tailscale",
memory: {
title: "Optimització de memòria",
tip: "Quan es supera el límit de memòria, es fa una neteja més agressiva. Recomanat: 75MB si uses Tailscale. Requereix reiniciar Tailscale.",
disable: "Desactiva"
},
restart: "Reiniciar Tailscale?",
stop: "Aturar Tailscale?",
stopDesc: "Tanca la sessió i desactiva l'inici automàtic",
loading: "Carregant...",
notInstall: "Tailscale no instal·lat! Instal·la-ho.",
install: "Instal·la",
installing: "Instal·lant",
failed: "Error en la instal·lació",
retry: "Actualitza i torna-ho a provar. O instal·la manualment",
download: "Descarrega el",
package: "paquet d'instal·lació",
unzip: "i descomprimeix-lo",
upTailscale: "Puja tailscale a /usr/bin/",
upTailscaled: "Puja tailscaled a /usr/sbin/",
refresh: "Actualitza la pàgina",
notLogin: "El dispositiu no està vinculat. Inicia sessió per vincular-lo.",
urlPeriod: "Aquesta URL és vàlida durant 10 minuts",
login: "Inicia sessió",
loginSuccess: "Sessió iniciada correctament",
enable: "Activa Tailscale",
deviceName: "Nom del dispositiu",
deviceIP: "IP del dispositiu",
account: "Compte",
logout: "Tanca sessió",
logoutDesc: "Segur que vols tancar sessió?",
uninstall: "Desinstal·la Tailscale",
okBtn: "Sí",
cancelBtn: "No"
},
update: {
title: "Comprova actualitzacions",
queryFailed: "Error en obtenir la versió",
updateFailed: "Error en actualitzar. Torna-ho a intentar.",
isLatest: "Ja tens la darrera versió.",
available: "Hi ha una actualització disponible. Vols actualitzar ara?",
updating: "Actualitzant... espera",
confirm: "Confirma",
cancel: "Cancel·la",
preview: "Versió de prova",
previewDesc: "Prova noves funcions abans que ningú",
previewTip: "Compte: aquestes versions poden tenir errors o funcions inacabades!"
},
account: {
title: "Compte",
webAccount: "Nom del compte web",
password: "Contrasenya",
updateBtn: "Canvia",
logoutBtn: "Tanca sessió",
logoutDesc: "Segur que vols tancar sessió?",
okBtn: "Sí",
cancelBtn: "No"
}
},
error: {
title: "Hi ha hagut un error",
refresh: "Actualitza"
},
fullscreen: {
toggle: "Pantalla completa"
},
menu: {
collapse: "Amaga menú",
expand: "Mostra menú"
}
}
};
export default ca;

View File

@@ -68,11 +68,13 @@ const de = {
keyboard: {
title: 'Tastatur',
paste: 'Einfügen',
tips: 'Nur Standard-Tastaturbuchstaben und Symbole werden unterstützt',
tips: 'Server Tastaturbelegung',
placeholder: 'Bitte eingeben',
submit: 'Senden',
virtual: 'Tastatur',
ctrlaltdel: 'Ctrl+Alt+Del'
ctrlaltdel: 'Ctrl+Alt+Del',
dropdownEnglish: 'Englisch',
dropdownGerman: 'Deutsch'
},
mouse: {
title: 'Maus',

View File

@@ -73,7 +73,13 @@ const en = {
placeholder: 'Please input',
submit: 'Submit',
virtual: 'Keyboard',
ctrlaltdel: 'Ctrl+Alt+Del'
ctrlaltdel: 'Ctrl+Alt+Del',
readClipboard: 'Read from Clipboard',
clipboardPermissionDenied:
'Clipboard permission denied. Please allow clipboard access in your browser.',
clipboardReadError: 'Failed to read clipboard',
dropdownEnglish: 'English',
dropdownGerman: 'German'
},
mouse: {
title: 'Mouse',
@@ -106,11 +112,18 @@ const en = {
title: 'Images',
loading: 'Loading...',
empty: 'Nothing Found',
cdrom: 'Mount the image in CD-ROM mode',
mountFailed: 'Mount Failed',
mountMode: 'Mount mode',
mountFailed: 'Mount failed',
mountDesc:
"In some systems, it's necessary to eject the virtual disk on the remote host before mounting the image.",
'On some systems, you need to eject the virtual disk from the remote host before mounting the image.',
unmountFailed: 'Unmount failed',
unmountDesc:
'On some systems, you need to manually eject from the remote host before unmounting the image.',
refresh: 'Refresh the image list',
attention: 'Attention',
deleteConfirm: 'Are you sure you want to delete this image?',
okBtn: 'Yes',
cancelBtn: 'No',
tips: {
title: 'How to upload',
usb1: 'Connect the NanoKVM to your computer via USB.',
@@ -266,7 +279,9 @@ const en = {
},
hidOnly: 'HID-Only Mode',
disk: 'Virtual Disk',
diskDesc: 'Mount virtual U-disk on the remote host',
diskDesc: 'Mount SD card on the remote host',
media: "Virtual Image",
mediaDesc: 'Attach virtual image device to the remote host',
network: 'Virtual Network',
networkDesc: 'Mount virtual network card on the remote host',
reboot: 'Reboot',
@@ -280,8 +295,11 @@ const en = {
title: 'Tailscale',
memory: {
title: 'Memory optimization',
tip: "When memory usage exceeds the limit, garbage collection is performed more aggressively to attempt to free up memory. It's recommended to set to 75MB if using Tailscale. A Tailscale restart is required for the change to take effect.",
disable: 'Disable'
tip: 'When memory usage exceeds the limit, garbage collection is performed more aggressively to attempt to free up memory. A Tailscale restart is required for the change to take effect.'
},
swap: {
title: 'Swap memory',
tip: 'If issues persist after enabling memory optimization, try enabling swap memory. This sets the swap file size to 256MB by default, which can be adjusted in "Settings > Device".'
},
restart: 'Restart Tailscale?',
stop: 'Stop Tailscale?',
@@ -298,6 +316,8 @@ const en = {
upTailscale: 'Upload tailscale to NanoKVM directory /usr/bin/',
upTailscaled: 'Upload tailscaled to NanoKVM directory /usr/sbin/',
refresh: 'Refresh current page',
notRunning: 'Tailscale is not running. Please start it to continue.',
run: 'Start',
notLogin:
'The device has not been bound yet. Please login and bind this device to your account.',
urlPeriod: 'This url is valid for 10 minutes',
@@ -310,6 +330,7 @@ const en = {
logout: 'Logout',
logoutDesc: 'Are you sure you want to logout?',
uninstall: 'Uninstall Tailscale',
uninstallDesc: 'Are you sure you want to uninstall Tailscale?',
okBtn: 'Yes',
cancelBtn: 'No'
},

View File

@@ -47,13 +47,15 @@ const ko = {
finishBtn: '완료'
},
screen: {
title: '화면',
video: '비디오 모드',
videoDirectTips: '이 모드를 사용하려면 "설정 > 장치"에서 HTTPS를 활성화하세요',
resolution: '해상도',
auto: '자동 설정',
autoTips:
'일부 해상도에서는 화면이 왜곡되거나 마우스 동작이 비정상적으로 나타날 수 있습니다. 원격 컴퓨터의 해상도를 변경하거나 자동 설정 대신 수동 설정을 사용해 보세요.',
fps: 'FPS',
customizeFps: 'FPS 설정',
customizeFps: '사용자 지정',
quality: '품질',
qualityLossless: '무손실',
qualityHigh: '높음',
@@ -65,6 +67,7 @@ const ko = {
resetHdmi: 'HDMI 초기화'
},
keyboard: {
title: '키보드',
paste: '붙여넣기',
tips: '표준 키보드 문자 및 기호만 지원됩니다',
placeholder: '입력하세요',
@@ -73,6 +76,8 @@ const ko = {
ctrlaltdel: 'Ctrl+Alt+Del'
},
mouse: {
title: '마우스',
cursor: '커서 스타일',
default: '기본 커서',
pointer: '포인터 커서',
cell: '셀 커서',
@@ -82,8 +87,20 @@ const ko = {
mode: '마우스 모드',
absolute: '절대값 모드',
relative: '상대값 모드',
speed: '휠 속도',
fast: '빠름',
slow: '느림',
requestPointer: '상대값 모드를 사용 중입니다. 커서를 찾으려면 데스크톱을 클릭하세요.',
resetHid: 'HID 초기화'
resetHid: 'HID 초기화',
hidOnly: {
title: 'HID 전용 모드',
desc: '마우스와 키보드가 응답하지 않고 HID 초기화도 도움이 되지 않는다면, NanoKVM과 장치 간의 호환성 문제일 수 있습니다. 더 나은 호환성을 위해 HID 전용 모드를 활성화해 보세요.',
tip1: 'HID 전용 모드를 활성화하면 가상 USB와 가상 네트워크가 언마운트됩니다',
tip2: 'HID 전용 모드에서는 이미지 마운트가 비활성화됩니다',
tip3: '모드 전환 후 NanoKVM이 자동으로 재부팅됩니다',
enable: 'HID 전용 모드 활성화',
disable: 'HID 전용 모드 비활성화'
}
},
image: {
title: '이미지',
@@ -129,7 +146,17 @@ const ko = {
serial: '시리얼 포트 터미널',
serialPort: '시리얼 포트',
serialPortPlaceholder: '시리얼 포트를 입력하세요',
baudrate: 'Baud rate',
baudrate: '전송 속도',
parity: '패리티',
parityNone: '없음',
parityEven: '짝수',
parityOdd: '홀수',
flowControl: '흐름 제어',
flowControlNone: '없음',
flowControlSoft: '소프트웨어',
flowControlHard: '하드웨어',
dataBits: '데이터 비트',
stopBits: '정지 비트',
confirm: '확인'
},
wol: {
@@ -147,10 +174,16 @@ const ko = {
},
power: {
title: '전원',
showConfirm: '확인',
showConfirmTip: '전원 작업에는 추가 확인이 필요합니다',
reset: '리셋',
power: '전원',
powerShort: '전원 (짧게 누르기)',
powerLong: '전원 (길게 누르기)'
powerLong: '전원 (길게 누르기)',
resetConfirm: '리셋 작업을 진행하시겠습니까?',
powerConfirm: '전원 작업을 진행하시겠습니까?',
okBtn: '네',
cancelBtn: '아니오'
},
settings: {
title: '설정',
@@ -164,14 +197,23 @@ const ko = {
image: '이미지 버전',
imageTip: 'NanoKVM 시스템 이미지 버전',
deviceKey: '장치 키',
community: '커뮤니티'
community: '커뮤니티',
hostname: '호스트 이름',
hostnameUpdated: '호스트 이름이 업데이트되었습니다. 적용하려면 재부팅하세요.',
ipType: {
Wired: '유선',
Wireless: '무선',
Other: '기타'
}
},
appearance: {
title: '디자인',
display: '표시',
language: '언어',
menuBar: '메뉴 바',
menuBarDesc: '메뉴 바에 아이콘을 표시'
menuBarDesc: '메뉴 바에 아이콘을 표시',
webTitle: '웹 제목',
webTitleDesc: '웹 페이지 제목 사용자 지정'
},
device: {
title: '장치',
@@ -197,10 +239,40 @@ const ko = {
description: 'SSH 원격 접속 활성화',
tip: '활성화하기 전에 강력한 비밀번호를 설정하세요. (계정 - 비밀번호 변경)'
},
tls: {
description: 'HTTPS 프로토콜 활성화',
tip: '주의: HTTPS 사용 시 특히 MJPEG 비디오 모드에서 지연 시간이 증가할 수 있습니다.'
},
advanced: '고급 설정',
swap: {
title: '스왑',
disable: '비활성화',
description: '스왑 파일 크기 설정',
tip: '이 기능을 활성화하면 SD 카드의 수명이 단축될 수 있습니다!'
},
mouseJiggler: {
title: '마우스 흔들기',
description: '원격 호스트가 절전 모드로 진입하는 것을 방지',
disable: '비활성화',
absolute: '절대값 모드',
relative: '상대값 모드'
},
mdns: {
description: 'mDNS 검색 서비스 활성화',
tip: '사용하지 않는 경우 끄는 것이 좋습니다'
},
hdmi: {
description: 'HDMI/모니터 출력 활성화'
},
hidOnly: 'HID 전용 모드',
disk: '가상 디스크',
diskDesc: '원격 호스트에서 가상 U-디스크를 마운트합니다.',
diskDesc: '원격 호스트에서 가상 USB를 마운트합니다.',
network: '가상 네트워크',
networkDesc: '원격 호스트에서 가상 네트워크 카드를 마운트합니다.'
networkDesc: '원격 호스트에서 가상 네트워크 카드를 마운트합니다.',
reboot: '재부팅',
rebootDesc: 'NanoKVM을 재부팅하시겠습니까?',
okBtn: '네',
cancelBtn: '아니오'
},
tailscale: {
title: 'Tailscale',
@@ -234,6 +306,7 @@ const ko = {
deviceIP: '장치 IP',
account: '계정',
logout: '로그아웃',
logoutDesc: '정말로 로그아웃 하시겠습니까?',
logout2: '정말로 로그아웃 합니까?',
uninstall: 'Tailscale 제거',
okBtn: '네',
@@ -247,19 +320,32 @@ const ko = {
available: '업데이트가 가능합니다. 정말로 업데이트 할까요?',
updating: '업데이트 시작. 잠시 기다려주세요...',
confirm: '확인',
cancel: '취소'
cancel: '취소',
preview: '미리보기 업데이트',
previewDesc: '새로운 기능과 개선 사항에 미리 접근하세요',
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!'
},
account: {
title: '계정',
webAccount: 'Web 계정',
webAccount: ' 계정',
password: '비밀번호',
updateBtn: '업데이트',
logoutBtn: '로그아웃'
logoutBtn: '로그아웃',
logoutDesc: '정말로 로그아웃 하시겠습니까?',
okBtn: '네',
cancelBtn: '아니오'
}
},
error: {
title: '문제가 발생했습니다.',
refresh: '새로고침'
},
fullscreen: {
toggle: '전체 화면 전환'
},
menu: {
collapse: '메뉴 접기',
expand: '메뉴 펼치기'
}
}
};

View File

@@ -0,0 +1,347 @@
const pt_br = {
translation: {
head: {
desktop: 'Área de Trabalho Remota',
login: 'Login',
changePassword: 'Mudar Senha',
terminal: 'Terminal',
wifi: 'Wi-Fi'
},
auth: {
login: 'Login',
placeholderUsername: 'Nome de usuário',
placeholderPassword: 'Senha',
placeholderPassword2: 'Por favor, digite a senha novamente',
noEmptyUsername: 'Nome de usuário é obrigatório',
noEmptyPassword: 'Senha é obrigatória',
noAccount: 'Falha ao obter informações do usuário, por favor atualize a página ou redefina a senha',
invalidUser: 'Nome de usuário ou senha inválidos',
error: 'Erro inesperado',
changePassword: 'Mudar Senha',
changePasswordDesc: 'Para a segurança do seu dispositivo, por favor, mude a senha!',
differentPassword: 'Senhas não conferem',
illegalUsername: 'Nome de usuário contém caracteres inválidos',
illegalPassword: 'Senha contém caracteres inválidos',
forgetPassword: 'Esqueci a senha',
ok: 'Ok',
cancel: 'Cancelar',
loginButtonText: 'Login',
tips: {
reset1: 'Para redefinir as senhas, pressione e segure o botão BOOT no NanoKVM por 10 segundos.',
reset2: 'Para etapas detalhadas, por favor, consulte este documento:',
reset3: 'Conta padrão da Web:',
reset4: 'Conta padrão SSH:',
change1: 'Por favor, note que esta ação irá alterar as seguintes senhas:',
change2: 'Senha de login da Web',
change3: 'Senha root do sistema (senha de login SSH)',
change4: 'Para redefinir as senhas, pressione e segure o botão BOOT no NanoKVM.'
}
},
wifi: {
title: 'Wi-Fi',
description: 'Configurar Wi-Fi para o NanoKVM',
success: 'Por favor, verifique o status da rede do NanoKVM e visite o novo endereço IP.',
failed: 'Operação falhou, por favor, tente novamente.',
confirmBtn: 'Ok',
finishBtn: 'Finalizado'
},
screen: {
title: 'Tela',
video: 'Modo de Vídeo',
videoDirectTips: 'Ative HTTPS em "Configurações > Dispositivo" para usar este modo',
resolution: 'Resolução',
auto: 'Automático',
autoTips: 'Rasgos na tela ou desvio do mouse podem ocorrer em resoluções específicas. Considere ajustar a resolução do host remoto ou desativar o modo automático.',
fps: 'FPS',
customizeFps: 'Personalizar',
quality: 'Qualidade',
qualityLossless: 'Sem perdas',
qualityHigh: 'Alta',
qualityMedium: 'Média',
qualityLow: 'Baixa',
frameDetect: 'Detecção de Quadros',
frameDetectTip: 'Calcular a diferença entre os quadros. Parar a transmissão de vídeo quando nenhuma alteração for detectada na tela do host remoto.',
resetHdmi: 'Redefinir HDMI'
},
keyboard: {
title: 'Teclado',
paste: 'Colar',
tips: 'Apenas letras e símbolos de teclado padrão são suportados',
placeholder: 'Por favor, digite',
submit: 'Enviar',
virtual: 'Teclado',
ctrlaltdel: 'Ctrl+Alt+Del'
},
mouse: {
title: 'Mouse',
cursor: 'Estilo do cursor',
default: 'Cursor padrão',
pointer: 'Cursor de ponteiro',
cell: 'Cursor de célula',
text: 'Cursor de texto',
grab: 'Cursor de arrastar',
hide: 'Ocultar cursor',
mode: 'Modo do mouse',
absolute: 'Modo absoluto',
relative: 'Modo relativo',
speed: 'Velocidade da roda',
fast: 'Rápido',
slow: 'Lento',
requestPointer: 'Usando modo relativo. Por favor, clique na área de trabalho para obter o ponteiro do mouse.',
resetHid: 'Redefinir HID',
hidOnly: {
title: 'Modo Somente-HID',
desc: 'Se o seu mouse e teclado pararem de responder e a redefinição de HID não ajudar, pode ser um problema de compatibilidade entre o NanoKVM e o dispositivo. Tente habilitar o modo Somente-HID para melhor compatibilidade.',
tip1: 'Habilitar o modo Somente-HID irá desmontar o U-disk virtual e a rede virtual',
tip2: 'No modo Somente-HID, a montagem de imagem está desativada',
tip3: 'NanoKVM será reiniciado automaticamente após a troca de modos',
enable: 'Habilitar modo Somente-HID',
disable: 'Desabilitar modo Somente-HID'
}
},
image: {
title: 'Imagens',
loading: 'Carregando...',
empty: 'Nada Encontrado',
cdrom: 'Montar a imagem no modo CD-ROM',
mountFailed: 'Falha na Montagem',
mountDesc: 'Em alguns sistemas, é necessário ejetar o disco virtual no host remoto antes de montar a imagem.',
refresh: 'Atualizar a lista de imagens',
tips: {
title: 'Como fazer upload',
usb1: 'Conecte o NanoKVM ao seu computador via USB.',
usb2: 'Certifique-se de que o disco virtual está montado (Configurações - Disco Virtual).',
usb3: 'Abra o disco virtual no seu computador e copie o arquivo de imagem para o diretório raiz do disco virtual.',
scp1: 'Certifique-se de que o NanoKVM e seu computador estão na mesma rede local.',
scp2: 'Abra um terminal no seu computador e use o comando SCP para fazer upload do arquivo de imagem para o diretório /data no NanoKVM.',
scp3: 'Exemplo: scp seu-caminho-da-imagem root@seu-ip-nanokvm:/data',
tfCard: 'Cartão TF',
tf1: 'Este método é suportado em sistemas Linux',
tf2: 'Remova o cartão TF do NanoKVM (para a versão FULL, desmonte a caixa primeiro).',
tf3: 'Insira o cartão TF em um leitor de cartão e conecte-o ao seu computador.',
tf4: 'Copie o arquivo de imagem para o diretório /data no cartão TF.',
tf5: 'Insira o cartão TF no NanoKVM.'
}
},
script: {
title: 'Scripts',
upload: 'Upload',
run: 'Executar',
runBackground: 'Executar em segundo plano',
runFailed: 'Falha na execução',
attention: 'Atenção',
delDesc: 'Tem certeza de que deseja excluir este arquivo?',
confirm: 'Sim',
cancel: 'Não',
delete: 'Excluir',
close: 'Fechar'
},
terminal: {
title: 'Terminal',
nanokvm: 'Terminal NanoKVM',
serial: 'Terminal de Porta Serial',
serialPort: 'Porta Serial',
serialPortPlaceholder: 'Por favor, digite a porta serial',
baudrate: 'Taxa de transmissão',
parity: 'Paridade',
parityNone: 'Nenhum',
parityEven: 'Par',
parityOdd: 'Ímpar',
flowControl: 'Controle de fluxo',
flowControlNone: 'Nenhum',
flowControlSoft: 'Software',
flowControlHard: 'Hardware',
dataBits: 'Bits de dados',
stopBits: 'Bits de parada',
confirm: 'Ok'
},
wol: {
title: 'Wake-on-LAN',
sending: 'Enviando comando...',
sent: 'Comando enviado',
input: 'Por favor, digite o MAC',
ok: 'Ok'
},
download: {
title: 'Baixador de Imagens',
input: 'Por favor, digite uma URL de imagem remota',
ok: 'Ok',
disabled: 'A partição /data é RO, então não podemos baixar a imagem'
},
power: {
title: 'Energia',
showConfirm: 'Confirmação',
showConfirmTip: 'Operações de energia requerem uma confirmação extra',
reset: 'Redefinir',
power: 'Energia',
powerShort: 'Energia (clique curto)',
powerLong: 'Energia (clique longo)',
resetConfirm: 'Prosseguir com a operação de redefinição?',
powerConfirm: 'Prosseguir com a operação de energia?',
okBtn: 'Sim',
cancelBtn: 'Não'
},
settings: {
title: 'Configurações',
about: {
title: 'Sobre o NanoKVM',
information: 'Informação',
ip: 'IP',
mdns: 'mDNS',
application: 'Versão do Aplicativo',
applicationTip: 'Versão do aplicativo web NanoKVM',
image: 'Versão da Imagem',
imageTip: 'Versão da imagem do sistema NanoKVM',
deviceKey: 'Chave do Dispositivo',
community: 'Comunidade',
hostname: 'Nome do Host',
hostnameUpdated: 'Nome do host atualizado. Reinicie para aplicar.',
ipType: {
Wired: 'Com Fio',
Wireless: 'Sem Fio',
Other: 'Outro'
}
},
appearance: {
title: 'Aparência',
display: 'Exibição',
language: 'Idioma',
menuBar: 'Barra de Menu',
menuBarDesc: 'Exibir ícones na barra de menu',
webTitle: 'Título da Web',
webTitleDesc: 'Personalizar o título da página web'
},
device: {
title: 'Dispositivo',
oled: {
title: 'OLED',
description: 'Desligar tela OLED após',
0: 'Nunca',
15: '15 seg',
30: '30 seg',
60: '1 min',
180: '3 min',
300: '5 min',
600: '10 min',
1800: '30 min',
3600: '1 hora'
},
wifi: {
title: 'Wi-Fi',
description: 'Configurar Wi-Fi',
setBtn: 'Configurar'
},
ssh: {
description: 'Habilitar acesso remoto SSH',
tip: 'Defina uma senha forte antes de habilitar (Conta - Mudar Senha)'
},
tls: {
description: 'Habilitar protocolo HTTPS',
tip: 'Atenção: O uso de HTTPS pode aumentar a latência, especialmente com o modo de vídeo MJPEG.'
},
advanced: 'Configurações Avançadas',
swap: {
title: 'Swap',
disable: 'Desativar',
description: 'Defina o tamanho do arquivo de swap',
tip: 'Habilitar esta função pode encurtar a vida útil do seu cartão SD!'
},
mouseJiggler: {
title: 'Movimentador de Mouse',
description: 'Impedir que o host remoto entre em suspensão',
disable: 'Desativar',
absolute: 'Modo Absoluto',
relative: 'Modo Relativo'
},
mdns: {
description: 'Habilitar serviço de descoberta mDNS',
tip: 'Desligue se não for necessário'
},
hdmi: {
description: 'Habilitar saída HDMI/monitor'
},
hidOnly: 'Modo Somente-HID',
disk: 'Disco Virtual',
diskDesc: 'Montar U-disk virtual no host remoto',
network: 'Rede Virtual',
networkDesc: 'Montar placa de rede virtual no host remoto',
reboot: 'Reiniciar',
rebootDesc: 'Tem certeza de que deseja reiniciar o NanoKVM?',
okBtn: 'Sim',
cancelBtn: 'Não'
},
tailscale: {
title: 'Tailscale',
memory: {
title: 'Otimização de memória',
tip: 'Quando o uso de memória excede o limite, a coleta de lixo é realizada de forma mais agressiva para tentar liberar memória. Recomenda-se definir para 75MB se estiver usando Tailscale. É necessário reiniciar o Tailscale para que a alteração tenha efeito.',
disable: 'Desativar'
},
restart: 'Reiniciar Tailscale?',
stop: 'Parar Tailscale?',
stopDesc: 'Sair do Tailscale e desabilitar a inicialização automática no boot.',
loading: 'Carregando...',
notInstall: 'Tailscale não encontrado! Por favor, instale.',
install: 'Instalar',
installing: 'Instalando',
failed: 'Falha na instalação',
retry: 'Por favor, atualize e tente novamente. Ou tente instalar manualmente',
download: 'Baixar o',
package: 'pacote de instalação',
unzip: 'e descompacte-o',
upTailscale: 'Fazer upload do tailscale para o diretório NanoKVM /usr/bin/',
upTailscaled: 'Fazer upload do tailscaled para o diretório NanoKVM /usr/sbin/',
refresh: 'Atualizar página atual',
notLogin: 'O dispositivo ainda não foi vinculado. Por favor, faça login e vincule este dispositivo à sua conta.',
urlPeriod: 'Esta URL é válida por 10 minutos',
login: 'Login',
loginSuccess: 'Login Bem-sucedido',
enable: 'Habilitar Tailscale',
deviceName: 'Nome do Dispositivo',
deviceIP: 'IP do Dispositivo',
account: 'Conta',
logout: 'Sair',
logoutDesc: 'Tem certeza de que deseja sair?',
uninstall: 'Desinstalar Tailscale',
okBtn: 'Sim',
cancelBtn: 'Não'
},
update: {
title: 'Verificar Atualizações',
queryFailed: 'Falha ao obter a versão',
updateFailed: 'Falha na atualização. Por favor, tente novamente.',
isLatest: 'Você já tem a versão mais recente.',
available: 'Uma atualização está disponível. Tem certeza de que deseja atualizar agora?',
updating: 'Atualização iniciada. Por favor, aguarde...',
confirm: 'Confirmar',
cancel: 'Cancelar',
preview: 'Prévia das Atualizações',
previewDesc: 'Tenha acesso antecipado a novos recursos e melhorias',
previewTip: 'Esteja ciente de que as versões de prévia podem conter bugs ou funcionalidade incompleta!'
},
account: {
title: 'Conta',
webAccount: 'Nome da Conta Web',
password: 'Senha',
updateBtn: 'Alterar',
logoutBtn: 'Sair',
logoutDesc: 'Tem certeza de que deseja sair?',
okBtn: 'Sim',
cancelBtn: 'Não'
}
},
error: {
title: 'Encontramos um problema',
refresh: 'Atualizar'
},
fullscreen: {
toggle: 'Alternar Tela Cheia'
},
menu: {
collapse: 'Recolher Menu',
expand: 'Expandir Menu'
}
}
}
export default pt_br;

353
web/src/i18n/locales/se.ts Normal file
View File

@@ -0,0 +1,353 @@
const se = {
translation: {
head: {
desktop: 'Fjärrskrivbord',
login: 'Logga in',
changePassword: 'Byt lösenord',
terminal: 'Terminal',
wifi: 'Wi-Fi'
},
auth: {
login: 'Logga in',
placeholderUsername: 'Användarnamn',
placeholderPassword: 'Lösenord',
placeholderPassword2: 'Vänligen ange lösenordet igen',
noEmptyUsername: 'Användarnamn krävs',
noEmptyPassword: 'Lösenord krävs',
noAccount: 'Kunde inte hämta användarinformation, uppdatera sidan eller återställ lösenordet',
invalidUser: 'Ogiltigt användarnamn eller lösenord',
error: 'Oväntat fel',
changePassword: 'Byt lösenord',
changePasswordDesc: 'För din enhets säkerhet, byt lösenord!',
differentPassword: 'Lösenorden matchar inte',
illegalUsername: 'Användarnamnet innehåller ogiltiga tecken',
illegalPassword: 'Lösenordet innehåller ogiltiga tecken',
forgetPassword: 'Glömt lösenord',
ok: 'Ok',
cancel: 'Avbryt',
loginButtonText: 'Logga in',
tips: {
reset1:
'För att återställa lösenordet, håll in BOOT-knappen på NanoKVM i 10 sekunder.',
reset2: 'För detaljerade steg, se detta dokument:',
reset3: 'Standardkonto för webben:',
reset4: 'Standardkonto för SSH:',
change1: 'Observera att denna åtgärd ändrar följande lösenord:',
change2: 'Webbinloggningslösenord',
change3: 'Systemets root-lösenord (SSH-lösenord)',
change4: 'För att återställa lösenordet, håll in BOOT-knappen på NanoKVM.'
}
},
wifi: {
title: 'Wi-Fi',
description: 'Konfigurera Wi-Fi för NanoKVM',
success: 'Kontrollera nätverksstatusen för NanoKVM och besök den nya IP-adressen.',
failed: 'Åtgärden misslyckades, försök igen.',
confirmBtn: 'Ok',
finishBtn: 'Färdig'
},
screen: {
title: 'Skärm',
video: 'Videoläge',
videoDirectTips: 'Aktivera HTTPS i "Inställningar > Enhet" för att använda detta läge',
resolution: 'Upplösning',
auto: 'Automatisk',
autoTips:
'Skärmtear eller musförskjutning kan förekomma vid vissa upplösningar. Överväg att justera fjärrvärdens upplösning eller inaktivera automatiskt läge.',
fps: 'FPS',
customizeFps: 'Anpassa',
quality: 'Kvalitet',
qualityLossless: 'Förlustfri',
qualityHigh: 'Hög',
qualityMedium: 'Medel',
qualityLow: 'Låg',
frameDetect: 'Ramdetection',
frameDetectTip:
"Beräkna skillnaden mellan ramar. Sluta skicka videoström när inga förändringar upptäcks på fjärrvärdens skärm.",
resetHdmi: 'Återställ HDMI'
},
keyboard: {
title: 'Tangentbord',
paste: 'Klistra in',
tips: 'Endast standardbokstäver och symboler på tangentbordet stöds',
placeholder: 'Ange text',
submit: 'Skicka',
virtual: 'Tangentbord',
ctrlaltdel: 'Ctrl+Alt+Del'
},
mouse: {
title: 'Mus',
cursor: 'Markörstil',
default: 'Standardmarkör',
pointer: 'Pekmarkör',
cell: 'Cellmarkör',
text: 'Textmarkör',
grab: 'Greppmarkör',
hide: 'Dölj markör',
mode: 'Musläge',
absolute: 'Absolut läge',
relative: 'Relativt läge',
speed: 'Scrollhastighet',
fast: 'Snabb',
slow: 'Långsam',
requestPointer: 'Använder relativt läge. Klicka på skrivbordet för att få muspekaren.',
resetHid: 'Återställ HID',
hidOnly: {
title: 'Endast-HID-läge',
desc: 'Om din mus och ditt tangentbord slutar svara och återställning av HID inte hjälper, kan det bero på kompatibilitetsproblem mellan NanoKVM och enheten. Prova att aktivera Endast-HID-läge för bättre kompatibilitet.',
tip1: 'Aktivering av Endast-HID-läge avmonterar den virtuella U-disken och nätverket',
tip2: 'I Endast-HID-läge är avbildningsmontering inaktiverat',
tip3: 'NanoKVM kommer automatiskt att starta om efter lägesbyte',
enable: 'Aktivera Endast-HID-läge',
disable: 'Inaktivera Endast-HID-läge'
}
},
image: {
title: 'Avbildningar',
loading: 'Laddar...',
empty: 'Inget hittades',
cdrom: 'Montera avbildning i CD-ROM-läge',
mountFailed: 'Montering misslyckades',
mountDesc:
"I vissa system måste den virtuella disken avmonteras på fjärrvärden innan avbildningen monteras.",
refresh: 'Uppdatera avbildningslistan',
tips: {
title: 'Hur man laddar upp',
usb1: 'Anslut NanoKVM till din dator via USB.',
usb2: 'Säkerställ att den virtuella disken är monterad (Inställningar - Virtuell Disk).',
usb3: 'Öppna den virtuella disken på din dator och kopiera avbildningsfilen till rotkatalogen.',
scp1: 'Säkerställ att NanoKVM och din dator är på samma lokala nätverk.',
scp2: 'Öppna en terminal på din dator och använd SCP-kommandot för att ladda upp avbildningen till /data på NanoKVM.',
scp3: 'Exempel: scp din-avbildningssökväg root@din-nanokvm-ip:/data',
tfCard: 'TF-kort',
tf1: 'Denna metod stöds på Linux-system',
tf2: 'Ta ut TF-kortet från NanoKVM (för FULL-versionen, öppna chassit först).',
tf3: 'Sätt in TF-kortet i en kortläsare och anslut till din dator.',
tf4: 'Kopiera avbildningsfilen till /data på TF-kortet.',
tf5: 'Sätt in TF-kortet i NanoKVM.'
}
},
script: {
title: 'Skript',
upload: 'Ladda upp',
run: 'Kör',
runBackground: 'Kör i bakgrunden',
runFailed: 'Körning misslyckades',
attention: 'Observera',
delDesc: 'Är du säker på att du vill ta bort denna fil?',
confirm: 'Ja',
cancel: 'Nej',
delete: 'Ta bort',
close: 'Stäng'
},
terminal: {
title: 'Terminal',
nanokvm: 'NanoKVM Terminal',
serial: 'Serieport-terminal',
serialPort: 'Serieport',
serialPortPlaceholder: 'Ange serieport',
baudrate: 'Baudhastighet',
parity: 'Paritet',
parityNone: 'Ingen',
parityEven: 'Jämn',
parityOdd: 'Udda',
flowControl: 'Flödeskontroll',
flowControlNone: 'Ingen',
flowControlSoft: 'Mjuk',
flowControlHard: 'Hård',
dataBits: 'Databitar',
stopBits: 'Stoppbitar',
confirm: 'Ok'
},
wol: {
title: 'Wake-on-LAN',
sending: 'Skickar kommando...',
sent: 'Kommando skickat',
input: 'Ange MAC-adress',
ok: 'Ok'
},
download: {
title: 'Avbildningshämtare',
input: 'Ange en fjärravbildnings-URL',
ok: 'Ok',
disabled: '/data partitionen är skrivskyddad, kan inte hämta avbildning'
},
power: {
title: 'Ström',
showConfirm: 'Bekräftelse',
showConfirmTip: 'Strömätgärder kräver extra bekräftelse',
reset: 'Starta om',
power: 'Ström',
powerShort: 'Ström (kort tryck)',
powerLong: 'Ström (långt tryck)',
resetConfirm: 'Utföra omstart?',
powerConfirm: 'Utföra strömåtgärd?',
okBtn: 'Ja',
cancelBtn: 'Nej'
},
settings: {
title: 'Inställningar',
about: {
title: 'Om NanoKVM',
information: 'Information',
ip: 'IP',
mdns: 'mDNS',
application: 'Applikationsversion',
applicationTip: 'NanoKVM webbapplikationsversion',
image: 'Systemversion',
imageTip: 'NanoKVM systemavbildningsversion',
deviceKey: 'Enhetsnyckel',
community: 'Community',
hostname: 'Värdnamn',
hostnameUpdated: 'Värdnamn uppdaterat. Starta om för att tillämpa.',
ipType: {
Wired: 'Trådbundet',
Wireless: 'Trådlöst',
Other: 'Annat'
}
},
appearance: {
title: 'Utseende',
display: 'Display',
language: 'Språk',
menuBar: 'Menyfält',
menuBarDesc: 'Visa ikoner i menyfältet',
webTitle: 'Webbtitel',
webTitleDesc: 'Anpassa webbsidans titel'
},
device: {
title: 'Enhet',
oled: {
title: 'OLED',
description: 'Stäng av OLED-skärmen efter',
0: 'Aldrig',
15: '15 sek',
30: '30 sek',
60: '1 min',
180: '3 min',
300: '5 min',
600: '10 min',
1800: '30 min',
3600: '1 timme'
},
wifi: {
title: 'Wi-Fi',
description: 'Konfigurera Wi-Fi',
setBtn: 'Konfigurera'
},
ssh: {
description: 'Aktivera SSH-fjärråtkomst',
tip: 'Ställ in ett starkt lösenord innan du aktiverar (Konto - Byt lösenord)'
},
tls: {
description: 'Aktivera HTTPS-protokoll',
tip: 'Observera: Användning av HTTPS kan öka fördröjningen, särskilt med MJPEG-läge.'
},
advanced: 'Avancerade inställningar',
swap: {
title: 'Swap',
disable: 'Inaktivera',
description: 'Ange swap-filens storlek',
tip: 'Aktivering av denna funktion kan förkorta livslängden på ditt SD-kort!'
},
mouseJiggler: {
title: 'Musvickare',
description: 'Förhindra att fjärrvärden går i viloläge',
disable: 'Inaktivera',
absolute: 'Absolut läge',
relative: 'Relativt läge'
},
mdns: {
description: 'Aktivera mDNS-upptäckningstjänst',
tip: 'Stäng av om det inte behövs'
},
hdmi: {
description: 'Aktivera HDMI/monitorutgång'
},
hidOnly: 'Endast-HID-läge',
disk: 'Virtuell disk',
diskDesc: 'Montera virtuell U-disk på fjärrvärden',
network: 'Virtuellt nätverk',
networkDesc: 'Montera virtuell nätverkskort på fjärrvärden',
reboot: 'Starta om',
rebootDesc: 'Är du säker på att du vill starta om NanoKVM?',
okBtn: 'Ja',
cancelBtn: 'Nej'
},
tailscale: {
title: 'Tailscale',
memory: {
title: 'Minnesoptimering',
tip: 'När minnesanvändningen överskrider gränsen utförs aggressivare skräpsamling för att frigöra minne. Rekommenderas att sättas till 75 MB om du använder Tailscale. Omstart krävs för att det ska gälla.',
disable: 'Inaktivera'
},
restart: 'Starta om Tailscale?',
stop: 'Stoppa Tailscale?',
stopDesc: 'Logga ut från Tailscale och inaktivera autostart vid uppstart.',
loading: 'Laddar...',
notInstall: 'Tailscale hittades inte! Installera först.',
install: 'Installera',
installing: 'Installerar',
failed: 'Installationen misslyckades',
retry: 'Uppdatera sidan och försök igen. Eller installera manuellt',
download: 'Ladda ner',
package: 'installationspaketet',
unzip: 'och packa upp det',
upTailscale: 'Ladda upp tailscale till NanoKVM-katalogen /usr/bin/',
upTailscaled: 'Ladda upp tailscaled till katalogen /usr/sbin/',
refresh: 'Uppdatera sidan',
notLogin:
'Enheten är ännu inte bunden. Logga in och bind enheten till ditt konto.',
urlPeriod: 'Denna URL är giltig i 10 minuter',
login: 'Logga in',
loginSuccess: 'Inloggning lyckades',
enable: 'Aktivera Tailscale',
deviceName: 'Enhetsnamn',
deviceIP: 'Enhets-IP',
account: 'Konto',
logout: 'Logga ut',
logoutDesc: 'Är du säker på att du vill logga ut?',
uninstall: 'Avinstallera Tailscale',
okBtn: 'Ja',
cancelBtn: 'Nej'
},
update: {
title: 'Sök efter uppdateringar',
queryFailed: 'Kunde inte hämta version',
updateFailed: 'Uppdatering misslyckades. Försök igen.',
isLatest: 'Du har redan den senaste versionen.',
available: 'En uppdatering finns tillgänglig. Vill du uppdatera nu?',
updating: 'Uppdatering påbörjad. Vänta...',
confirm: 'Bekräfta',
cancel: 'Avbryt',
preview: 'Förhandsvisning av uppdateringar',
previewDesc: 'Få tidig tillgång till nya funktioner och förbättringar',
previewTip:
'Observera att förhandsversioner kan innehålla buggar eller ofullständig funktionalitet!'
},
account: {
title: 'Konto',
webAccount: 'Webbkonto-namn',
password: 'Lösenord',
updateBtn: 'Byt',
logoutBtn: 'Logga ut',
logoutDesc: 'Är du säker på att du vill logga ut?',
okBtn: 'Ja',
cancelBtn: 'Nej'
}
},
error: {
title: 'Vi stötte på ett problem',
refresh: 'Uppdatera'
},
fullscreen: {
toggle: 'Växla fullskärm'
},
menu: {
collapse: 'Fäll ihop menyn',
expand: 'Expandera menyn'
}
}
};
export default se;

353
web/src/i18n/locales/tr.ts Normal file
View File

@@ -0,0 +1,353 @@
const tr = {
translation: {
head: {
desktop: 'Uzak masaüstü',
login: 'Giriş',
changePassword: 'Şifreyi değiştir',
terminal: 'Uçbirim',
wifi: 'Wi-Fi'
},
auth: {
login: 'Giriş',
placeholderUsername: 'Kullanıcı Adı',
placeholderPassword: 'Şifre',
placeholderPassword2: 'Şifrenizi tekrar deneyiniz',
noEmptyUsername: 'Kullanıcı adı gereklidir',
noEmptyPassword: 'Şifre gereklidir',
noAccount: 'Kullanıcı verileri alınırken hata yaşandı, lütfen sayfayı yenileyiniz ya da şifrenizi sıfırlayınız',
invalidUser: 'Yanlış kullanıcı adı ya da şifre',
error: 'Beklenmedik bir hata',
changePassword: 'Şifrenizi değiştiriniz',
changePasswordDesc: 'Güvenlik sebebiyle lütfen şifrenizi değiştiriniz!',
differentPassword: 'Şifreler eşleşmemektedir',
illegalUsername: 'Kullanıcı adı istenmeyen karakterler içermektedir',
illegalPassword: 'Şifre istenmeyen karakterler içermektedir',
forgetPassword: 'Şifremi unuttum',
ok: 'Tamam',
cancel: 'İptal',
loginButtonText: 'Giriş',
tips: {
reset1:
'Şifreleri sıfırlamak için NanoKVM üzerinde bulunan BOOT tuşuna 10 saniye boyunca basılı tutun.',
reset2: 'Ayrıntılı adımlar için dökümana göz atın:',
reset3: 'Arayüz varsayılan hesap:',
reset4: 'Güvenli Kabuk Bağlantısı (SSH) varsayılan hesap:',
change1: 'Bu işlem şu şifreleri değiştiricektir:',
change2: 'Arayüz giriş şifresi',
change3: 'Sistem yöneticisi şifresi (Güvenli Kabuk Bağlantısı (SSH) giriş şifresi)',
change4: 'Şifreleri sıfırlamak için NanoKVM üzerinde bulunan BOOT tuşuna 10 saniye boyunca basılı tutun.'
}
},
wifi: {
title: 'Wi-Fi',
description: 'NanoKVM için Wi-Fi ayarlarını ayarlayın',
success: 'NanoKVM\'in bağlantı durumunu kontrol edin ve yeni IP adresini ziyaret edin.',
failed: 'İşlem başarısız oldu, lütfen tekrar deneyiniz.',
confirmBtn: 'Tamam',
finishBtn: 'Bitti'
},
screen: {
title: 'Ekran',
video: 'Görüntü modu',
videoDirectTips: 'kullanmak için "Ayarlar > Cihaz" HTTPS aktif edin',
resolution: 'Çözünürlük',
auto: 'Otomatik',
autoTips:
"Belirli çözünürlüklerde ekran yırtılması veya fare kayması meydana gelebilir. Bu durumda uzak ana bilgisayarın çözünürlüğünü ayarlamayı ya da otomatik modu devre dışı bırakmayı deneyin.",
fps: 'Saniyedeki kare sayısı',
customizeFps: 'Kişiselleştir',
quality: 'Kalite',
qualityLossless: 'Kayıpsız',
qualityHigh: 'Yüksek',
qualityMedium: 'Orta',
qualityLow: 'Düşük',
frameDetect: 'Kareleri algıla',
frameDetectTip:
"Gönderilen kareler arasındaki farkı hesaplar. Uzak ana bilgisayardan gönderilen yayında bir değişiklik yoksa görüntü yayınını durdurur.",
resetHdmi: 'HDMI sıfırla'
},
keyboard: {
title: 'Klavye',
paste: 'Yapıştır',
tips: 'Sadece standart klavye harfleri ve sembolleri desteklenmektedir.',
placeholder: 'Girdi',
submit: 'Gönder',
virtual: 'Klavye',
ctrlaltdel: 'Ctrl+Alt+Del'
},
mouse: {
title: 'Fare',
cursor: 'İmleç sitili',
default: 'Varsayılan imleç',
pointer: 'Nokta imleç',
cell: 'Artı imleç',
text: 'Yazı imleç',
grab: 'El imleç',
hide: 'İmleci gizle',
mode: 'Fare modu',
absolute: 'Mutlak fare modu',
relative: 'Bağıl fare modu',
speed: 'Tekerlek hızı',
fast: 'Hızlı',
slow: 'Yavaş',
requestPointer: 'Bağıl fare modu kullanılıyor. Masaüstüne tıklayarak imleç elde edinin.',
resetHid: 'İnsan Arayüz Cihazı\'nı sıfırlayın',
hidOnly: {
title: 'Sadece İnsan Arayüz Cihazı modu',
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı\'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
tip1: 'Sadece İnsan Arayüz Cihazı modunu aktifleştirmek, sanal U-diski ve sanal ağı devre dışı bırakacaktır.',
tip2: 'Sadece İnsan Arayüz Cihazı modunda disk imajı bağlama özelliği devre dışı bırakılır',
tip3: 'NanoKVM mod değişiminden sonra kendiliğinden yeniden başlatılacaktır',
enable: 'Sadece İnsan Arayüz Cihazı modunu aktifleştir',
disable: 'Sadece İnsan Arayüz Cihazı modunu devre dışı bırak'
}
},
image: {
title: 'Disk İmajları',
loading: 'Yükleniyor...',
empty: 'Hiçbir şey bulunamadı',
cdrom: 'Disk imajını CD-ROM modunda bağlayın.',
mountFailed: 'Bağlantı başarısız oldu',
mountDesc:
"Bazı sistemlerde, disk imajını bağlamadan önce uzak ana bilgisayardaki sanal diski çıkarmak gerekir.",
refresh: 'Disk imajı listesini yenile',
tips: {
title: 'Nasıl yüklenir',
usb1: 'NanoKVM\'i bilgisayarınıza USB ile bağlayın.',
usb2: 'Sanal diskin bağlı olduğundan emin olun (Ayarlar - Sanal Disk).',
usb3: 'Sanal diski bilgisayarınızda açın ve disk imajı dosyanızı sanal diskin kök dizinine kopyalayın.',
scp1: 'NanoKVM ve bilgisayarınızın aynı yerel ağda bulunduğundan emin olun.',
scp2: 'Bilgisayarınızda uçbirimi açın ve disk imajı dosyanını SCP komudunu kullanarak NanoKVM\'in /data dizinine yükleyin.',
scp3: 'Örnek: scp senin-disk-imajı-dizinin root@senin-nanokvm-ip:/data',
tfCard: 'micro SD kart',
tf1: 'Bu yöntem Linux sistemlerde desteklenmektedir.',
tf2: 'NanoKVM\'den micro SD kartı çıkartın(TAM sürüm için öncelikle kutuyu sökün).',
tf3: 'micro SD kartı kart okuyucusuna takın ve bilgisayarınıza bağlayın.',
tf4: 'Disk imajı dosyanını micro SD kartın /data dizinine kopyalayın.',
tf5: 'micro SD kartı NanoKVM\'e geri yerleştirin.'
}
},
script: {
title: 'Betikler',
upload: 'Yükle',
run: 'Çalıştır',
runBackground: 'Arka planda çalıştır',
runFailed: 'Çalıştırma başarısız oldu',
attention: 'Dikkat',
delDesc: 'Bu dosyayı silmek istediğinden emin misin?',
confirm: 'Evet',
cancel: 'Hayır',
delete: 'Sil',
close: 'Kapat'
},
terminal: {
title: 'Uçbirim',
nanokvm: 'NanoKVM Uçbirimi',
serial: 'Serial Port Uçbirimi',
serialPort: 'Serial Port',
serialPortPlaceholder: 'Lütfen serial portunu giriniz',
baudrate: 'Baud hızı',
parity: 'Eşlik kontrolü',
parityNone: 'Yok',
parityEven: 'Çift',
parityOdd: 'Tek',
flowControl: 'Akış kontrolü',
flowControlNone: 'Yok',
flowControlSoft: 'Yazılımsal',
flowControlHard: 'Donanımsal',
dataBits: 'Veri bitleri',
stopBits: 'Dur bitleri',
confirm: 'Tamam'
},
wol: {
title: 'Ağ Üzerinden Uyandırma (WOL)',
sending: 'Komut gönderiliyor...',
sent: 'Komut gönderildi',
input: 'MAC adresi girin',
ok: 'Tamam'
},
download: {
title: 'Disk İmajı İndirici',
input: 'Uzaktaki disk imajı adresini girin',
ok: 'Tamam',
disabled: '/data bölüntüsü salt okunur modda, disk imajı indirilemiyor.'
},
power: {
title: 'Güç',
showConfirm: 'Doğrulama',
showConfirmTip: 'Güç ile ilgili işlemler fazladan doğrulama gerektirir',
reset: 'Sıfırla',
power: 'Güç',
powerShort: 'Güç tuşu (bas-çek)',
powerLong: 'Güç tuşu (uzun bas)',
resetConfirm: 'Sıfırlama işlemine devam etmek istediğinizden emin misiniz?',
powerConfirm: 'Güç işlemine devam etmek istediğinizden emin misiniz?',
okBtn: 'Evet',
cancelBtn: 'Hayır'
},
settings: {
title: 'Ayarlar',
about: {
title: 'NanoKVM Hakkında',
information: 'Bilgi',
ip: 'IP',
mdns: 'mDNS',
application: 'Uygulama sürümü',
applicationTip: 'NanoKVM web uygulaması sürümü',
image: 'İmaj Sürümü',
imageTip: 'NanoKVM sistem imajı sürümü',
deviceKey: 'Cihaz Anahtarı',
community: 'Topluluk',
hostname: 'Hostname',
hostnameUpdated: 'Hostname güncellendi. Uygulamak için yeniden başlatın.',
ipType: {
Wired: 'Kablolu bağlantı',
Wireless: 'Kablosuz bağlantı',
Other: 'Diğer'
}
},
appearance: {
title: 'Görünüm',
display: 'Ekran',
language: 'Dil',
menuBar: 'Menü çubuğu',
menuBarDesc: 'Menü çubuğunda ikonları göster',
webTitle: 'Site başlığı',
webTitleDesc: 'Görünen site başlığını güncelleyin'
},
device: {
title: 'Cihaz',
oled: {
title: 'OLED',
description: 'Oled ekranı ... sonra kapatın',
0: 'Hiçbir zaman',
15: '15 saniye',
30: '30 saniye',
60: '1 dakika',
180: '3 dakika',
300: '5 dakika',
600: '10 dakika',
1800: '30 dakika',
3600: '1 saat'
},
wifi: {
title: 'Wi-Fi',
description: 'Wi-Fi ayarlayın',
setBtn: 'Ayarla'
},
ssh: {
description: 'Güvenli Kabuk Bağlantısı (SSH) aktif et',
tip: 'Aktifleştirmeden önce güçlü bir şifreye sahip olduğunuzdan emin olun (Hesap - Şifremi Değiştir)'
},
tls: {
description: 'HTTPS protokolünü etkinleştir',
tip: 'HTTPS protokolü bağlantıda gecikmeye sebep olabilir, özellikle MJPEG görüntü modu ile.'
},
advanced: 'Gelişmiş Ayarlar',
swap: {
title: 'Swap',
disable: 'Aktifleştir',
description: 'Swap dosyasının boyutunu belirle',
tip: "Bu özelliği aktifleştirmek micro SD kartınızın ömrünü kısaltabilir!"
},
mouseJiggler: {
title: 'Fare Oynatıcı',
description: 'Uzak ana bilgisayarın uykuya geçmesini engeller',
disable: 'Devre dışı bırak',
absolute: 'Mutlak mod',
relative: 'Bağıl mod'
},
mdns: {
description: 'mDNS keşif hizmetini etkinleştir',
tip: "Kullanmıyorsanız devre dışı bırakabilirsiniz"
},
hdmi: {
description: 'HDMI/Momitör çıktısını aktifleştir'
},
hidOnly: 'Sadece İnsan Arayüz Cihazı modu',
disk: 'Sanal Disk',
diskDesc: 'Sanal U-disk\'i uzak ana bilgisayara bağla',
network: 'Sanal Ağ',
networkDesc: 'Sanal ağ kartını uzak ana bilgisayara bağla',
reboot: 'Yeniden Başlat',
rebootDesc: 'NanoKVM\'i yeniden başlatmak istediğinizden emin misiniz?',
okBtn: 'Evet',
cancelBtn: 'Hayır'
},
tailscale: {
title: 'Tailscale',
memory: {
title: 'Bellek optimizasyonu',
tip: "Bellek kullanımı sınırı aştığında, belleği boşaltmak amacıyla çöp toplama işlemi daha agresif bir şekilde gerçekleştirilir. Tailscale kullanıyorsanız bu değerin 75 MB olarak ayarlanması önerilir. Değişikliğin etkili olabilmesi için Tailscale'in yeniden başlatılması gerekir.",
disable: 'Devre dışı bırak'
},
restart: 'Tailscale\'i yeniden başlat?',
stop: 'Tailscale\'i durdur?',
stopDesc: 'Tailscale oturumundan çıkış yap ve başlangıçta çalışmasını devre dışı bırak.',
loading: 'Yükleniyor...',
notInstall: 'Tailscale bulunamadı! Lütfen indirin.',
install: 'İndir',
installing: 'İndiriliyor',
failed: 'İndirme başarısız oldu',
retry: 'Lütfen sayfayı yenileyin ve tekrar deneyin, ya da manuel indirin',
download: 'İndir',
package: 'yükleme paketi',
unzip: 'sıkışmış dosyayıın',
upTailscale: 'tailscale dosyasını NanoKVM\'in /usr/bin dizinine yükleyin',
upTailscaled: 'tailscaled dosyasını NanoKVM\'in /usr/sbin dizinine yükleyin',
refresh: 'İçinde bulunduğunuz sayfayı yenileyin',
notLogin:
'Cihaz bağlı değil. Lütfen giriş yapıp cihazınızı hesabınıza bağlayın.',
urlPeriod: 'Adres sadece 10 ndakika boyunca geçerlidir',
login: 'Giriş yap',
loginSuccess: 'Giriş yapıldı',
enable: 'Tailscale\'i etkinleştir',
deviceName: 'Cihaz Adı',
deviceIP: 'Cihaz IP adresi',
account: 'Hesap',
logout: ıkış yap',
logoutDesc: ıkış yapmak istediğinizden emin misiniz?',
uninstall: 'Tailscale\'i kaldır',
okBtn: 'Evet',
cancelBtn: 'Hayır'
},
update: {
title: 'Güncelleştirmeleri kontrol et',
queryFailed: 'Sürüm bilgisi alınamadı',
updateFailed: 'Güncelleme başarısız oldu. Lütfen tekrar deneyin.',
isLatest: 'En yeni sürüme sahipsiniz.',
available: 'Bir güncelleme indirilebilir. Şimdi güncellemek istediğinizden emin misiniz?',
updating: 'Güncelleme başlatıldı. Lütfen bekleyin...',
confirm: 'Onayla',
cancel: 'İptal',
preview: 'Ön İzleme Güncellemeleri',
previewDesc: 'En son geliştirmelere ve özelliklere erken erişin',
previewTip:
'Ön izleme güncellemelerinin tamamlanmamış olduğunu ve sorunlara sebep olabileceğini unutmayın!'
},
account: {
title: 'Hesap',
webAccount: 'Web Hesap Adı',
password: 'Şifre',
updateBtn: 'Değiştir',
logoutBtn: ıkış yap',
logoutDesc: ıkış yapmak istediğinizden emin misiniz?',
okBtn: 'Evet',
cancelBtn: 'Hayır'
}
},
error: {
title: "Bir hata oldu!",
refresh: 'Yenile'
},
fullscreen: {
toggle: 'Tam ekrana geç'
},
menu: {
collapse: 'Menüyü küçült',
expand: 'Menüyü genişlet'
}
}
};
export default tr;

View File

@@ -104,9 +104,16 @@ const zh = {
title: '镜像',
loading: '加载中',
empty: '无镜像文件',
cdrom: '以 CD-ROM 模式挂载镜像',
mountMode: '挂载模式',
mountFailed: '挂载失败',
mountDesc: '在某些系统中,需要在远程主机中弹出虚拟硬盘后再挂载镜像。',
unmountFailed: '卸载失败',
unmountDesc: '在某些系统中,需要在远程主机中手动弹出后再卸载镜像。',
refresh: '刷新镜像列表',
attention: '注意',
deleteConfirm: '确定要删除该镜像吗?',
okBtn: '确定',
cancelBtn: '取消',
tips: {
title: '如何上传',
usb1: '将 NanoKVM 通过 USB 连接到你的电脑;',
@@ -256,8 +263,11 @@ const zh = {
title: 'Tailscale',
memory: {
title: '内存优化',
tip: '当内存占用超过限制时,会更积极地执行垃圾回收来尝试释放内存。如果使用 Tailscale 推荐设置为 50MB需重启 Tailscale 后生效。',
disable: '关闭'
tip: '当内存占用超过限制时,会更积极地执行垃圾回收来尝试释放内存。需重启 Tailscale 后生效。'
},
swap: {
title: '交换内存',
tip: '如果启用内存优化后依然存在问题可以尝试开启交换内存。启用后会将交换文件设置为256MB可以在「设置 - 设备」中修改该选项。'
},
restart: '取定要重启 Tailscale 吗?',
stop: '确定要停止 Tailscale 吗?',
@@ -274,6 +284,8 @@ const zh = {
upTailscale: '将 tailscale 文件上传到 /usr/bin/ 目录',
upTailscaled: '将 tailscaled 文件上传到 /usr/sbin/ 目录',
refresh: '刷新页面',
notRunning: 'Tailscale 尚未运行,请先执行启动操作',
run: '启动',
notLogin: '该设备尚未绑定,请点击登录并将这台设备绑定到您的账号。',
urlPeriod: '该链接10分钟内有效',
login: '登录',
@@ -285,6 +297,7 @@ const zh = {
logout: '退出',
logoutDesc: '确定要退出吗?',
uninstall: '卸载 Tailscale',
uninstallDesc: '确定要卸载 Tailscale 吗?',
reboot: '重启',
rebootDesc: '你确定要重启 NanoKVM 吗?',
okBtn: '确认',

View File

@@ -46,7 +46,9 @@ const zh_tw = {
finishBtn: '完成'
},
screen: {
title: '螢幕',
video: '編碼格式',
videoDirectTips: '本模式需先啟用HTTPS請前往「設定 -> 設備」中開啟',
resolution: '解析度',
auto: '自動',
autoTips:
@@ -63,6 +65,7 @@ const zh_tw = {
resetHdmi: '重置 HDMI'
},
keyboard: {
title: 'Keyboard',
paste: '貼上',
tips: '僅支援標準鍵盤字母和符號',
placeholder: '請輸入內容',
@@ -71,6 +74,8 @@ const zh_tw = {
ctrlaltdel: 'Ctrl+Alt+Del'
},
mouse: {
title: '滑鼠',
cursor: '游標樣式',
default: '預設游標',
pointer: '懸浮游標',
cell: '儲存格游標',
@@ -80,8 +85,20 @@ const zh_tw = {
mode: '滑鼠模式',
absolute: '絕對模式',
relative: '相對模式',
speed: '滾輪速度',
fast: '快',
slow: '慢',
requestPointer: '使用相對模式。請按一下桌面以取得滑鼠游標。',
resetHid: '重設 HID'
resetHid: '重設 HID',
hidOnly: {
title: 'HID-Only 模式',
desc: "如果您的滑鼠和鍵盤停止回應,且重設 HID 無效,可能是 NanoKVM 與裝置間的相容性問題。請嘗試啟用 HID-Only 模式以獲得更好的相容性。",
tip1: '啟用 HID-Only 模式將會卸載虛擬隨身碟和虛擬網卡',
tip2: '在 HID-Only 模式下,映像掛載功能將被停用',
tip3: 'NanoKVM 將在切換模式後自動重新啟動',
enable: '啟用 HID-Only 模式',
disable: '停用 HID-Only 模式'
}
},
image: {
title: '映像',
@@ -90,6 +107,7 @@ const zh_tw = {
cdrom: '以CD-ROM模式掛載',
mountFailed: '掛載失敗',
mountDesc: '在某些系統中,在掛載映像之前需要中斷遠端主機上的虛擬磁碟。',
refresh: 'Refresh the image list',
tips: {
title: '如何上傳',
usb1: '透過 USB 將 NanoKVM 連接到您的電腦。',
@@ -126,6 +144,16 @@ const zh_tw = {
serialPort: 'Serial Port',
serialPortPlaceholder: '請輸入 Serial Port',
baudrate: 'Baud rate',
parity: 'Parity',
parityNone: 'None',
parityEven: 'Even',
parityOdd: 'Odd',
flowControl: 'Flow control',
flowControlNone: 'None',
flowControlSoft: 'Soft',
flowControlHard: 'Hard',
dataBits: 'Data bits',
stopBits: 'Stop bits',
confirm: '確定'
},
wol: {
@@ -143,10 +171,16 @@ const zh_tw = {
},
power: {
title: '電源',
showConfirm: 'Confirmation',
showConfirmTip: 'Power operations require an extra confirmation',
reset: '重新啟動',
power: '電源',
powerShort: '電源 (短按)',
powerLong: '電源 (長按)'
powerLong: '電源 (長按)',
resetConfirm: '執行重新啟動按鍵操作?',
powerConfirm: '執行電源按鍵操作?',
okBtn: '是',
cancelBtn: '取消'
},
settings: {
title: '設定',
@@ -160,14 +194,23 @@ const zh_tw = {
image: '映像版本',
imageTip: 'NanoKVM 系统镜像版本',
deviceKey: '設備序號',
community: '社群'
community: '社群',
hostname: '主機名稱',
hostnameUpdated: '已更新主機名稱. 請重新啟動以生效',
ipType: {
Wired: '有線',
Wireless: '無線',
Other: '其他'
}
},
appearance: {
title: '外觀',
display: '顯示',
language: '語言',
menuBar: '選單列',
menuBarDesc: '是否在選單列中顯示圖案'
menuBarDesc: '是否在選單列中顯示圖案',
webTitle: '網頁標題',
webTitleDesc: '自訂網頁標題'
},
device: {
title: '設備',
@@ -193,10 +236,40 @@ const zh_tw = {
description: '啟用SSH',
tip: '啟用前請務必設定強密碼(帳號 - 更改密碼)'
},
tls: {
description: '啟用 HTTPS',
tip: '啟用 HTTPS 可以提高安全性,但可能會增加延遲,特別是使用 MJPEG 編碼格式時。'
},
advanced: '進階設定',
swap: {
title: 'Swap',
disable: '停用',
description: '設定Swap檔大小',
tip: "啟用此功能可能會減少SD卡的使用壽命"
},
mouseJiggler: {
title: 'Mouse Jiggler',
description: '避免遠端主機進入休眠狀態',
disable: '停用',
absolute: '絕對模式',
relative: '相對模式'
},
mdns: {
description: '啟用mDNS服務',
tip: "若無需求,建議關閉此功能"
},
hdmi: {
description: '啟用 HDMI/螢幕 輸出'
},
hidOnly: 'HID-Only 模式',
disk: '虛擬隨身碟',
diskDesc: '在遠端主機上連接虛擬隨身碟',
network: '虛擬網卡',
networkDesc: '在遠端主機上新增虛擬網卡'
networkDesc: '在遠端主機上新增虛擬網卡',
reboot: '重新啟動',
rebootDesc: '你確定要重新啟動 NanoKVM?',
okBtn: '是',
cancelBtn: '取消'
},
tailscale: {
title: 'Tailscale',
@@ -229,7 +302,7 @@ const zh_tw = {
deviceIP: '裝置 IP',
account: '帳號',
logout: '登出',
logout2: '確認登出?',
logoutDesc: '確認登出?',
uninstall: '移除 Tailscale',
okBtn: '確認',
cancelBtn: '取消'
@@ -242,26 +315,33 @@ const zh_tw = {
available: '有可用更新。確定要更新嗎?',
updating: '正在更新。請稍等...',
confirm: '確定',
cancel: '取消'
cancel: '取消',
preview: '預覽更新',
previewDesc: '搶先體驗新功能和改進',
previewTip:
'請注意,預覽版本可能包含錯誤或功能不完整!'
},
account: {
title: '帳號',
webAccount: '網頁帳號',
password: '密碼',
updateBtn: '修改',
logoutBtn: '登出'
logoutBtn: '登出',
logoutDesc: '你確定要登出嗎?',
okBtn: '是',
cancelBtn: '取消'
}
},
error: {
title: '我們遇到了一些問題',
refresh: '重新整理',
refresh: '重新整理'
},
fullscreen: {
toggle: '進入全螢幕模式',
toggle: '進入全螢幕模式'
},
menu: {
collapse: '收起選單',
expand: '展開選單',
expand: '展開選單'
}
}
};

View File

@@ -2,37 +2,24 @@ import { useEffect, useRef } from 'react';
import { client } from '@/lib/websocket.ts';
import { KeyboardCodes, ModifierCodes } from './mappings.ts';
import { KeyboardCodes } from './mappings.ts';
export const Keyboard = () => {
const lastCodeRef = useRef('');
const modifierRef = useRef({
ctrl: 0,
shift: 0,
alt: 0,
meta: 0
});
const pressedKeys = useRef<Set<string>>(new Set());
// listen keyboard events
useEffect(() => {
const modifiers = ['Control', 'Shift', 'Alt', 'Meta'];
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', releaseAllKeys);
document.addEventListener('visibilitychange', handleVisibilityChange);
// press button
function handleKeyDown(event: KeyboardEvent) {
disableEvent(event);
lastCodeRef.current = event.code;
if (modifiers.includes(event.key)) {
const code = ModifierCodes.get(event.code)!;
setModifier(event.key, code);
if (event.key === 'Meta') {
return;
}
if (!pressedKeys.current.has(event.code)) {
pressedKeys.current.add(event.code);
}
sendKeyDown(event);
@@ -42,58 +29,64 @@ export const Keyboard = () => {
function handleKeyUp(event: KeyboardEvent) {
disableEvent(event);
if (modifiers.includes(event.key)) {
if (event.key === 'Meta' && lastCodeRef.current === event.code) {
sendKeyDown(event, true);
sendKeyUp();
}
setModifier(event.key, 0);
if (pressedKeys.current.has(event.code)) {
pressedKeys.current.delete(event.code);
}
if (event.key !== 'Meta') {
sendKeyUp();
sendKeyUp();
}
function releaseAllKeys() {
if (pressedKeys.current.size === 0) {
return;
}
sendKeyUp();
pressedKeys.current.clear();
}
function handleVisibilityChange() {
if (document.hidden) {
releaseAllKeys();
}
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', releaseAllKeys);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
function setModifier(key: string, code: number) {
switch (key) {
case 'Control':
modifierRef.current.ctrl = code;
break;
case 'Alt':
modifierRef.current.alt = code;
break;
case 'Shift':
modifierRef.current.shift = code;
break;
case 'Meta':
modifierRef.current.meta = code;
break;
default:
console.log('unknown key: ', key);
}
}
function sendKeyDown(event: KeyboardEvent, isMeta?: boolean) {
function sendKeyDown(event: KeyboardEvent) {
const code = KeyboardCodes.get(event.code);
if (!code) {
console.log('unknown code: ', event.code);
return;
}
const ctrl = event.ctrlKey ? modifierRef.current.ctrl || 1 : 0;
const shift = event.shiftKey ? modifierRef.current.shift || 2 : 0;
const alt = event.altKey ? modifierRef.current.alt || 4 : 0;
const meta = event.metaKey || isMeta ? modifierRef.current.meta || 8 : 0;
let ctrl = 0;
if (event.ctrlKey) {
if (pressedKeys.current.has('ControlLeft')) {
ctrl = 1;
} else if (pressedKeys.current.has('ControlRight')) {
ctrl = 16;
} else if (pressedKeys.current.has('AltRight')) {
ctrl = 0;
} else {
ctrl = 1;
}
}
client.send([1, code, ctrl, shift, alt, meta]);
const modifiers = [
ctrl,
event.shiftKey ? (pressedKeys.current.has('ShiftRight') ? 32 : 2) : 0,
event.altKey ? (pressedKeys.current.has('AltRight') ? 64 : 4) : 0,
event.metaKey ? (pressedKeys.current.has('MetaRight') ? 128 : 8) : 0
];
client.send([1, code, ...modifiers]);
}
function sendKeyUp() {

View File

@@ -85,6 +85,7 @@ export const KeyboardCodes: Map<string, number> = new Map([
['Backslash', 49],
['IntlBackslash', 49],
['Backquote_azerty', 100],
['IntlBackslash_qwertz', 100],
['Semicolon', 51],
['Quote', 52],

View File

@@ -46,6 +46,7 @@ export const VirtualKeyboard = () => {
const languages = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'Deutsch' },
{ value: 'ru', label: 'Russian' }
];
@@ -65,6 +66,7 @@ export const VirtualKeyboard = () => {
const layoutMap = new Map([
['en', 'default'],
['ru', 'rus'],
['de', 'qwertz'],
['fr', 'azerty']
]);
@@ -126,6 +128,17 @@ export const VirtualKeyboard = () => {
// all other labels use their own code
return KeyboardCodes.get(base);
}
if (keyboardLanguage === 'de' && key.endsWith('_qwertz')) {
const base = key.replace('_qwertz', '');
// Tausch
if (base === 'KeyZ') return KeyboardCodes.get('KeyY');
if (base === 'KeyY') return KeyboardCodes.get('KeyZ');
if (base === 'IntlBackslash') return KeyboardCodes.get('IntlBackslash_qwertz');
// all other labels use their own code
return KeyboardCodes.get(base);
}
const specialKey = specialKeyMap.get(key);
if (specialKey) {

View File

@@ -29,6 +29,14 @@ export const keyboardOptions = {
'{shiftleft} RusZ RusX RusC RusV RusB RusN RusM RusComma RusPeriod RusSlash {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
],
qwertz: [
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote_qwertz Digit1_qwertz Digit2_qwertz Digit3_qwertz Digit4_qwertz Digit5_qwertz Digit6_qwertz Digit7_qwertz Digit8_qwertz Digit9_qwertz Digit0_qwertz Minus_qwertz Equal_qwertz {backspace}',
'{tab} KeyQ_qwertz KeyW KeyE_qwertz KeyR KeyT KeyZ_qwertz KeyU KeyI KeyO KeyP BracketLeft_qwertz BracketRight_qwertz',
'{capslock} KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon_qwertz Quote_qwertz Backslash_qwertz {enter}',
'{shiftleft} IntlBackslash_qwertz KeyY_qwertz KeyX KeyC KeyV KeyB KeyN KeyM Comma_qwertz Period_qwertz Slash_qwertz {shiftright}',
'{controlleft_qwertz} {winleft} {altleft} {space} {altright_qwertz} {winright} {menu} {controlright_qwertz}'
],
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}',
@@ -55,6 +63,39 @@ export const keyboardOptions = {
Equal: '+<br/>=',
'{backspace}': 'Backspace',
Backquote_qwertz: '^<br/>°',
Digit1_qwertz: '1<br/>!',
Digit2_qwertz: '2<br/>"',
Digit3_qwertz: '3<br/>§',
Digit4_qwertz: '4<br/>$',
Digit5_qwertz: '5<br/>%',
Digit6_qwertz: '6<br/>&',
Digit7_qwertz: '7{/',
Digit8_qwertz: '8[(',
Digit9_qwertz: '9])',
Digit0_qwertz: '0}=',
Minus_qwertz: 'ß\\?',
Equal_qwertz: '´<br/>`',
KeyY_qwertz: 'Y',
KeyZ_qwertz: 'Z',
KeyQ_qwertz: 'Q<br/>@',
KeyE_qwertz: 'E<br/>€',
BracketRight_qwertz: '+~*',
Backslash_qwertz: '#<br/>\'',
IntlBackslash_qwertz: '<|>',
Comma_qwertz: ',<br/>;',
Period_qwertz: '.<br/>:',
Slash_qwertz: '-<br/>_',
'{controlleft_qwertz}': 'STRG',
'{altright_qwertz}': 'ALT GR',
'{controlright_qwertz}': 'STRG',
BracketLeft_qwertz: 'Ü',
Semicolon_qwertz: 'Ö',
Quote_qwertz: 'Ä',
'{tab}': 'Tab',
KeyQ: 'Q',
KeyW: 'W',
@@ -275,7 +316,12 @@ export const specialKeyMap = new Map([
['{arrowright}', 'ArrowRight'],
['{arrowleft}', 'ArrowLeft'],
['{arrowdown}', 'ArrowDown'],
['{arrowup}', 'ArrowUp']
['{arrowup}', 'ArrowUp'],
//DE
['{controlleft_qwertz}', 'ControlLeft'],
['{controlright_qwertz}', 'ControlRight'],
['{altright_qwertz}', 'AltRight']
]);
// modifier keys
@@ -289,7 +335,12 @@ export const modifierKeys = [
'{controlright}',
'{altright}',
'{metaright}',
'{winright}'
'{winright}',
// DE-spezifische Modifier-Tokens (für qwertz-Layout)
'{controlleft_qwertz}',
'{controlright_qwertz}',
'{altright_qwertz}'
];
// double line display buttons

View File

@@ -1,12 +1,13 @@
import { useEffect, useState } from 'react';
import { notification } from 'antd';
import { Button, Modal, notification, Typography } from 'antd';
import clsx from 'clsx';
import {
ArrowBigDownDashIcon,
ArrowBigUpDashIcon,
LoaderCircleIcon,
PackageIcon,
PackageSearchIcon
PackageSearchIcon,
Trash2Icon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -27,6 +28,9 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
const [images, setImages] = useState<string[]>([]);
const [mountingImage, setMountingImage] = useState('');
const [mountedImage, setMountedImage] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedImage, setSelectedImage] = useState('');
const [deletingImage, setDeletingImage] = useState('');
useEffect(() => {
if (isOpen) {
@@ -34,6 +38,7 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
}
}, [isOpen]);
// get image list
function getImages() {
if (isLoading) return;
setIsLoading(true);
@@ -59,32 +64,33 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
});
}
async function getMountedImage() {
try {
const rsp = await api.getMountedImage();
// get mounted image
function getMountedImage() {
api.getMountedImage().then((rsp) => {
if (rsp.code !== 0) return;
const file = rsp.data?.file;
setMountedImage(file);
setIsMounted(!!file);
} catch (e) {
console.log(e);
}
});
}
// mount/unmount image
function mountImage(image: string) {
if (mountingImage) return;
setMountingImage(image);
client.close();
const filename = mountedImage === image ? '' : image;
const isMounted = mountedImage === image;
const filename = isMounted ? '' : image;
api
.mountImage(filename, cdrom)
.then((rsp) => {
if (rsp.code !== 0) {
openNotification();
console.log(rsp.msg);
openNotification(isMounted);
return;
}
@@ -97,42 +103,90 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
});
}
function openNotification() {
// show delete image modal
function showDeleteModal(e: any, image: string) {
e.stopPropagation();
const isMounted = mountedImage === image;
const isDeleting = deletingImage !== '';
if (isMounted || isDeleting) {
return;
}
setSelectedImage(image);
setIsModalOpen(true);
}
// delete image
function deleteImage() {
if (!selectedImage || !!deletingImage) return;
setDeletingImage(selectedImage);
setIsModalOpen(false);
api
.deleteImage(selectedImage)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
getImages();
setSelectedImage('');
})
.finally(() => {
setDeletingImage('');
});
}
// show mount/unmount failed notification
function openNotification(isMounted: boolean) {
const message = isMounted ? 'image.unmountFailed' : 'image.mountFailed';
const description = isMounted ? 'image.unmountDesc' : 'image.mountDesc';
notify.open({
message: t('image.mountFailed'),
description: t('image.mountDesc'),
duration: 6
message: t(message),
description: t(description),
duration: 10
});
}
// loading
if (isLoading) {
return (
<div className="flex items-center justify-center space-x-2 py-5 text-neutral-400">
<LoaderCircleIcon className="animate-spin" size={18} />
<span className="text-sm">{t('image.loading')}</span>
</div>
);
}
// empty image
if (images.length === 0) {
return (
<div className="flex items-center justify-center space-x-2 py-5 text-neutral-500">
<PackageSearchIcon size={18} />
<span className="text-sm">{t('image.empty')}</span>
</div>
);
}
return (
<>
{contextHolder}
{isLoading ? (
// loading
<div className="flex items-center space-x-2 py-2 pl-2 pr-4 text-neutral-400">
<LoaderCircleIcon className="animate-spin" size={18} />
<span className="text-sm">{t('image.loading')}</span>
</div>
) : images.length === 0 ? (
// no image
<div className="flex items-center space-x-2 pl-2 pr-4 text-neutral-500">
<PackageSearchIcon size={18} />
<span className="text-sm">{t('image.empty')}</span>
</div>
) : (
// image list
images.map((image) => (
<div className="flex max-h-[400px] flex-col overflow-y-auto pb-2">
{images.map((image) => (
<div
key={image}
className={clsx(
'group flex max-w-[300px] cursor-pointer select-none items-center space-x-1 rounded p-2 hover:bg-neutral-700/70',
'group flex cursor-pointer select-none items-center space-x-1 rounded px-1 py-2 hover:bg-neutral-700/70',
mountedImage === image && 'text-blue-500'
)}
onClick={() => mountImage(image)}
>
<div className="h-[18px] w-[18px]">
<div className="flex h-[24px] w-[24px] items-center justify-center">
{mountingImage === image ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : (
@@ -142,16 +196,54 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
<div className="flex-1 truncate">{image.replace(/^.*[\\/]/, '')}</div>
<div className="h-[18px] w-[18px]">
<div className="flex h-[24px] w-[24px] items-center justify-center rounded">
{mountedImage === image ? (
<ArrowBigDownDashIcon size={18} className="hidden text-red-500 group-hover:block" />
<ArrowBigDownDashIcon size={22} className="hidden text-red-500 group-hover:block" />
) : (
<ArrowBigUpDashIcon size={18} className="hidden text-blue-500 group-hover:block" />
<ArrowBigUpDashIcon size={22} className="hidden text-blue-500 group-hover:block" />
)}
</div>
<div
className={clsx(
'flex h-[24px] w-[24px] items-center justify-center rounded hover:bg-neutral-500/50',
mountedImage === image
? 'cursor-not-allowed text-neutral-500'
: 'text-neutral-300 hover:text-red-500'
)}
onClick={(e) => showDeleteModal(e, image)}
>
{deletingImage === image ? (
<LoaderCircleIcon className="animate-spin text-red-500" size={16} />
) : (
<Trash2Icon size={16} />
)}
</div>
</div>
))
)}
))}
</div>
<Modal
title={t('image.attention')}
open={isModalOpen}
width={520}
footer={null}
onCancel={() => setIsModalOpen(false)}
>
<div className="flex flex-col items-center pb-10">
<p>{t('image.deleteConfirm')}</p>
<Typography.Text code>{selectedImage}</Typography.Text>
</div>
<div className="flex justify-center space-x-3 pb-3">
<Button type="primary" danger onClick={deleteImage}>
{t('image.okBtn')}
</Button>
<Button onClick={() => setIsModalOpen(false)}>{t('image.cancelBtn')}</Button>
</div>
</Modal>
{contextHolder}
</>
);
};

View File

@@ -1,11 +1,10 @@
import { useEffect, useState } from 'react';
import { Divider, Switch, Tooltip } from 'antd';
import { Divider, Modal, Segmented } from 'antd';
import clsx from 'clsx';
import { DiscIcon } from 'lucide-react';
import { DiscIcon, HardDriveIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getCdRom, getMountedImage } from '@/api/storage.ts';
import { MenuItem } from '@/components/menu-item.tsx';
import * as api from '@/api/storage.ts';
import { Images } from './images.tsx';
import { Tips } from './tips.tsx';
@@ -13,64 +12,76 @@ import { Tips } from './tips.tsx';
export const Image = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const [cdrom, setCdrom] = useState(false);
const [mode, setMode] = useState('mass-storage');
const modes = [
{
value: 'mass-storage',
label: (
<div className="flex items-center space-x-1">
<HardDriveIcon size={16} />
<span>Mass Storage</span>
</div>
)
},
{
value: 'cd-rom',
label: (
<div className="flex items-center space-x-1">
<DiscIcon size={16} />
<span>CD ROM</span>
</div>
)
}
];
useEffect(() => {
getMountedImage().then((rsp) => {
api.getMountedImage().then((rsp) => {
if (rsp.code === 0) {
setIsMounted(!!rsp.data?.file);
}
});
getCdRom().then((rsp) => {
api.getCdRom().then((rsp) => {
if (rsp.code === 0) {
setCdrom(rsp.data?.cdrom === 1);
setMode(rsp.data?.cdrom === 1 ? 'cd-rom' : 'mass-storage');
}
});
}, []);
const content = (
<div className="min-w-[300px]">
<div className="flex items-center justify-between px-1">
return (
<>
<div
className={clsx(
'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700',
isMounted ? 'text-blue-500' : 'text-neutral-300 hover:text-white'
)}
onClick={() => setIsModalOpen(true)}
>
<DiscIcon size={18} />
</div>
<Modal open={isModalOpen} footer={null} onCancel={() => setIsModalOpen(false)}>
<div className="flex items-center space-x-1">
<span className="text-base font-bold text-neutral-300">{t('image.title')}</span>
<span className="text-xl font-bold">{t('image.title')}</span>
<Tips />
</div>
<div className="flex items-center space-x-2">
<Tooltip title={t('image.cdrom')} placement="bottom">
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-400">CD-ROM</span>
<Divider style={{ margin: '24px 0' }} />
<Switch
size="small"
checked={cdrom}
onChange={(checked) => setCdrom(checked)}
></Switch>
</div>
</Tooltip>
<div className="flex flex-col space-y-6">
<div className="flex items-center justify-between">
<span>{t('image.mountMode')}</span>
<Segmented value={mode} options={modes} disabled={isMounted} onChange={setMode} />
</div>
<Divider style={{ margin: '24px 0 0 0' }} />
<Images isOpen={isModalOpen} cdrom={mode === 'cd-rom'} setIsMounted={setIsMounted} />
</div>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
<Images isOpen={isPopoverOpen} cdrom={cdrom} setIsMounted={setIsMounted} />
</div>
);
return (
<MenuItem
title={t('image.title')}
icon={<DiscIcon size={17} />}
content={content}
className={clsx(
'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700',
isMounted ? 'text-blue-500' : 'text-neutral-300 hover:text-white'
)}
fresh={true}
onOpenChange={setIsPopoverOpen}
/>
</Modal>
</>
);
};

View File

@@ -51,13 +51,10 @@ export const Tips = () => {
return (
<>
<div
className="flex cursor-pointer items-center space-x-1 text-neutral-400 hover:text-blue-400"
className="flex cursor-pointer items-center space-x-1 text-neutral-500 hover:text-blue-500"
onClick={() => setIsModalOpen(true)}
>
<CircleHelpIcon size={16} />
{/*<span className="text-sm text-neutral-500 hover:text-neutral-400">*/}
{/* {t('image.tips.title')}*/}
{/*</span>*/}
</div>
<Modal

View File

@@ -1,14 +1,15 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Button, Input, Modal, type InputRef } from 'antd';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { Button, Divider, Input, Modal, Select } from 'antd';
import type { InputRef } from 'antd';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { ClipboardIcon } from 'lucide-react';
import { ClipboardIcon, ClipboardPasteIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { paste } from '@/api/hid';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
const { TextArea } = Input;
type InputStatus = '' | 'error';
export const Paste = () => {
const { t } = useTranslation();
@@ -16,23 +17,56 @@ export const Paste = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [status, setStatus] = useState<'' | 'error'>('');
const [isLoading, setIsLoading] = useState(false);
const [isClipboardSupported, setIsClipboardSupported] = useState(false);
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
const [langue, setLangue] = useState('en');
const [status, setStatus] = useState<InputStatus>('');
const [errMsg, setErrMsg] = useState('');
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<InputRef>(null);
const languages = [
{ value: 'en', label: t('keyboard.dropdownEnglish') },
{ value: 'de', label: t('keyboard.dropdownGerman') }
];
useEffect(() => {
setIsClipboardSupported('clipboard' in navigator);
}, []);
function onChange(e: ChangeEvent<HTMLTextAreaElement>) {
const value = e.target.value;
setStatus(isASCII(value) ? '' : 'error');
setInputValue(value);
}
async function readFromClipboard() {
if (isReadingClipboard) return;
setIsReadingClipboard(true);
try {
const text = await navigator.clipboard.readText();
if (!text) {
return;
}
setInputValue((value) => value + text);
} catch (error) {
if (error instanceof Error && error.name === 'NotAllowedError') {
setErrMsg(t('keyboard.clipboardPermissionDenied'));
return;
}
setErrMsg(t('keyboard.clipboardReadError'));
} finally {
setIsReadingClipboard(false);
}
}
function submit() {
if (isLoading) return;
if (isLoading || !inputValue) return;
setIsLoading(true);
paste(inputValue)
paste(inputValue, langue)
.then((rsp) => {
if (rsp.code !== 0) {
setErrMsg(rsp.msg);
@@ -81,28 +115,66 @@ export const Paste = () => {
<Modal
open={isModalOpen}
centered={false}
title={t('keyboard.paste')}
footer={null}
onCancel={() => setIsModalOpen(false)}
afterOpenChange={afterOpenChange}
>
<div className="pb-3 text-xs text-neutral-500">{t('keyboard.tips')}</div>
<div className="flex flex-col">
<span className="text-xl">{t('keyboard.paste')}</span>
<span className="text-sm text-neutral-600">{t('keyboard.tips')}</span>
</div>
<TextArea
<Divider style={{ margin: '14px 0' }} />
<div
className={clsx(
'flex w-full items-center space-x-3 pb-2',
isClipboardSupported ? 'justify-start' : 'justify-end'
)}
>
{isClipboardSupported && (
<Button
color="default"
variant="filled"
icon={<ClipboardPasteIcon size={16} />}
loading={isReadingClipboard}
onClick={readFromClipboard}
className="flex items-center"
>
{t('keyboard.readClipboard') || 'Read from Clipboard'}
</Button>
)}
<Select
value={langue}
variant="filled"
onChange={(value) => setLangue(value)}
options={languages}
></Select>
</div>
<Input.TextArea
ref={inputRef}
value={inputValue}
status={status}
showCount
maxLength={1024}
autoSize={{ minRows: 5, maxRows: 12 }}
autoSize={{ minRows: 6, maxRows: 12 }}
placeholder={t('keyboard.placeholder')}
onFocus={() => setErrMsg('')}
onChange={onChange}
/>
{errMsg && <div className="pt-1 text-sm text-red-500">{errMsg}</div>}
<div className="flex justify-center py-3">
<Button type="primary" loading={isLoading} htmlType="submit" onClick={submit}>
<div className="flex justify-center py-5">
<Button
type="primary"
loading={isLoading}
htmlType="submit"
style={{ width: '300px' }}
onClick={submit}
>
{t('keyboard.submit')}
</Button>
</div>

View File

@@ -22,7 +22,6 @@ export const Screen = () => {
const videoMode = useAtomValue(videoModeAtom);
const resolution = useAtomValue(resolutionAtom);
const [fps, setFps] = useState(30);
const [quality, setQuality] = useState(2);
const [gop, setGop] = useState(30);
@@ -74,7 +73,8 @@ export const Screen = () => {
<Resolution />
<Quality quality={quality} setQuality={setQuality} />
<Fps fps={fps} setFps={setFps} />
{videoMode === 'mjpeg' ? <FrameDetect /> : <Gop gop={gop} setGop={setGop} />}
{videoMode !== 'mjpeg' && <Gop gop={gop} setGop={setGop} />}
{videoMode === 'mjpeg' && <FrameDetect />}
<Reset />
</div>
);

View File

@@ -37,7 +37,7 @@ export const Community = () => {
{communities.map((community) => (
<a
key={community.name}
className="flex h-20 w-20 flex-col items-center justify-center space-y-2 rounded-lg text-neutral-300 outline outline-1 outline-neutral-700 hover:bg-neutral-800 hover:text-white focus:bg-neutral-800"
className="flex h-[64px] w-[80px] flex-col items-center justify-center space-y-2 rounded-lg text-neutral-300 outline outline-1 outline-neutral-800 hover:bg-neutral-800 hover:text-white focus:bg-neutral-800 md:h-[72px] md:w-[100px]"
href={community.url}
target="_blank"
>

View File

@@ -1,16 +1,13 @@
import { useEffect, useState } from 'react';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, Input } from 'antd';
import { useSetAtom } from 'jotai';
import { ClipboardPenIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
export const Hostname = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isLoading, setIsLoading] = useState(false);
const [hostname, setHostname] = useState('');
@@ -76,8 +73,6 @@ export const Hostname = () => {
<div className="flex items-center space-x-1">
<Input
disabled={isLoading}
onFocus={() => setIsKeyboardEnable(false)}
onBlur={() => setIsKeyboardEnable(true)}
style={{ width: 150 }}
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -86,13 +81,13 @@ export const Hostname = () => {
<Button size="small" icon={<CloseOutlined />} onClick={() => setEditState('')} />
</div>
) : (
<div className="flex items-start space-x-2">
<div className="flex items-center space-x-2">
<span>{hostname}</span>
<div
className="cursor-pointer text-blue-500 hover:text-blue-500/60"
className="size-[16px] cursor-pointer text-neutral-500 hover:text-blue-500"
onClick={showInput}
>
<ClipboardPenIcon size={18} />
<ClipboardPenIcon size={16} />
</div>
</div>
)}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Tag, Tooltip } from 'antd';
import { CircleHelpIcon } from 'lucide-react';
import { Tooltip } from 'antd';
import { CircleHelpIcon, EthernetPortIcon, WifiIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
@@ -47,9 +47,15 @@ export const Information = () => {
{information?.ips && information.ips.length > 0 ? (
<div className="flex flex-col space-y-1">
{information.ips.map((ip) => (
<div key={ip.addr} className="flex items-center">
<Tag>{t('settings.about.ipType.' + ip.type)}</Tag>
<div key={ip.addr} className="flex items-center justify-end space-x-2">
<span>{ip.addr}</span>
<div className="size-[16px] text-neutral-500">
{ip.type === 'Wireless' ? (
<WifiIcon size={16} />
) : (
<EthernetPortIcon size={16} />
)}
</div>
</div>
))}
</div>
@@ -97,12 +103,6 @@ export const Information = () => {
<span>{information ? information.application : '-'}</span>
</div>
{/* device key */}
{/*<div className="flex w-full items-center justify-between">*/}
{/* <span>{t('settings.about.deviceKey')}</span>*/}
{/* <span>{information ? information.deviceKey : '-'}</span>*/}
{/*</div>*/}
</div>
</>
);

View File

@@ -1,19 +1,17 @@
import { useEffect, useState } from 'react';
import { Input } from 'antd';
import { useSetAtom } from 'jotai';
import { useAtom } from 'jotai';
import { GlobeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { webTitleAtom } from '@/jotai/settings.ts';
export const WebTitle = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const setWebTitle = useSetAtom(webTitleAtom);
const [webTitle, setWebTitle] = useAtom(webTitleAtom);
const [isLoading, setIsLoading] = useState(false);
const [title, setTitle] = useState('NanoKVM');
useEffect(() => {
setIsLoading(true);
@@ -22,7 +20,6 @@ export const WebTitle = () => {
.getWebTitle()
.then((rsp) => {
if (rsp.data?.title) {
setTitle(rsp.data.title);
setWebTitle(rsp.data.title);
}
})
@@ -31,19 +28,17 @@ export const WebTitle = () => {
});
}, []);
function update() {
function submit() {
if (isLoading) return;
setIsLoading(true);
api
.setWebTitle(title)
.setWebTitle(webTitle)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setWebTitle(title === 'NanoKVM' ? '' : title);
})
.finally(() => {
setIsLoading(false);
@@ -53,7 +48,10 @@ export const WebTitle = () => {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1">
<div className="flex size-[16px] items-center justify-center">
<GlobeIcon size={14} />
</div>
<span>{t('settings.appearance.webTitle')}</span>
</div>
<span className="text-xs text-neutral-500">{t('settings.appearance.webTitleDesc')}</span>
@@ -61,12 +59,12 @@ export const WebTitle = () => {
<Input
disabled={isLoading}
onFocus={() => setIsKeyboardEnable(false)}
onBlur={() => setIsKeyboardEnable(true)}
style={{ width: 180 }}
value={title}
onChange={(e) => setTitle(e.target.value)}
onPressEnter={update}
value={webTitle}
onChange={(e) => setWebTitle(e.target.value)}
onPressEnter={submit}
onBlur={submit}
placeholder="NanoKVM"
/>
</div>
);

View File

@@ -20,6 +20,10 @@ export const Swap = () => {
];
useEffect(() => {
getSwap();
}, []);
function getSwap() {
setIsLoading(true);
api
@@ -32,9 +36,9 @@ export const Swap = () => {
.finally(() => {
setIsLoading(false);
});
}, []);
}
async function update(value: string) {
function update(value: string) {
if (isLoading) return;
setIsLoading(true);

View File

@@ -20,7 +20,6 @@ import { Wifi } from './wifi.tsx';
export const Device = () => {
const { t } = useTranslation();
const [hidMode, setHidMode] = useAtom(hidModeAtom);
useEffect(() => {

View File

@@ -8,8 +8,9 @@ export const VirtualDevices = () => {
const { t } = useTranslation();
const [isNetworkOn, setIsNetworkOn] = useState(false);
const [isMediaOn, setIsMediaOn] = useState(false);
const [isDiskOn, setIsDiskOn] = useState(false);
const [loading, setLoading] = useState(''); // '' | 'network' | 'disk'
const [loading, setLoading] = useState(''); // '' | 'network' | 'media' | 'disk'
useEffect(() => {
api.getVirtualDevice().then((rsp) => {
@@ -18,11 +19,12 @@ export const VirtualDevices = () => {
}
setIsNetworkOn(rsp.data.network);
setIsMediaOn(rsp.data.media);
setIsDiskOn(rsp.data.disk);
});
}, []);
function update(device: 'network' | 'disk') {
function update(device: 'network' | 'media' | 'disk') {
if (loading) return;
setLoading(device);
@@ -33,10 +35,16 @@ export const VirtualDevices = () => {
return;
}
if (device === 'network') {
setIsNetworkOn(rsp.data.on);
} else {
setIsDiskOn(rsp.data.on);
switch (device) {
case 'network':
setIsNetworkOn(rsp.data.on);
break;
case 'media':
setIsMediaOn(rsp.data.on);
break;
case 'disk':
setIsDiskOn(rsp.data.on);
break;
}
})
.finally(() => {
@@ -46,6 +54,15 @@ export const VirtualDevices = () => {
return (
<>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span>{t('settings.device.media')}</span>
<span className="text-xs text-neutral-500">{t('settings.device.mediaDesc')}</span>
</div>
<Switch checked={isMediaOn} loading={loading === 'media'} onChange={() => update('media')} />
</div>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span>{t('settings.device.disk')}</span>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Badge, Modal, Tooltip } from 'antd';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import {
BadgeInfoIcon,
CircleArrowUpIcon,
@@ -14,6 +15,7 @@ import semver from 'semver';
import * as api from '@/api/application.ts';
import * as ls from '@/lib/localstorage.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { Tailscale as TailscaleIcon } from '@/components/icons/tailscale';
import { About } from './about';
@@ -31,6 +33,7 @@ export const Settings = () => {
const [currentTab, setCurrentTab] = useState('about');
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const tabs = [
{ id: 'about', icon: <BadgeInfoIcon size={16} />, component: <About /> },
@@ -81,11 +84,17 @@ export const Settings = () => {
}
}
function openModal() {
setIsModalOpen(true);
setIsKeyboardEnable(false);
}
function closeModal() {
if (isLocked) {
return;
}
setIsKeyboardEnable(true);
setIsModalOpen(false);
setCurrentTab('about');
}
@@ -95,7 +104,7 @@ export const Settings = () => {
<Tooltip title={t('settings.title')} placement="bottom" mouseEnterDelay={0.6}>
<div
className="flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/80"
onClick={() => setIsModalOpen(true)}
onClick={openModal}
>
<Badge dot={isUpdateAvailable} color="blue" offset={[0, 2]}>
<div className="pt-[3px]">
@@ -107,17 +116,20 @@ export const Settings = () => {
<Modal
open={isModalOpen}
width={900}
width={'80%'}
centered={true}
footer={null}
destroyOnClose={true}
styles={{ content: { padding: 0 } }}
onCancel={closeModal}
style={{ maxWidth: '1080px' }}
styles={{ content: { padding: 0 } }}
>
<div className="flex min-h-[500px] rounded-lg outline outline-1 outline-neutral-700">
<div className="flex flex-col space-y-0.5 rounded-l-lg bg-neutral-800 py-5 sm:w-1/5 sm:px-2">
<div className="hidden px-3 text-lg font-bold sm:block">{t('settings.title')}</div>
<div className="flex h-[80vh] max-h-[700px] rounded-lg outline outline-1 outline-neutral-700">
<div className="flex h-full max-w-[240px] flex-col space-y-0.5 rounded-l-lg bg-neutral-800 px-1 sm:w-1/5 md:px-2">
<div className="hidden px-3 pt-10 text-xl sm:block">{t('settings.title')}</div>
<div className="h-10 sm:h-5" />
<div className="pt-3" />
{tabs.map((tab) => (
<div
key={tab.id}
@@ -144,8 +156,8 @@ export const Settings = () => {
))}
</div>
<div className="flex max-h-[700px] w-full flex-col items-center overflow-y-auto rounded-r-lg bg-neutral-900 px-3 sm:w-4/5">
<div className="w-full max-w-[550px] py-10">
<div className="flex h-full w-full flex-col items-center overflow-y-auto rounded-r-lg bg-neutral-900 px-3">
<div className="w-full max-w-[600px] pb-10 pt-14">
<>{tabs.find((tab) => tab.id === currentTab)?.component}</>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import * as api from '@/api/extensions/tailscale.ts';
import { Memory } from './memory.tsx';
import { Swap } from './swap.tsx';
import type { State } from './types.ts';
import { Uninstall } from './uninstall.tsx';
@@ -93,6 +94,7 @@ export const Header = ({ state, onSuccess }: HeaderProps) => {
content={
<div className="flex min-w-[250px] flex-col">
<Memory />
<Swap />
<Uninstall onSuccess={onSuccess} />
</div>
}

View File

@@ -9,6 +9,7 @@ import { Device } from './device.tsx';
import { Header } from './header.tsx';
import { Install } from './install.tsx';
import { Login } from './login.tsx';
import { Run } from './run.tsx';
import type { Status } from './types.ts';
type TailscaleProps = {
@@ -61,9 +62,9 @@ export const Tailscale = ({ setIsLocked }: TailscaleProps) => {
<Install setIsLocked={setIsLocked} onSuccess={getStatus} />
)}
{(status?.state === 'notRunning' || status?.state === 'notLogin') && (
<Login onSuccess={getStatus} />
)}
{status?.state === 'notRunning' && <Run onSuccess={getStatus} />}
{status?.state === 'notLogin' && <Login onSuccess={getStatus} />}
{(status?.state === 'stopped' || status?.state === 'running') && (
<Device status={status} onLogout={getStatus} />

View File

@@ -36,54 +36,50 @@ export const Install = ({ setIsLocked, onSuccess }: InstallProps) => {
});
}
if (state === 'failed') {
return (
<Result
status="warning"
title={t('settings.tailscale.failed')}
subTitle={t('settings.tailscale.retry')}
icon={<InfoCircleOutlined />}
extra={
<Card key="tips" styles={{ body: { padding: 0 } }}>
<ul className="list-decimal text-left font-mono text-sm text-neutral-300">
<li>
{t('settings.tailscale.download')}
<a
className="px-1"
href="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
target="_blank"
>
{t('settings.tailscale.package')}
</a>
{t('settings.tailscale.unzip')}
</li>
<li>{t('settings.tailscale.upTailscale')}</li>
<li>{t('settings.tailscale.upTailscaled')}</li>
<li>{t('settings.tailscale.refresh')}</li>
</ul>
</Card>
}
/>
);
}
return (
<>
{state !== 'failed' ? (
<Result
icon={<DownloadOutlined />}
subTitle={t('settings.tailscale.notInstall')}
extra={
<Button
key="install"
type="primary"
size="large"
loading={state === 'installing'}
onClick={install}
>
{state === 'installing'
? t('settings.tailscale.installing')
: t('settings.tailscale.install')}
</Button>
}
/>
) : (
<Result
status="warning"
title={t('settings.tailscale.failed')}
subTitle={t('settings.tailscale.retry')}
icon={<InfoCircleOutlined />}
extra={
<Card key="tips" styles={{ body: { padding: 0 } }}>
<ul className="list-decimal text-left font-mono text-sm text-neutral-300">
<li>
{t('settings.tailscale.download')}
<a
className="px-1"
href="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
target="_blank"
>
{t('settings.tailscale.package')}
</a>
{t('settings.tailscale.unzip')}
</li>
<li>{t('settings.tailscale.upTailscale')}</li>
<li>{t('settings.tailscale.upTailscaled')}</li>
<li>{t('settings.tailscale.refresh')}</li>
</ul>
</Card>
}
/>
)}
</>
<Card>
<Result
icon={<DownloadOutlined />}
subTitle={t('settings.tailscale.notInstall')}
extra={
<Button key="install" type="primary" loading={state === 'installing'} onClick={install}>
{state === 'installing'
? t('settings.tailscale.installing')
: t('settings.tailscale.install')}
</Button>
}
/>
</Card>
);
};

View File

@@ -27,7 +27,7 @@ export const Memory = () => {
setIsLoading(true);
const enabled = !isEnabled;
const limit = isEnabled ? 0 : 50;
const limit = isEnabled ? 0 : 75;
api
.setMemoryLimit(enabled, limit)
@@ -45,22 +45,20 @@ export const Memory = () => {
}
return (
<>
<div className="flex h-[40px] cursor-pointer items-center justify-between space-x-6 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
<div className="flex items-center space-x-1">
<span>{t('settings.tailscale.memory.title')}</span>
<Tooltip
title={t('settings.tailscale.memory.tip')}
className="cursor-pointer text-neutral-500"
placement="top"
overlayStyle={{ maxWidth: '350px' }}
>
<CircleHelpIcon size={15} />
</Tooltip>
</div>
<Switch value={isEnabled} size="small" onClick={update} />
<div className="flex h-[40px] cursor-pointer items-center justify-between space-x-6 rounded px-2 text-neutral-300 hover:bg-neutral-700/70">
<div className="flex items-center space-x-1">
<span>{t('settings.tailscale.memory.title')}</span>
<Tooltip
title={t('settings.tailscale.memory.tip')}
className="cursor-pointer text-neutral-500"
placement="top"
overlayStyle={{ maxWidth: '400px' }}
>
<CircleHelpIcon size={15} />
</Tooltip>
</div>
</>
<Switch value={isEnabled} size="small" onClick={update} />
</div>
);
};

View File

@@ -0,0 +1,54 @@
import { useState } from 'react';
import { PauseCircleOutlined } from '@ant-design/icons';
import { Button, Card, Result } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/extensions/tailscale.ts';
type RunProps = {
onSuccess: () => void;
};
export const Run = ({ onSuccess }: RunProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [errMsg, setErrMsg] = useState('');
function run() {
if (isLoading) return;
setIsLoading(true);
api
.start()
.then((rsp) => {
if (rsp.code !== 0) {
setErrMsg(rsp.msg);
return;
}
onSuccess();
})
.finally(() => {
setIsLoading(false);
});
}
return (
<Card>
<Result
icon={<PauseCircleOutlined />}
subTitle={t('settings.tailscale.notRunning')}
extra={
<Button key="install" type="primary" loading={isLoading} onClick={run}>
{t('settings.tailscale.run')}
</Button>
}
/>
<div className="flex justify-center">
{errMsg && <span className="text-red-500">{errMsg}</span>}
</div>
</Card>
);
};

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { Switch, Tooltip } from 'antd';
import { CircleHelpIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
export const Swap = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [isEnabled, setIsEnabled] = useState(false);
useEffect(() => {
getSwap();
}, []);
function getSwap() {
setIsLoading(true);
api
.getSwap()
.then((rsp) => {
if (rsp.data?.size > 0) {
setIsEnabled(true);
}
})
.finally(() => {
setIsLoading(false);
});
}
function update(enable: boolean) {
if (isLoading) return;
setIsLoading(true);
const size = enable ? 256 : 0;
api
.setSwap(size)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsEnabled(enable);
})
.finally(() => {
setIsLoading(false);
});
}
return (
<div className="flex h-[40px] cursor-pointer items-center justify-between space-x-6 rounded px-2 text-neutral-300 hover:bg-neutral-700/70">
<div className="flex items-center space-x-1">
<span>{t('settings.tailscale.swap.title')}</span>
<Tooltip
title={t('settings.tailscale.swap.tip')}
className="cursor-pointer text-neutral-500"
placement="top"
overlayStyle={{ maxWidth: '400px' }}
>
<CircleHelpIcon size={15} />
</Tooltip>
</div>
<Switch value={isEnabled} loading={isLoading} size="small" onChange={update} />
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Button } from 'antd';
import clsx from 'clsx';
import { Modal } from 'antd';
import { Trash2Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/extensions/tailscale.ts';
@@ -13,44 +13,49 @@ export const Uninstall = ({ onSuccess }: UninstallProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [isConfirmation, setIsConfirmation] = useState(false);
function showConfirmation() {
if (!isConfirmation) {
setIsConfirmation(true);
}
}
const [isModalOpen, setIsModalOpen] = useState(false);
function uninstall() {
if (isLoading) return;
setIsLoading(true);
api.uninstall().finally(() => {
setIsModalOpen(false);
setIsLoading(false);
onSuccess();
});
}
return (
<div
className="flex h-[40px] cursor-pointer items-center justify-between space-x-6 rounded px-3 text-neutral-300 hover:bg-neutral-700/70"
onClick={showConfirmation}
>
<span className={clsx(isConfirmation && 'text-red-500')}>
{t('settings.tailscale.uninstall')}
</span>
{isConfirmation && (
<div className="flex items-center space-x-2">
<Button type="primary" size="small" disabled={isLoading} danger onClick={uninstall}>
{t('settings.tailscale.okBtn')}
</Button>
<Button type="primary" size="small" onClick={() => setIsConfirmation(false)}>
{t('settings.tailscale.cancelBtn')}
</Button>
</div>
)}
const title = (
<div className="flex items-center space-x-1 text-red-500">
<Trash2Icon size={18} />
<span>{t('settings.tailscale.uninstall')}</span>
</div>
);
return (
<>
<div
className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-2 py-1 text-neutral-300 hover:bg-neutral-700/70"
onClick={() => setIsModalOpen(true)}
>
<span>{t('settings.tailscale.uninstall')}</span>
</div>
<Modal
title={title}
open={isModalOpen}
okType="danger"
okText={t('settings.tailscale.okBtn')}
cancelText={t('settings.tailscale.cancelBtn')}
onOk={uninstall}
onCancel={() => setIsModalOpen(false)}
confirmLoading={isLoading}
>
<div className="py-5">
<p className="text-base">{t('settings.tailscale.uninstallDesc')}</p>
</div>
</Modal>
</>
);
};

View File

@@ -12,12 +12,12 @@ type UpdateProps = {
setIsLocked: (isClosable: boolean) => void;
};
type Status = 'loading' | 'updating' | 'outdated' | 'latest' | 'failed';
type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'failed';
export const Update = ({ setIsLocked }: UpdateProps) => {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>('loading');
const [status, setStatus] = useState<Status>('');
const [currentVersion, setCurrentVersion] = useState('');
const [latestVersion, setLatestVersion] = useState('');
const [errMsg, setErrMsg] = useState('');
@@ -27,12 +27,13 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
}, []);
function checkForUpdates() {
if (status === 'loading') return;
setStatus('loading');
api
.getVersion()
.then((rsp: any) => {
if (rsp.code !== 0) {
if (rsp.code !== 0 || !rsp.data) {
setStatus('failed');
setErrMsg(t('settings.update.queryFailed'));
return;
@@ -80,47 +81,53 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
<Divider />
<Preview />
<Divider />
{status === 'loading' && (
<div className="flex justify-center pt-24">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
)}
<div className="my-[40px] h-px bg-neutral-500/10" />
{status === 'updating' && (
<div className="flex flex-col items-center justify-center space-y-10 pb-10 pt-24">
<Spin size="large" />
<span className="text-blue-600">{t('settings.update.updating')}</span>
</div>
)}
<div className="flex min-h-[400px] flex-col justify-between">
{status === 'loading' && (
<div className="flex justify-center pt-24">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
)}
{status === 'latest' && (
<Result
status="success"
icon={<SmileOutlined />}
title={currentVersion}
subTitle={t('settings.update.isLatest')}
/>
)}
{status === 'updating' && (
<div className="flex flex-col items-center justify-center space-y-10 pb-10 pt-24">
<Spin size="large" />
<span className="text-blue-600">{t('settings.update.updating')}</span>
</div>
)}
{status === 'outdated' && (
<Result
status="warning"
icon={<RocketOutlined />}
title={`${currentVersion} -> ${latestVersion}`}
subTitle={t('settings.update.available')}
extra={[
<Button key="confirm" type="primary" onClick={update}>
{t('settings.update.confirm')}
</Button>
]}
/>
)}
{status === 'latest' && (
<Result
status="success"
icon={<SmileOutlined />}
title={currentVersion}
subTitle={t('settings.update.isLatest')}
extra={[
<Button key="confirm" onClick={checkForUpdates}>
{t('settings.update.title')}
</Button>
]}
/>
)}
{status === 'failed' && <Result subTitle={errMsg} />}
{status === 'outdated' && (
<Result
status="warning"
icon={<RocketOutlined />}
title={`${currentVersion} -> ${latestVersion}`}
subTitle={t('settings.update.available')}
extra={[
<Button key="confirm" type="primary" onClick={update}>
{t('settings.update.confirm')}
</Button>
]}
/>
)}
{status === 'failed' && <Result subTitle={errMsg} />}
{status !== 'loading' && (
<div className="flex justify-center">
<Button
type="link"
@@ -131,7 +138,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
CHANGELOG
</Button>
</div>
)}
</div>
</>
);
};

View File

@@ -13,9 +13,14 @@ export const Absolute = () => {
const lastScrollTimeRef = useRef(0);
const mouseButtonMapping = (button: number) => {
const mappings = [MouseButton.Left, MouseButton.Wheel, MouseButton.Right];
return mappings[button] || MouseButton.None;
};
// listen mouse events
useEffect(() => {
const canvas = document.getElementById('screen');
const canvas = document.getElementById('screen') as HTMLVideoElement;
if (!canvas) return;
canvas.addEventListener('mousedown', handleMouseDown);
@@ -29,21 +34,8 @@ export const Absolute = () => {
function handleMouseDown(event: any) {
disableEvent(event);
let button: MouseButton;
switch (event.button) {
case 0:
button = MouseButton.Left;
break;
case 1:
button = MouseButton.Wheel;
break;
case 2:
button = MouseButton.Right;
break;
default:
console.log(`unknown button ${event.button}`);
return;
}
const button: MouseButton = mouseButtonMapping(event.button);
if (button === MouseButton.None) return;
const data = [2, MouseEvent.Down, button, 0, 0];
client.send(data);
@@ -61,13 +53,8 @@ export const Absolute = () => {
function handleMouseMove(event: any) {
disableEvent(event);
const rect = canvas!.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
const hexX = x < 0 ? 0x0001 : Math.floor(0x7fff * x) + 0x0001;
const hexY = y < 0 ? 0x0001 : Math.floor(0x7fff * y) + 0x0001;
const data = [2, MouseEvent.MoveAbsolute, MouseButton.None, hexX, hexY];
const { x, y } = getCoordinate(event);
const data = [2, MouseEvent.MoveAbsolute, MouseButton.None, x, y];
client.send(data);
}
@@ -88,6 +75,53 @@ export const Absolute = () => {
client.send(data);
}
function getCorrectedCoords(clientX: number, clientY: number) {
if (!canvas) {
return { x: 0, y: 0 };
}
const rect = canvas.getBoundingClientRect();
if (!canvas.videoWidth || !canvas.videoHeight) {
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
return { x, y };
}
const videoRatio = canvas.videoWidth / canvas.videoHeight;
const elementRatio = rect.width / rect.height;
let renderedWidth = rect.width;
let renderedHeight = rect.height;
let offsetX = 0;
let offsetY = 0;
if (videoRatio > elementRatio) {
renderedHeight = rect.width / videoRatio;
offsetY = (rect.height - renderedHeight) / 2;
} else {
renderedWidth = rect.height * videoRatio;
offsetX = (rect.width - renderedWidth) / 2;
}
const x = (clientX - rect.left - offsetX) / renderedWidth;
const y = (clientY - rect.top - offsetY) / renderedHeight;
return { x, y };
}
function getCoordinate(event: any): { x: number; y: number } {
const { x, y } = getCorrectedCoords(event.clientX, event.clientY);
const finalX = Math.max(0, Math.min(1, x));
const finalY = Math.max(0, Math.min(1, y));
const hexX = Math.floor(0x7fff * finalX) + 0x0001;
const hexY = Math.floor(0x7fff * finalY) + 0x0001;
return { x: hexX, y: hexY };
}
return () => {
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mousedown', handleMouseDown);

View File

@@ -0,0 +1,147 @@
import Queue from 'yocto-queue';
let canvas: OffscreenCanvas | null = null;
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let rendering: boolean = false;
let decoder: VideoDecoder | null = null;
const frameQueue = new Queue<VideoFrame>();
self.onmessage = (event: MessageEvent) => {
const { type, data, canvas: offscreenCanvas } = event.data;
switch (type) {
case 'h264':
canvas = offscreenCanvas;
ctx = canvas!.getContext('2d') as OffscreenCanvasRenderingContext2D;
break;
case 'ws_message':
handleWsMessage(data);
break;
case 'error':
case 'close':
resetDecoder();
break;
}
};
function handleWsMessage(message: ArrayBuffer) {
try {
if (message.byteLength < 9) {
return;
}
const view = new DataView(message);
const isKeyFrame = view.getUint8(0) === 1;
const timestamp = Number(view.getBigUint64(1, true));
const data = new Uint8Array(message, 9);
if (!decoder && isKeyFrame) {
initializeDecoder();
}
if (decoder?.state === 'configured') {
decode(isKeyFrame, timestamp, data);
}
} catch (error) {
console.error('Error processing WebSocket message in worker:', error);
}
}
function initializeDecoder() {
if (!self.VideoDecoder) {
console.log('Error: WebCodecs API not supported in this worker.');
return;
}
if (decoder && decoder.state !== 'unconfigured') {
return;
}
const init = {
output: (frame: VideoFrame) => {
frameQueue.enqueue(frame);
if (frameQueue.size >= 10) {
frameQueue.dequeue()?.close();
}
if (!rendering) {
rendering = true;
processFrameQueue();
}
},
error: () => {
resetDecoder();
}
};
try {
decoder = new VideoDecoder(init);
decoder.configure({
codec: 'avc1.42E01F',
optimizeForLatency: true
});
} catch (err) {
console.log(err);
decoder = null;
}
}
function decode(isKeyFrame: boolean, timestamp: number, data: Uint8Array) {
const chunk = new EncodedVideoChunk({
type: isKeyFrame ? 'key' : 'delta',
timestamp: timestamp,
data: data
});
try {
decoder?.decode(chunk);
} catch (err: any) {
if (err.name === 'TypeError' || err.message.includes('configured')) {
resetDecoder();
}
}
}
function processFrameQueue() {
const frame = frameQueue.dequeue();
if (frame) {
renderFrame(frame);
}
if (frameQueue.size > 0) {
setTimeout(processFrameQueue, 0);
} else {
rendering = false;
}
}
function renderFrame(frame: VideoFrame) {
if (!canvas || !ctx) {
frame.close();
return;
}
if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) {
canvas.width = frame.displayWidth;
canvas.height = frame.displayHeight;
}
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
frame.close();
}
function resetDecoder() {
if (decoder && decoder.state !== 'closed') {
try {
decoder.close();
} catch (err) {
console.log(err);
}
}
decoder = null;
rendering = false;
Array.from(frameQueue.drain()).forEach((frame) => frame.close());
}

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