Compare commits

..

95 Commits
2.2.9 ... 2.3.0

Author SHA1 Message Date
wj-xiao
9311b898f2 update CHANGELOG.md 2025-11-26 15:13:51 +08:00
wj-xiao
0e30a26db4 Streaming Refactor:
- H.264 WebRTC: Refactored to significantly reduce video latency
- H.264 Direct: Optimized data transmission; data parsing now continues correctly even when the tab is in the background
- MJPEG: Refactored to ensure the correct data length is sent

Bug Fixes
- Fixed an issue where certain keyboard modifier keys were not recognized
- Fixed vertical mouse cursor drift when the page is zoomed in or out

Optimizations
- Optimized HID write logic and updated the HID reset mechanism
- Added support for deleting images
- Added a Swap Memory option to the Tailscale page
- Added a confirmation dialog when uninstalling Tailscale to prevent accidental removal
- Improved the logic for updating the web page title
- Improved the UI for the Clipboard, Settings, Image Mounting, and App Update pages

Security
- Added a mandatory delay after failed login attempts to prevent brute-force attacks
- Updated dependencies to patch known security vulnerabilities
2025-11-26 10:57:30 +08:00
wj-xiao
24a460e64a fix en.ts 2025-11-21 12:07:39 +08:00
wenjie
f62320f3e1 Merge pull request #663 from patrickpilon/claude/add-clipboard-copy-paste-011CUogNYVLkp3YrnDiwZabG
Add clipboard copy/paste functionality for Chrome
2025-11-21 11:44:22 +08:00
wenjie
0d73e8b52a Merge branch 'main' into claude/add-clipboard-copy-paste-011CUogNYVLkp3YrnDiwZabG 2025-11-21 11:44:15 +08:00
wenjie
11d66da064 Merge pull request #661 from scpcom/storage-set-inquiry-string
server: storage: set inquiry string
2025-11-21 11:38:56 +08:00
wenjie
f24fecf01c Merge pull request #650 from scpcom/hostname-update-etc-hosts
Hostname update etc hosts
2025-11-21 11:37:57 +08:00
wenjie
1c16444614 Merge pull request #644 from Keylem/main
Adding Turkish translation
2025-11-21 11:37:08 +08:00
wenjie
8b7b1236ba Merge pull request #642 from scpcom/fix-hid-lock
Fix hid lock
2025-11-21 11:36:49 +08:00
wenjie
c44bf06cc9 Merge pull request #638 from lazydba247/patch-1
Correct set tailscale setting
2025-11-21 11:36:25 +08:00
wenjie
8230d59354 Merge pull request #631 from Alexander-Ger-Reich/main
Adds a German keyboard and more
2025-11-21 11:35:27 +08:00
wenjie
0f270c8823 Merge pull request #625 from Luccas-LF/main
Add Brazilian Portuguese in translation
2025-11-21 11:34:02 +08:00
wenjie
95970f87fa Merge pull request #618 from Zagur/translate-ca
Add catalan in translation
2025-11-21 11:33:44 +08:00
wenjie
469c1693bd Merge pull request #592 from acidflash/main
Add Swedish in translation
2025-11-21 11:33:15 +08:00
wenjie
2a45a8a8fa Merge pull request #549 from protonchang/zhtw-translation
Update zhTW Translation
2025-11-21 11:32:46 +08:00
wenjie
2180fa70f7 Merge pull request #529 from tpretz/hdmi-persist
Make HDMI toggle state persist across reboots
2025-11-21 11:31:48 +08:00
wenjie
b093e11aeb Merge pull request #528 from KRFOSS/main
Korean translation modification
2025-11-21 11:31:11 +08:00
Claude
140812606f Add clipboard copy/paste functionality for Chrome
Enhanced the paste feature with browser clipboard API integration:
- Added "Read from Clipboard" button to directly access browser clipboard
- Implemented proper permission handling and error messages
- Added validation for ASCII-only content and 1024 character limit
- Integrated user-friendly feedback with Ant Design message notifications
- Added comprehensive translation keys for all clipboard-related messages

This makes it much easier to paste content from Chrome without manually
copying into the text area.
2025-11-04 23:07:51 +00:00
scpcom
14239b8a6f server: storage: set inquiry string 2025-11-02 21:51:13 +01:00
scpcom
dfee0934f0 service: hostname: set hostname immediately 2025-10-23 00:47:02 +02:00
scpcom
42c332a406 service: hostname: update /etc/hosts 2025-10-23 00:46:56 +02:00
Keylem
4c1ebc84e9 Adding Turkish translation
Adding (TR) Turkish translation.
2025-10-11 18:00:06 +02:00
scpcom
81a6e5d152 server: storage: mount: lock hid 2025-10-10 21:09:10 +02:00
scpcom
145a43d688 server: hid: lock before reset 2025-10-10 21:09:00 +02:00
lazydba247
386c5fa86c Correct set tailscale setting
before tailscaled is up, we just get an error running tailscale set command, so moved the command after the tailscaled is up.
also added example how to set the nanoKVM as your exit-node for the local network
2025-09-28 15:40:11 +03:00
Alexander-Ger-Reich
1499edd83c Update paste.go 2025-09-28 13:23:59 +02:00
Alexander-Ger-Reich
db719c32ad Update virtual-keyboard.tsx 2025-09-28 13:18:11 +02:00
Alexander-Ger-Reich
340477a31f Update mappings.ts 2025-09-28 13:11:27 +02:00
Alexander-Ger-Reich
fd57047e86 Update de.ts 2025-09-28 12:42:58 +02:00
Alexander-Ger-Reich
496b9f981e Update en.ts 2025-09-28 12:42:43 +02:00
Alexander-Ger-Reich
76dc2312b4 Update de.ts 2025-09-28 12:41:35 +02:00
Alexander-Ger-Reich
ade6d80f10 Update paste.tsx 2025-09-28 12:38:19 +02:00
Alexander-Ger-Reich
574b41a0a1 Update de.ts 2025-09-28 12:37:47 +02:00
Alexander-Ger-Reich
f907a340b6 Update en.ts 2025-09-28 12:36:41 +02:00
Alexander-Ger-Reich
0498b39f0e Update paste.tsx 2025-09-28 12:30:13 +02:00
Alexander-Ger-Reich
5d196ff8d8 Update hid.ts 2025-09-28 12:22:07 +02:00
Alexander-Ger-Reich
84d65fb167 Update paste.tsx 2025-09-28 12:20:22 +02:00
Alexander-Ger-Reich
0f22d53e7c Update hid.ts 2025-09-28 12:19:27 +02:00
Alexander-Ger-Reich
f44bc72201 Update paste.tsx 2025-09-28 12:15:47 +02:00
Alexander-Ger-Reich
310d550ff6 Update paste.tsx 2025-09-28 12:03:51 +02:00
Alexander-Ger-Reich
044292ccb4 Update paste.go 2025-09-28 11:36:54 +02:00
Alexander-Ger-Reich
0dc0f9ea73 Update paste.go 2025-09-27 23:55:14 +02:00
Alexander-Ger-Reich
5fcdf888b3 Update paste.go 2025-09-22 22:14:52 +02:00
Alexander-Ger-Reich
f209cb1e0e Update virtual-keyboard.tsx 2025-09-14 18:42:35 +02:00
Alexander-Ger-Reich
7d3df10915 Update virtual-keys.ts 2025-09-14 18:41:45 +02:00
Alexander-Ger-Reich
fb9f127289 Update virtual-keys.ts 2025-09-14 18:31:41 +02:00
Alexander-Ger-Reich
bc64084516 Update virtual-keys.ts 2025-09-14 18:28:16 +02:00
Alexander-Ger-Reich
e86d385521 Update virtual-keyboard.tsx 2025-09-14 18:25:08 +02:00
Alexander-Ger-Reich
6ca0fa7626 Update virtual-keys.ts 2025-09-14 18:25:05 +02:00
Alexander-Ger-Reich
f8d8b04de3 Update virtual-keyboard.tsx 2025-09-14 18:15:39 +02:00
Alexander-Ger-Reich
305a75d1a7 Update virtual-keys.ts 2025-09-14 18:10:40 +02:00
Alexander-Ger-Reich
c3a9e9d402 Update virtual-keys.ts 2025-09-14 18:05:44 +02:00
Alexander-Ger-Reich
48c941fb86 Update virtual-keyboard.tsx 2025-09-14 18:01:34 +02:00
Alexander-Ger-Reich
1016853d25 Update virtual-keyboard.tsx 2025-09-14 17:54:13 +02:00
Alexander-Ger-Reich
90068a3c2d Update virtual-keyboard.tsx 2025-09-14 14:16:22 +02:00
Alexander-Ger-Reich
8fc71a2e36 Update virtual-keyboard.tsx 2025-09-14 14:14:48 +02:00
Alexander-Ger-Reich
8310e43b31 Update virtual-keys.ts 2025-09-14 14:08:53 +02:00
Alexander-Ger-Reich
0e30b62894 Update mappings.ts 2025-09-14 14:07:28 +02:00
Alexander-Ger-Reich
3de21f295b Update mappings.ts 2025-09-14 14:04:26 +02:00
Alexander-Ger-Reich
d8c4de9a01 Update virtual-keys.ts 2025-09-14 14:04:10 +02:00
Alexander-Ger-Reich
acf366d61e Update mappings.ts 2025-09-14 14:03:09 +02:00
Alexander-Ger-Reich
b9c85fad7b Update virtual-keyboard.tsx 2025-09-14 13:00:06 +02:00
Alexander-Ger-Reich
196b9555ab Update virtual-keys.ts 2025-09-14 12:58:59 +02:00
Alexander-Ger-Reich
e9cccd58ea Update virtual-keys.ts 2025-09-14 12:26:06 +02:00
Alexander-Ger-Reich
00d30efa45 Update virtual-keys.ts 2025-09-14 12:12:44 +02:00
Alexander-Ger-Reich
309b9665cb Update virtual-keys.ts 2025-09-14 12:06:30 +02:00
Alexander-Ger-Reich
759abc92c9 Update virtual-keyboard.tsx 2025-09-14 12:02:37 +02:00
Alexander-Ger-Reich
ae8f0e2236 Update virtual-keyboard.tsx 2025-09-14 11:58:35 +02:00
BuGu
815770fabf Update S30eth 2025-09-05 18:19:32 +08:00
Luccas Ferreira
e82069dea2 Delete web/src/i18n/locales/pt-br.ts 2025-09-03 12:22:57 -03:00
Luccas Ferreira
396aee7d0a Update languages.ts 2025-09-03 12:22:33 -03:00
Luccas Ferreira
3299c202b0 Update languages.ts 2025-09-03 12:19:50 -03:00
Luccas Ferreira
755cbe113d Create pt-br.ts 2025-09-03 12:17:37 -03:00
BuGu
bab729ed18 Add PID/VID Setting Function 2025-09-01 16:34:12 +08:00
BuGu
350dbdfe4b Add NanoKVM EDID Update tool 2025-08-29 19:51:48 +08:00
T3rr0rZ0n3
1b5ca8eef6 Add catalan translation 2025-08-28 20:37:33 +02:00
Jonas Byström
ba242be427 Add swedish in translation 2025-07-28 09:25:57 +00:00
jingyuexing
fce974c146 update CHANGELOG.md 2025-07-15 15:04:44 +08:00
jingyuexing
a82aa792d8 Merge remote-tracking branch 'origin/main' 2025-07-15 10:38:50 +08:00
chiconws
105f77d9ec Add PT-BR translation 2025-07-15 10:38:33 +08:00
chiconws
b6d87b9799 Add PT-BR translation 2025-07-15 10:38:33 +08:00
jingyuexing
6279086b5a Merge pull request #554 from chiconws/main
Adding PT-BR translation
2025-07-15 10:17:18 +08:00
jingyuexing
61ea4bd385 chore: cherry pick 2025-07-14 14:52:48 +08:00
chiconws
7f3e039d1b Add PT-BR translation 2025-06-13 16:05:05 -03:00
chiconws
f2c91c09c7 Add PT-BR translation 2025-06-13 16:03:07 -03:00
wj-xiao
32c81f220d update CHANGELOG.md 2025-06-13 18:27:04 +08:00
protonchang
cd767d16df Update zhTW Translation 2025-06-07 18:51:39 +08:00
kmw_
8591aecb9c update korean readme
updated to include the addition of nanokvm-pro
2025-06-01 20:14:02 +09:00
kmw_
9d6cf49c3f fix missed translations 2025-05-25 14:26:02 +09:00
kmw0410
dcb87d0d89 add korean readme 2025-05-25 14:17:28 +09:00
Thomas Pressnell
6e45e6b135 make hdmi toggle persist 2025-05-24 14:03:50 +01:00
X
7f7e31bae9 터미널 수정 2025-05-23 22:29:49 +09:00
X
4499f9ed44 kmw0410, U-디스크 번역 수정 및 기타 수정 2025-05-23 22:23:12 +09:00
kmw0410
9a5ad1dff2 번역 수정 2025-05-23 22:13:24 +09:00
X
2f8f77cb89 번역 추가, 정렬 2025-05-23 21:49:17 +09:00
101 changed files with 6331 additions and 1997 deletions

View File

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

View File

@@ -13,7 +13,7 @@ NanoKVM
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 Firmware Releases</a>
</h3>
English | [中文](./README_ZH.md) | [日本語](./README_JA.md)
English | [中文](./README_ZH.md) | [日本語](./README_JA.md) | [한국어](./README_KO.md)
</div>

View File

@@ -11,7 +11,7 @@ NanoKVM
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM"> ハードウェア </a>
</h3>
[English](./README.md) | [中文](./README_ZH.md) | 日本語
[English](./README.md) | [中文](./README_ZH.md) | 日本語 | [한국어](./README_KO.md)
</div>

139
README_KO.md Normal file
View File

@@ -0,0 +1,139 @@
NanoKVM
======
<div align="center">
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<h3>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 빠른 시작</a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ 하드웨어 정보</a>
|
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 펌웨어 릴리즈</a>
</h3>
[English](./README.md) | [中文](./README_ZH.md) | [日本語](./README_JA.md) | 한국어
</div>
## 🌟 NanoKVM이 무엇인가요?
NanoKVM은 소형 오픈 소스 IP-KVM 장치 시리즈입니다. LicheeRV Nano(RISC-V)를 기반으로 구축된 NanoKVM을 사용하면 마치 컴퓨터 앞에 바로 앉아있는 것처럼 원격으로 컴퓨터에 접근하고 제어할 수 있어 서버, 임베디드 시스템 또는 모든 헤드리스 머신을 관리하는 데 완벽합니다.
## 📦 제품군
다양한 요구를 충족할 수 있도록 여러 NanoKVM 버전을 제공합니다:
* **NanoKVM-Cube Lite:** DIY 사용자 및 대량 배포가 필요한 기업용을 위한 최소 구성 키트입니다.
* **NanoKVM-Cube Full:** 세련된 케이스, 액세서리, 사전 플래시된 시스템 SD 카드가 포함된 완전 구성 패키지입니다. 개봉 후 즉시 사용할 수 있어 개인 사용자에게 적합합니다.
* **NanoKVM-PCIe:** 독특한 폼팩터로, PCIe 브래킷을 통해 섀시 내부에 장착하며, 전원인 PCIe 슬롯에서 직접 공급받습니다. 선택적으로 WiFi와 PoE 기능을 추가할 수 있습니다.
### 🚀 NanoKVM-Pro: 4K를 위한 파워 머신
대중적인 수요를 기반으로, NanoKVM을 **NanoKVM-Pro**로 업그레이드했습니다:
* **해상도 업그레이드:** 1080P에서 놀라운 **4K@30fps / 2K@60fps**까지.
* **네트워크 업그레이드:** 초고속 **GbE + PoE + WiFi 6** (100M 이더넷 대비 향상).
* **지연 시간 업그레이드:** 인코더 가속 지연시간이 100-150ms에서 **50-100ms**로 감소.
> NanoKVM-Pro가 예약 판매 중입니다! [제품 세부 정보는 여기를 클릭하세요](https://sipeed.com/nanokvm/pro).
<div align="center">
![NanoKVM Product Family](https://cdn.sipeed.com/public/nanokvm-products.jpg)
</div>
## 🛠️ 기술 사양
| 제품 | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
| 코어 | AX631 2xA53 1.5G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
| 메모리 & 저장 공간 | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMc | 256M DDR3 + 16G eMMC |
| 시스템 | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM |
| 해상도 | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps |
| HDMI 루프아웃 | 4K 루프아웃 | x | x | x |
| 영상 인코딩 | MJPEG / H.264 / H.265 | MJPEG / H264 | MJPEG / H264 | MJPEG / H264 |
| 오디오 전송 | ✓ | x | ✓ | x |
| UEFI / BIOS | ✓ | ✓ | ✓ | ✓ |
| USB 키보드 & 마우스 에뮬레이션 | ✓ | ✓ | ✓ | ✓ |
| USB ISO 에뮬레이션 | ✓ | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ | x |
| Wake-on-LAN | ✓ | ✓ | ✓ | ✓ |
| 웹 터미널 | ✓ | ✓ | ✓ | ✓ |
| 시리얼 터미널 | 3 채널 | 2 채널 | x | 1 채널 |
| 사용자 정의 스크립트 | ✓ | ✓ | x | x |
| 저장 공간 | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s |
| 이더넷 | 1000M | 100M | 1000M | 100M |
| PoE | 옵션 | 옵션 | x | x |
| WiFi | WiFi6 옵션 | WiFi6 옵션 | x | x |
| ATX 전원 조작 | ✓ | ✓ | $15 추가 | $10 추가 |
| 디스플레이 | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | - | 1.68" 280x240 |
| 기타 기능 | LED 줄 동기화 / 스마트 어시스턴트 | - | - | - |
| 전력 소비량 | 0.4A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
| 전원 입력 | USB-C 또는 PoE | USB-C | USB-C | USB-C |
| 크기 | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm |
| 가격 | ~~$79~~ $69 ATX / ~~$89~~ $79 Desk | $25 Lite / $50 Full(with ATX) | ATX 미사용 시 $89 / ATX 사용 시 $102 | ATX 미사용 시 $69 / ATX 사용 시 $79 |
## 📂 프로젝트 구조
``` shell
├── kvmapp # APP 업데이트 패키지
│ ├── jpg_stream # 매우 오래된 버전에서도 직접 업데이트 가능하도록 호환됨
│ ├── kvm_new_app # kvm_system 업데이트에 필요한 구성 요소를 트리거함
│ ├── kvm_system # kvm_system 애플리케이션
│ ├── server # NanoKVM 프론트 엔드와 백엔드 애플리케이션
│ └── system # 필요한 시스템 구성 요소
├── web # NanoKVM 프론트엔드
├── server # NanoKVM 백엔드
├── support # 보조 기능 (이미지 하위 시스템, 시스템 상태, 시스템 업데이트, 화면, 키, 기타.)
│ ├── sg2002 # NanoKVM-Lite/Full/PCIe
│ └── h618 # NanoKVM-Pro
├── ...
```
## 🔩 하드웨어 플랫폼 (NanoKVM Cube/PCIe)
NanoKVM은 Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html)를 기반으로 하며,[여기](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano)에서 사양, 도식, 치수, 도면은 여기에서 찾을 수 있습니다.
NanoKVM Lite는 LicheeRV Nano와 HDMItoCSI 보드로 구성되어 있으며, NanoKVM FULL은 NanoKVM Lite에 NasnoKVM-A/B 보드와 케이스를 추가한 구성입니다. HDMItoCSI 보드는 HDMI 신호를 변환하는 역할을 하고 NanoKVM-A는 OLED, ATX 전원 출력 (USB Type-C 인터페이스), 보조 전원 공급 장치, ATX 전원 켜기/끄기 및 리셋 버튼을 포함합니다. NanoKVM-B는 한쪽 끝이 컴퓨터와 연결되고 다른 한쪽 끝은 컴퓨터의 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_RV_Nano_KVM_B_30131.pdf)
* [NanoKVM 이미지](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
![NanoKVM PCB Pinout](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
## 🤝 기여
여러분의 기여를 환영합니다! 도움을 줄 수 있는 방법은 다음과 같습니다:
1. 레포지토리를 포크하세요
2. 기능 브랜치를 만드세요
3. 변경한 것을 커밋하세요
4. 브랜치로 푸시하세요
5. 풀 리퀘스트를 생성하세요
Please keep your pull requests small and focused to facilitate easier review and merging.
> 🎁 **고품질 풀 리퀘스트를 제출한 기여자는 감사의 표시로 NanoKVM Cube, PCIe, 또는 Pro를 받을 수 있습니다!**
## 🛒 어디서 구매하나요?
* [Aliexpress(미국&러시아를 제외한 전 세계)](https://www.aliexpress.com/item/1005007369816019.html)
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
* [사전 주문 (Aliexpress나 Taobao를 지원하지 않는 국가)](https://sipeed.com/nanokvm)
## 💬 커뮤니티 & 지원
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
* QQ group: 703230713
## 📜 라이센스
이 프로젝트는 GPL-3.0 라이센스에 따라 라이센스가 부여됩니다. 자세한 사항은 LICENSE 파일을 참조하세요.

View File

@@ -13,7 +13,7 @@ NanoKVM
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 固件发布</a>
</h3>
[English](./README.md) | 中文 | [日本語](./README_JA.md)
[English](./README.md) | 中文 | [日本語](./README_JA.md) | [한국어](./README_KO.md)
</div>

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,6 @@ EOF
case "$1" in
start)
tailscale set --accept-dns=false
if [ -f /etc/kvm/GOMEMLIMIT ]; then
value=$(cat /etc/kvm/GOMEMLIMIT)
export GOMEMLIMIT="${value}MiB"
@@ -51,7 +50,14 @@ case "$1" in
--socket=/var/run/tailscale/tailscaled.sock \
--port=${PORT} \
$FLAGS
[ $? = 0 ] && echo "OK" || echo "FAIL"
if [ $? = 0 ]; then
echo "OK"
tailscale set --accept-dns=false
# example to make your nanoKVM an exit node
# tailscale set --advertise-exit-node --advertise-routes=192.168.0.0/16
else
echo "FAIL"
fi
;;
stop)
printf "Stopping $DAEMON: "
@@ -111,4 +117,4 @@ EOF
exit 1
esac
exit 0
exit 0

64
server/build.sh Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package storage
import (
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -12,6 +13,7 @@ import (
log "github.com/sirupsen/logrus"
"NanoKVM-Server/proto"
"NanoKVM-Server/service/hid"
)
const (
@@ -19,6 +21,7 @@ const (
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"
)
@@ -91,6 +94,20 @@ func (s *Service) MountImage(c *gin.Context) {
}
}
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(inquiryString, []byte(inquiryData), 0o666); err != nil {
log.Errorf("set inquiry %s failed: %s", inquiryData, err)
rsp.ErrRsp(c, -2, "set inquiry failed")
return
}
// mount
image := req.File
if image == "" {
@@ -103,6 +120,14 @@ func (s *Service) MountImage(c *gin.Context) {
return
}
h := hid.GetHid()
h.Lock()
h.CloseNoLock()
defer func() {
h.OpenNoLock()
h.Unlock()
}()
// reset usb
commands := []string{
"echo > /sys/kernel/config/usb_gadget/g0/UDC",
@@ -165,3 +190,31 @@ func (s *Service) GetCdRom(c *gin.Context) {
rsp.OkRspWithData(c, data)
}
func (s *Service) DeleteImage(c *gin.Context) {
var req proto.DeleteImageReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
filename := strings.ToLower(req.File)
validPrefix := strings.HasPrefix(filename, imageDirectory)
validSuffix := strings.HasSuffix(filename, ".iso") || strings.HasSuffix(filename, ".img")
if !validPrefix || !validSuffix {
rsp.ErrRsp(c, -2, "invalid arguments")
return
}
if err := os.Remove(req.File); err != nil {
rsp.ErrRsp(c, -3, "remove file failed")
log.Errorf("failed to remove file %s: %s", req.File, err)
return
}
rsp.OkRsp(c)
log.Debugf("delete image %s success", req.File)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

1851
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -73,7 +73,13 @@ const en = {
placeholder: 'Please input',
submit: 'Submit',
virtual: 'Keyboard',
ctrlaltdel: 'Ctrl+Alt+Del'
ctrlaltdel: 'Ctrl+Alt+Del',
readClipboard: 'Read from Clipboard',
clipboardPermissionDenied:
'Clipboard permission denied. Please allow clipboard access in your browser.',
clipboardReadError: 'Failed to read clipboard',
dropdownEnglish: 'English',
dropdownGerman: 'German'
},
mouse: {
title: 'Mouse',
@@ -106,11 +112,18 @@ const en = {
title: 'Images',
loading: 'Loading...',
empty: 'Nothing Found',
cdrom: 'Mount the image in CD-ROM mode',
mountFailed: 'Mount Failed',
mountMode: 'Mount mode',
mountFailed: 'Mount failed',
mountDesc:
"In some systems, it's necessary to eject the virtual disk on the remote host before mounting the image.",
'On some systems, you need to eject the virtual disk from the remote host before mounting the image.',
unmountFailed: 'Unmount failed',
unmountDesc:
'On some systems, you need to manually eject from the remote host before unmounting the image.',
refresh: 'Refresh the image list',
attention: 'Attention',
deleteConfirm: 'Are you sure you want to delete this image?',
okBtn: 'Yes',
cancelBtn: 'No',
tips: {
title: 'How to upload',
usb1: 'Connect the NanoKVM to your computer via USB.',
@@ -278,8 +291,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?',
@@ -296,6 +312,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',
@@ -308,6 +326,7 @@ const en = {
logout: 'Logout',
logoutDesc: 'Are you sure you want to logout?',
uninstall: 'Uninstall Tailscale',
uninstallDesc: 'Are you sure you want to uninstall Tailscale?',
okBtn: 'Yes',
cancelBtn: 'No'
},

View File

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

View File

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

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

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

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

@@ -0,0 +1,353 @@
const tr = {
translation: {
head: {
desktop: 'Uzak masaüstü',
login: 'Giriş',
changePassword: 'Şifreyi değiştir',
terminal: 'Uçbirim',
wifi: 'Wi-Fi'
},
auth: {
login: 'Giriş',
placeholderUsername: 'Kullanıcı Adı',
placeholderPassword: 'Şifre',
placeholderPassword2: 'Şifrenizi tekrar deneyiniz',
noEmptyUsername: 'Kullanıcı adı gereklidir',
noEmptyPassword: 'Şifre gereklidir',
noAccount: 'Kullanıcı verileri alınırken hata yaşandı, lütfen sayfayı yenileyiniz ya da şifrenizi sıfırlayınız',
invalidUser: 'Yanlış kullanıcı adı ya da şifre',
error: 'Beklenmedik bir hata',
changePassword: 'Şifrenizi değiştiriniz',
changePasswordDesc: 'Güvenlik sebebiyle lütfen şifrenizi değiştiriniz!',
differentPassword: 'Şifreler eşleşmemektedir',
illegalUsername: 'Kullanıcı adı istenmeyen karakterler içermektedir',
illegalPassword: 'Şifre istenmeyen karakterler içermektedir',
forgetPassword: 'Şifremi unuttum',
ok: 'Tamam',
cancel: 'İptal',
loginButtonText: 'Giriş',
tips: {
reset1:
'Şifreleri sıfırlamak için NanoKVM üzerinde bulunan BOOT tuşuna 10 saniye boyunca basılı tutun.',
reset2: 'Ayrıntılı adımlar için dökümana göz atın:',
reset3: 'Arayüz varsayılan hesap:',
reset4: 'Güvenli Kabuk Bağlantısı (SSH) varsayılan hesap:',
change1: 'Bu işlem şu şifreleri değiştiricektir:',
change2: 'Arayüz giriş şifresi',
change3: 'Sistem yöneticisi şifresi (Güvenli Kabuk Bağlantısı (SSH) giriş şifresi)',
change4: 'Şifreleri sıfırlamak için NanoKVM üzerinde bulunan BOOT tuşuna 10 saniye boyunca basılı tutun.'
}
},
wifi: {
title: 'Wi-Fi',
description: 'NanoKVM için Wi-Fi ayarlarını ayarlayın',
success: 'NanoKVM\'in bağlantı durumunu kontrol edin ve yeni IP adresini ziyaret edin.',
failed: 'İşlem başarısız oldu, lütfen tekrar deneyiniz.',
confirmBtn: 'Tamam',
finishBtn: 'Bitti'
},
screen: {
title: 'Ekran',
video: 'Görüntü modu',
videoDirectTips: 'kullanmak için "Ayarlar > Cihaz" HTTPS aktif edin',
resolution: 'Çözünürlük',
auto: 'Otomatik',
autoTips:
"Belirli çözünürlüklerde ekran yırtılması veya fare kayması meydana gelebilir. Bu durumda uzak ana bilgisayarın çözünürlüğünü ayarlamayı ya da otomatik modu devre dışı bırakmayı deneyin.",
fps: 'Saniyedeki kare sayısı',
customizeFps: 'Kişiselleştir',
quality: 'Kalite',
qualityLossless: 'Kayıpsız',
qualityHigh: 'Yüksek',
qualityMedium: 'Orta',
qualityLow: 'Düşük',
frameDetect: 'Kareleri algıla',
frameDetectTip:
"Gönderilen kareler arasındaki farkı hesaplar. Uzak ana bilgisayardan gönderilen yayında bir değişiklik yoksa görüntü yayınını durdurur.",
resetHdmi: 'HDMI sıfırla'
},
keyboard: {
title: 'Klavye',
paste: 'Yapıştır',
tips: 'Sadece standart klavye harfleri ve sembolleri desteklenmektedir.',
placeholder: 'Girdi',
submit: 'Gönder',
virtual: 'Klavye',
ctrlaltdel: 'Ctrl+Alt+Del'
},
mouse: {
title: 'Fare',
cursor: 'İmleç sitili',
default: 'Varsayılan imleç',
pointer: 'Nokta imleç',
cell: 'Artı imleç',
text: 'Yazı imleç',
grab: 'El imleç',
hide: 'İmleci gizle',
mode: 'Fare modu',
absolute: 'Mutlak fare modu',
relative: 'Bağıl fare modu',
speed: 'Tekerlek hızı',
fast: 'Hızlı',
slow: 'Yavaş',
requestPointer: 'Bağıl fare modu kullanılıyor. Masaüstüne tıklayarak imleç elde edinin.',
resetHid: 'İnsan Arayüz Cihazı\'nı sıfırlayın',
hidOnly: {
title: 'Sadece İnsan Arayüz Cihazı modu',
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı\'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
tip1: 'Sadece İnsan Arayüz Cihazı modunu aktifleştirmek, sanal U-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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,28 +2,34 @@ import { useEffect, useRef } from 'react';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { w3cwebsocket as W3cWebSocket } from 'websocket';
import Queue from 'yocto-queue';
import { getBaseUrl } from '@/lib/service.ts';
import { mouseStyleAtom } from '@/jotai/mouse';
import { resolutionAtom } from '@/jotai/screen.ts';
import DirectWorker from './direct.worker.ts?worker';
export const H264Direct = () => {
const resolution = useAtomValue(resolutionAtom);
const mouseStyle = useAtomValue(mouseStyleAtom);
const canvasRef = useRef<any>();
const renderingRef = useRef(false);
const decoderRef = useRef<VideoDecoder | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const workerRef = useRef<Worker | null>(null);
const frameQueue = new Queue<VideoFrame>();
// init websocket
useEffect(() => {
if (!window.VideoDecoder) {
console.log('Error: WebCodecs API not supported.');
return;
}
if (!canvasRef.current) {
return;
}
const worker = new DirectWorker();
workerRef.current = worker;
const offscreen = canvasRef.current.transferControlToOffscreen();
worker.postMessage({ type: 'h264', canvas: offscreen }, [offscreen]);
const url = `${getBaseUrl('ws')}/api/stream/h264/direct`;
const ws = new W3cWebSocket(url);
@@ -31,142 +37,28 @@ export const H264Direct = () => {
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data as string);
if (!decoderRef.current && message.isKeyFrame) {
initializeDecoder();
}
if (decoderRef.current?.state === 'configured') {
decode(message);
}
worker.postMessage({ type: 'ws_message', data: event.data }, [event.data]);
} catch (error) {
console.error('Error processing WebSocket message:', error);
}
};
ws.onerror = () => {
resetDecoder();
worker.postMessage({ type: 'error' });
};
ws.onclose = () => {
resetDecoder();
worker.postMessage({ type: 'close' });
};
return () => {
if (ws.readyState === 1) {
ws.close();
}
resetDecoder();
worker.terminate();
};
}, []);
// init video decoder
function initializeDecoder() {
if (!window.VideoDecoder) {
return;
}
if (decoderRef.current && decoderRef.current.state !== 'unconfigured') {
return;
}
const init = {
output: (frame: VideoFrame) => {
frameQueue.enqueue(frame);
if (frameQueue.size >= 10) {
frameQueue.dequeue()?.close();
}
if (!renderingRef.current) {
requestAnimationFrame(processFrameQueue);
}
},
error: () => {
resetDecoder();
}
};
const config = {
codec: 'avc1.42E01F',
optimizeForLatency: true
};
try {
const decoder = new VideoDecoder(init);
decoder.configure(config);
decoderRef.current = decoder;
} catch (err) {
console.log(err);
decoderRef.current = null;
}
}
// decode video chunk
function decode(message: any) {
const byteString = atob(message.data);
const byteArray = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) {
byteArray[i] = byteString.charCodeAt(i);
}
const chunk = new EncodedVideoChunk({
type: message.isKeyFrame ? 'key' : 'delta',
timestamp: message.timestamp,
data: byteArray
});
try {
decoderRef.current?.decode(chunk);
} catch (err: any) {
if (err.name === 'TypeError' || err.message.includes('configured')) {
resetDecoder();
}
}
}
function processFrameQueue() {
renderingRef.current = true;
const frame = frameQueue.dequeue();
if (frame) {
renderFrame(frame);
}
requestAnimationFrame(processFrameQueue);
}
function renderFrame(frame: VideoFrame) {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
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();
}
// reset video decoder
function resetDecoder() {
if (decoderRef.current && decoderRef.current.state !== 'closed') {
try {
decoderRef.current.close();
} catch (err) {
console.log(err);
}
}
decoderRef.current = null;
renderingRef.current = false;
Array.from(frameQueue.drain()).forEach((frame) => frame.close());
}
return (
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
<canvas

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Spin } from 'antd';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
@@ -14,43 +14,62 @@ export const H264Webrtc = () => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const videoElement = document.getElementById('screen') as HTMLVideoElement;
const videoRef = useRef<HTMLVideoElement | null>(null);
const videoOfferSent = useRef(false);
const videoIceCandidates = useRef<RTCIceCandidate[]>([]);
useEffect(() => {
const url = `${getBaseUrl('ws')}/api/stream/h264`;
const ws = new W3cWebSocket(url);
const pc = new RTCPeerConnection({
iceServers: [
{
urls: ['stun:stun.l.google.com:19302']
}
]
});
const iceServers = [{ urls: ['stun:stun.l.google.com:19302'] }];
const video = new RTCPeerConnection({ iceServers });
pc.ontrack = function (event) {
if (event.track.kind !== 'video') {
console.log('unhandled track kind: ', event.track.kind);
// --- Init Video ---
video.onnegotiationneeded = async () => {
if (videoOfferSent.current || video.signalingState !== 'stable') {
console.log('Skipping video negotiation - Waiting for answer or state unstable');
return;
}
videoElement.srcObject = event.streams[0];
try {
videoOfferSent.current = true;
const offer = await video.createOffer({
offerToReceiveVideo: true,
offerToReceiveAudio: false
});
await video.setLocalDescription(offer);
sendMsg('video-offer', JSON.stringify(video.localDescription));
} catch (error) {
videoOfferSent.current = false;
console.error('Video negotiation failed:', error);
}
};
video.onconnectionstatechange = () => {
if (video.iceConnectionState === 'connected') {
setIsLoading(false);
}
};
video.ontrack = (event) => {
if (videoRef.current && event.track.kind === 'video') {
videoRef.current.srcObject = new MediaStream([event.track]);
}
};
ws.onopen = () => {
pc.onicecandidate = (event) => {
videoOfferSent.current = false;
video.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({ event: 'candidate', data: JSON.stringify(event.candidate) }));
sendMsg('video-candidate', JSON.stringify(event.candidate));
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.createOffer({ offerToReceiveVideo: true })
.then((offer) => {
pc.setLocalDescription(offer).catch(console.log);
ws.send(JSON.stringify({ event: 'offer', data: JSON.stringify(offer) }));
})
.catch(console.log);
video.addTransceiver('video', { direction: 'recvonly' });
};
ws.onmessage = (event) => {
@@ -62,28 +81,75 @@ export const H264Webrtc = () => {
if (!data) return;
switch (msg.event) {
case 'answer':
pc.setRemoteDescription(data).catch(console.log);
case 'video-answer':
handleVideoAnswer(data);
break;
case 'candidate':
pc.addIceCandidate(data).catch(console.log);
case 'video-candidate':
handleVideoCandidate(data);
break;
case 'heartbeat':
break;
default:
console.log('unhandled event: ', msg.event);
console.log('Unhandled event:', msg.event);
}
} catch (err) {
console.log(err);
console.error('Message processing error:', err);
}
};
const handleVideoAnswer = (data: any) => {
if (video.signalingState !== 'have-local-offer') {
videoOfferSent.current = false;
console.warn(`Video signaling state incorrect for answer: ${video.signalingState}`);
return;
}
video
.setRemoteDescription(new RTCSessionDescription(data))
.then(() => {
videoOfferSent.current = false;
videoIceCandidates.current.forEach((candidate) => {
video
.addIceCandidate(candidate)
.catch((e) => console.error('Video candidate failed to add:', e.message));
});
videoIceCandidates.current = [];
})
.catch((error) => {
console.error('Video answer set failed:', error);
videoOfferSent.current = false;
});
};
const handleVideoCandidate = (data: any) => {
if (!data.candidate) {
return;
}
const candidate = new RTCIceCandidate(data);
if (video.remoteDescription) {
video
.addIceCandidate(candidate)
.catch((e) => console.error('Video candidate failed to add:', e.message));
} else {
videoIceCandidates.current.push(candidate);
}
};
const sendMsg = (event: string, data: string) => {
if (ws.readyState !== WebSocket.OPEN) {
return;
}
try {
ws.send(JSON.stringify({ event, data }));
} catch (err) {
console.error('Error sending event: ', err);
}
};
const heartbeatTimer = setInterval(() => {
try {
ws.send(JSON.stringify({ event: 'heartbeat', data: '' }));
} catch (err) {
console.log(err);
}
sendMsg('heartbeat', '');
}, 60 * 1000);
setTimeout(() => {
@@ -91,8 +157,13 @@ export const H264Webrtc = () => {
}, 5 * 1000);
return () => {
ws.close();
pc.close();
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
video.close();
videoOfferSent.current = false;
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
}
@@ -104,6 +175,7 @@ export const H264Webrtc = () => {
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
<video
id="screen"
ref={videoRef}
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
style={
resolution?.width

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