mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
Merge branch 'main' into usb-disable
This commit is contained in:
52
CHANGELOG.md
52
CHANGELOG.md
@@ -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
193
README.md
@@ -1,92 +1,88 @@
|
||||
NanoKVM
|
||||
======
|
||||
# NanoKVM
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
<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">
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
<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 | 90~230ms | 90~230ms | 100~230ms |
|
||||
| UEFI/BIOS | ✓ | ✓ | ✓ |
|
||||
| Virtual HID | ✓ | ✓ | ✓ |
|
||||
| Virtual CD-ROM | ✓ | ✓ | ✓ |
|
||||
| IPMI | ✓ | ✓ | ✓ |
|
||||
| Wake-on-LAN | ✓ | ✓ | ✓ |
|
||||
| ETH | 100M/10M | 100M/10M | 1000M/100M/10M |
|
||||
| ATX power control | None,Users can connect by their own | USB interface IO control board | RJ-45 interface IO control board |
|
||||
| OLED | None,Users can connect by their own | 128x64 0.96" white | 128x32 0.91" white |
|
||||
| UART | 2 | 2 | 1 |
|
||||
| TF Card | None | ✓ | ✓ |
|
||||
| Expansion | None | PoE | WiFi/LTE |
|
||||
| Power consumption | 0.2A@5V | 0.2A@5V | Peak 2.6A@5V |
|
||||
| Power input | PC USB can be powered | PC USB or auxiliary power supply | DC 5V 3A power supply is required |
|
||||
| Cooling | Silent fanless | Silent fanless | Fan cooling |
|
||||
| Size | 23x37x15mm ~1/30 PiKVM V4 size | 40x36x36mm ~1/7 PiKVM V4 size | 120x68x44mm |
|
||||
|
||||

|
||||
|
||||
## Hardware platform NanoKVM
|
||||
|
||||
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.
|
||||
|
||||
129
README_JA.md
129
README_JA.md
@@ -1,129 +0,0 @@
|
||||
NanoKVM
|
||||
======
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
<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 (Lite) | NanoKVM (Full) | PiKVM V4 |
|
||||
|----------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |
|
||||
| 計算ユニット | LicheeRV Nano(RISCV) | LicheeRV Nano(RISCV) | CM4 (ARM) |
|
||||
| 解像度 | 1080P @ 60fps | 1080P @ 60fps | 1080P @ 60fps |
|
||||
| ビデオエンコーディング | MJPEG, H264 | MJPEG, H264 | MJPEG, H264 |
|
||||
| ビデオ遅延 | 90~230ms | 90~230ms | 100~230ms |
|
||||
| UEFI/BIOS | ✓ | ✓ | ✓ |
|
||||
| 仮想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 ハードウェアプラットフォーム
|
||||
|
||||
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
|
||||
111
README_ZH.md
111
README_ZH.md
@@ -1,111 +0,0 @@
|
||||
NanoKVM
|
||||
======
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
<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">
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
</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 |
|
||||
| 视频延迟 | 90~230ms | 90~230ms | 100~230ms |
|
||||
| UEFI/BIOS | ✓ | ✓ | ✓ |
|
||||
| 模拟USB键鼠 | ✓ | ✓ | ✓ |
|
||||
| 模拟USB存储 | ✓ | ✓ | ✓ |
|
||||
| IPMI | ✓ | ✓ | ✓ |
|
||||
| Wake-on-LAN | ✓ | ✓ | ✓ |
|
||||
| ETH | 100M/10M | 100M/10M | 1000M/100M/10M |
|
||||
| ATX电源控制 | 无,用户可自行连接 | USB接口IO控制板 | RJ45接口IO控制板 |
|
||||
| OLED显示 | 无,用户可自行扩展 | 128x64 0.96" white | 128x32 0.91" white |
|
||||
| 外接串口 | 2路 | 2路 | 1路 |
|
||||
| TF卡 | 无,用户自备 | 有,开机即用 | 有 |
|
||||
| 扩展配件 | 无 | PoE | WiFi/LTE |
|
||||
| 功耗 | 0.2A@5V | 0.2A@5V | Peak 2.6A@5V |
|
||||
| 电源输入 | PC USB即可供电 | PC USB即可供电 <br> 也支持额外辅助供电 | 需要DC 5V 3A供电 |
|
||||
| 散热 | 静音无风扇 | 静音无风扇 | 需要风扇主动散热 |
|
||||
| 尺寸 | 23x37x15mm <br> ~1/30 PiKVM V4 体积 | 40x36x36mm <br/> ~1/7 PiKVM V4 体积 | 120x68x44mm |
|
||||
|
||||

|
||||
|
||||
## NanoKVM 硬件平台
|
||||
|
||||
NanoKVM 基于 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
64
server/build.sh
Executable 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}"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -16,3 +16,7 @@ type GetMountedImageRsp struct {
|
||||
type GetCdRomRsp struct {
|
||||
Cdrom int64 `json:"cdrom"`
|
||||
}
|
||||
|
||||
type DeleteImageReq struct {
|
||||
File string `json:"file" validate:"required"`
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ type DeleteScriptReq struct {
|
||||
|
||||
type GetVirtualDeviceRsp struct {
|
||||
Network bool `json:"network"`
|
||||
Media bool `json:"media"`
|
||||
Disk bool `json:"disk"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -69,8 +69,6 @@ func (c *Cli) Stop() error {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = os.Remove(ConfigPath)
|
||||
|
||||
return os.Remove(ScriptPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
server/service/stream/direct/pool.go
Normal file
12
server/service/stream/direct/pool.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package direct
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var BufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
123
server/service/stream/direct/streamer.go
Normal file
123
server/service/stream/direct/streamer.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
131
server/service/stream/mjpeg/streamer.go
Normal file
131
server/service/stream/mjpeg/streamer.go
Normal 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
|
||||
}
|
||||
112
server/service/stream/webrtc/client.go
Normal file
112
server/service/stream/webrtc/client.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
158
server/service/stream/webrtc/h264.go
Normal file
158
server/service/stream/webrtc/h264.go
Normal 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,
|
||||
})
|
||||
}
|
||||
96
server/service/stream/webrtc/manager.go
Normal file
96
server/service/stream/webrtc/manager.go
Normal 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()
|
||||
}
|
||||
}
|
||||
149
server/service/stream/webrtc/signaling.go
Normal file
149
server/service/stream/webrtc/signaling.go
Normal 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", "")
|
||||
}
|
||||
53
server/service/stream/webrtc/track.go
Normal file
53
server/service/stream/webrtc/track.go
Normal 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)
|
||||
}
|
||||
}
|
||||
38
server/service/stream/webrtc/types.go
Normal file
38
server/service/stream/webrtc/types.go
Normal 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"`
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
38
server/utils/hdmi.go
Normal 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
|
||||
}
|
||||
BIN
tools/nanokvm_update_edid/E21_NanoKVM.bin
Normal file
BIN
tools/nanokvm_update_edid/E21_NanoKVM.bin
Normal file
Binary file not shown.
22
tools/nanokvm_update_edid/Makefile
Normal file
22
tools/nanokvm_update_edid/Makefile
Normal 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
|
||||
BIN
tools/nanokvm_update_edid/nanokvm_update_edid
Executable file
BIN
tools/nanokvm_update_edid/nanokvm_update_edid
Executable file
Binary file not shown.
749
tools/nanokvm_update_edid/nanokvm_update_edid.c
Normal file
749
tools/nanokvm_update_edid/nanokvm_update_edid.c
Normal 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;
|
||||
}
|
||||
41
tools/nanokvm_update_edid/nanokvm_update_edid.h
Normal file
41
tools/nanokvm_update_edid/nanokvm_update_edid.h
Normal 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
|
||||
@@ -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
1851
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -26,3 +26,4 @@ export function setPreviewUpdates(enable: boolean) {
|
||||
export function getPreviewUpdates() {
|
||||
return http.get('/api/application/preview');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
347
web/src/i18n/locales/ca.ts
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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: '메뉴 펼치기'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
347
web/src/i18n/locales/pt_br.ts
Normal file
347
web/src/i18n/locales/pt_br.ts
Normal 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
353
web/src/i18n/locales/se.ts
Normal 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
353
web/src/i18n/locales/tr.ts
Normal 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-disk’i 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ı açı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;
|
||||
@@ -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: '确认',
|
||||
|
||||
@@ -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: '展開選單'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import { Wifi } from './wifi.tsx';
|
||||
|
||||
export const Device = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [hidMode, setHidMode] = useAtom(hidModeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
54
web/src/pages/desktop/menu/settings/tailscale/run.tsx
Normal file
54
web/src/pages/desktop/menu/settings/tailscale/run.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
71
web/src/pages/desktop/menu/settings/tailscale/swap.tsx
Normal file
71
web/src/pages/desktop/menu/settings/tailscale/swap.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
147
web/src/pages/desktop/screen/direct.worker.ts
Normal file
147
web/src/pages/desktop/screen/direct.worker.ts
Normal 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
Reference in New Issue
Block a user