Compare commits

..

138 Commits
2.2.6 ... 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
wj-xiao
c77981cc0c fix(security): resolve parameter injection in serial port terminal 2025-06-13 17:50:11 +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
wj-xiao
34746afea3 update README.md 2025-05-26 18:17:51 +08: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
wj-xiao
a4978d93d6 update CHANGELOG.md 2025-05-22 14:13:18 +08:00
wj-xiao
01e28f10ae Fix direct H.264 issues and optimize UI
feat: support custom mouse wheel speed
fix: prevent direct H.264 stream buffer overflow and replay
style: optimize virtual keyboard layout
style: optimize menu bar and settings styling
2025-05-22 11:31:08 +08:00
wenjie
ea9068fd0d Merge pull request #520 from ethanperrine/main
Updated paste.go
2025-05-20 19:30:15 -07:00
wenjie
d85823abcf Merge pull request #508 from arbdevml/ru
Russian language file was updated in accordance with the new version.
2025-05-20 19:29:51 -07:00
wenjie
4c0027ffd3 Merge pull request #507 from arbdevml/uk
Ukrainian language file was updated in accordance with the new version.
2025-05-20 19:29:35 -07:00
wenjie
afb868af30 Merge pull request #501 from tpretz/hdmi-disable
Adding HDMI Enable/Disable Toggle for PCIe
2025-05-20 19:29:10 -07:00
wenjie
66b1d5dc30 Merge pull request #500 from felix068/feature/azerty-keyboard-layout
Feat: Add AZERTY layout (virtual keyboard) and keyboard layout settings
2025-05-20 19:27:48 -07:00
wenjie
7d8cdc74c6 Merge pull request #496 from kmw0410/main
revised some korean text
2025-05-20 19:25:11 -07:00
Quandale Dingle
809581df49 Update paste.go
Code now relies solely on the modifier byte within the main keyDown report to signal that shift is active. This is a more reliable approach.
2025-05-20 17:09:27 -04:00
Alexandro
5d47a8d48d Update ru.ts 2025-05-12 22:10:26 +00:00
Alexandro
6242f1931e Update uk.ts 2025-05-12 22:08:52 +00:00
Alexandro
b768219c18 Update uk.ts 2025-05-12 22:03:56 +00:00
Thomas Pressnell
6935923bd1 Adding HDMI Enable/Disable Toggle for PCIe 2025-05-10 19:41:25 +01:00
TPM-28
559e6e2556 Feat: Add AZERTY layout (virtual keyboard) and keyboard layout settings
This pull request introduces the following changes:

* **Added AZERTY Keyboard Layout:**
  Implemented support for the AZERTY keyboard layout in the virtual keyboard. This includes both the visual layout and the corresponding key code mappings.

* **Keyboard Layout Setting:**
  Added a new setting under *"Appearance"* to allow users to select their preferred keyboard layout (QWERTY Win, QWERTY Mac, AZERTY Win, Russian). The selected layout is saved to local storage and applied to the virtual keyboard.
2025-05-10 12:25:21 +02:00
kmw0410
f354fdfbf4 revised some korean text 2025-05-09 19:35:38 +09:00
wj-xiao
f242b5a378 update CHANGELOG.md 2025-05-08 16:39:13 +08:00
wj-xiao
e18ec2219d Support HTTPS and direct H.264
feat: add HTTPS support
feat: enable direct H.264 streaming over HTTP
style: optimize menu bar and settings styling
2025-05-08 14:50:32 +08:00
wenjie
d075ae58d9 Merge pull request #494 from 3limin4tor/patch-1
Finalized German translations
2025-05-07 23:23:39 -07:00
3limin4tor
e8ccac8e61 Update de.ts 2025-05-07 19:25:49 +02:00
3limin4tor
7c8f9f3cd8 Finalized german translations 2025-05-07 19:23:26 +02:00
3limin4tor
66e434db24 Finalized german translation 2025-05-07 19:17:44 +02:00
3limin4tor
0be553c4c8 Update de.ts
Started with new translation for German
2025-05-07 01:22:25 +02:00
wenjie
1dda0f29be Merge pull request #472 from pekishev/main
Add Russian virtual keyboard layout when UI language is 'ru'
2025-04-27 02:51:42 -07:00
wenjie
fbdedc7d8a Merge pull request #467 from click0/Update_Ukrainian_translation_for_v_2.2.6
Update translation into Ukrainian for version 2.2.6.
2025-04-27 02:49:53 -07:00
wenjie
e325352309 Merge pull request #466 from LeonStraathof/patch-1
Update nl.ts
2025-04-27 02:49:29 -07:00
wenjie
bf51b50346 Merge pull request #464 from LingkongSky/main
Fix bugs
2025-04-27 02:49:00 -07:00
wenjie
96b52f3ac0 Merge pull request #461 from polyzium/ru-translaton-fix
Russian translaton fix + more translation keys
2025-04-27 02:48:36 -07:00
vlad11
ad44db254b Update translation into Ukrainian for version 2.2.6. 2025-04-18 20:41:57 +03:00
Leon Straathof
736b89d7d1 Update nl.ts
Changed minor language mistakes like official use of the word wifi in Dutch, and replaced remaining English text.
2025-04-18 13:19:37 +02:00
凌空
3122531cae Merge branch 'sipeed:main' into main 2025-04-18 16:33:19 +08:00
LingkongSky
cbb47ab735 fix some bugs for the localstorage and add some translations 2025-04-18 16:32:15 +08:00
BuGu
fa8d4fd5ca Update CHANGELOG.md
Add instructions for fixing MIC issues
2025-04-18 11:28:30 +08:00
LingkongSky
423d05a087 Revert "add a switch to stop the hid in Web UI"
This reverts commit 7036e94dab.
2025-04-18 10:12:19 +08:00
LingkongSky
7036e94dab add a switch to stop the hid in Web UI 2025-04-17 21:24:21 +08:00
polyzium
3a19180a51 Make Swap title translateable 2025-04-17 15:43:39 +03:00
polyzium
f8f8920fc5 Make IP types translateable 2025-04-17 15:40:08 +03:00
polyzium
507b53c10e Update Russian translation 2025-04-17 15:40:04 +03:00
Yury Pekishev
642a00051e add Rus virtual keyboard conditionally 2025-04-04 21:38:30 +03:00
Yury Pekishev
7f9bd1c457 add Rus virtual keyboard conditionally 2025-04-04 21:29:27 +03:00
Yury Pekishev
c4e8d19649 add rus keyboard layout 2025-03-27 23:38:59 +03:00
Yury Pekishev
82be18c469 add rus keyboard layout 2025-03-27 23:30:52 +03:00
137 changed files with 8415 additions and 2499 deletions

View File

@@ -1,3 +1,77 @@
## 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))
* feat: add support for enabling/disabling HDMI output (PCIe version only) (thanks to [@tpretz](https://github.com/tpretz))
* feat: add support for custom mouse wheel speed
* fix: prevent direct H.264 stream buffer overflow and replay issues
* perf: improve keyboard paste performance (thanks to [@ethanperrine](https://github.com/ethanperrine))
* Localization
* update Korean translation (thanks to [@kmw0410](https://github.com/kmw0410))
* update Ukrainian translation (thanks to [@arbdevml](https://github.com/arbdevml))
* update Russian translation (thanks to [@arbdevml](https://github.com/arbdevml))
## 2.2.7 [e18ec22](https://github.com/sipeed/NanoKVM/commit/e18ec2219d22886529575d1fdaad5c320e05f5b2) (2025-05-08)
* feat: add HTTPS support
* feat: support direct H.264 streaming over HTTP
* Localization
* update Russian translation (thanks to [polyzium](https://github.com/polyzium))
* update Dutch translation (thanks to [LeonStraathof](https://github.com/LeonStraathof))
* update Ukrainian translation (thanks to [click0](https://github.com/click0))
* update German translation (thanks to [3limin4tor](https://github.com/3limin4tor))
## 2.2.6 [c83dc55](https://github.com/sipeed/NanoKVM/commit/c83dc5565c9dbed22336661a8832edbd93a06d11) (2025-04-17)
* feat: add mouse jiggler to prevent system sleep
@@ -7,6 +81,7 @@
* feat: add support for assigning custom names to Wake-on-LAN MAC addresses
* feat: add confirmation prompts for power operations
* feat: add logo to the login page (thanks to [S33G](https://github.com/S33G))
* fix: fix possible privacy issues with MIC drivers. [Repair Record](https://github.com/sipeed/NanoKVM/commit/f9244b36df090a05cd59ba11ea4fd01e9b638995)
* fix: fix iptables rule that could interfere with SSH connections (thanks to [scpcom](https://github.com/scpcom))
* fix: fix the static IP gateway configuration might not apply correctly (thanks to [xitation](https://github.com/xitation))
* perf: optimized OLED display handling and sleep logic

152
README.md
View File

@@ -6,42 +6,77 @@ NanoKVM
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<h3>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html"> Quick Start </a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM"> Hardware </a>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 Quick Start</a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ Hardware Details</a>
|
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 Firmware Releases</a>
</h3>
English | [中文](./README_ZH.md) | [日本語](./README_JA.md)
English | [中文](./README_ZH.md) | [日本語](./README_JA.md) | [한국어](./README_KO.md)
</div>
> Your NanoKVM is powered by [RISC-V](https://en.wikipedia.org/wiki/RISC-V)!
> If you have any issues or suggestions, creating issue here, or tell us with [MaixHub Discussion](https://maixhub.com/discussion/nanokvm).
## 🌟 What is NanoKVM?
## Introduction
NanoKVM is a series of compact, open-source IP-KVM devices. Built upon the LicheeRV Nano (RISC-V), NanoKVM allows you to remotely access and control computers as if you were sitting right in front of them – perfect for managing servers, embedded systems, or any headless machine.
Lichee NanoKVM is an IP-KVM product based on LicheeRV Nano, inheriting the extreme size and powerful features of LicheeRV Nano.
## 📦 Product Family
The Lichee NanoKVM is now available in three versions:
We offer several NanoKVM versions to suit your needs:
* NanoKVM-Cube Lite is a basic configuration that is suitable for individual users with certain DIY capabilities and enterprise users with bulk requirements.
* **NanoKVM-Cube Lite:** A barebones kit for DIY enthusiasts and enterprise users needing bulk deployment.
* **NanoKVM-Cube Full:** A complete package with a sleek case, accessories, and a pre-flashed system SD card. Ready to use out-of-the-box, ideal for individual users.
* **NanoKVM-PCIe:** A unique form factor with a PCIe bracket for internal chassis mounting, drawing power directly from a PCIe slot. And add optional WiFi and PoE functions.
* NanoKVM-Cube Full is a full version with a sophisticated case and complete accessories, as well as a built-in system mirror card that is ready to use at boot, and is recommended for individual users.
### 🚀 NanoKVM-Pro: The 4K Powerhouse
* NanoKVM-PCle is a new form with a built-in PCle baffle that can be fixed inside the chassis. NanoKVM-PCle adds optional WiFi and PoE functions based on NanoKVM-Cube; it has its own PCle slot and can draw power from the motherboard PCle slot; in addition, the wired connection (ETH) is also more stable, meeting more professional needs.
Based on popular demand, we've upgraded NanoKVM to the **NanoKVM-Pro**:
* **Resolution Upgrade:** From 1080P to stunning **4K@30fps / 2K@60fps**.
* **Network Upgrade:** Blazing fast **GbE + PoE + WiFi 6** (up from 100M Ethernet).
* **Latency Upgrade:** Encoder accelerated latency reduced from 100-150ms to **50-100ms**.
> The NanoKVM-Pro is now available for pre-sale! [Click here for product details](https://sipeed.com/nanokvm/pro).
<div align="center">
![NanoKVM-Cube](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_1.jpg)
![NanoKVM-PCIe](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM-PCIe.png)
![NanoKVM Product Family](https://cdn.sipeed.com/public/nanokvm-products.jpg)
</div>
> For users concerned about security, you can read this issue: [#301](https://github.com/sipeed/NanoKVM/issues/301). You will find explanations addressing all security concerns.
> GitHub, as an open and transparent platform, provides a space for discussing so-called "backdoor" concerns. Open-source makes products more secure!
## 🛠️ Technical Specifications
## Project Structure
| Product | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
| Core | AX631 2xA53 1.5G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
| Memory & Storage | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMc | 256M DDR3 + 16G eMMC |
| System | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM |
| Resolution | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps |
| HDMI Loopout | 4K loopout | x | x | x |
| Video Encoding | MJPEG / H.264 / H.265 | MJPEG / H264 | MJPEG / H264 | MJPEG / H264 |
| Audio Transmit | ✓ | x | ✓ | x |
| UEFI / BIOS | ✓ | ✓ | ✓ | ✓ |
| Emulated USB Keyboard & Mouse | ✓ | ✓ | ✓ | ✓ |
| Emulated USB ISO | ✓ | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ | x |
| Wake-on-LAN | ✓ | ✓ | ✓ | ✓ |
| Web Terminal | ✓ | ✓ | ✓ | ✓ |
| Serial Terminal | 3 channels | 2 channels | x | 1 channel |
| Custom Scripts | ✓ | ✓ | x | x |
| Storage | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s |
| Ethernet | 1000M | 100M | 1000M | 100M |
| PoE | Optional | Optional | x | x |
| WiFi | Optional WiFi6 | Optional WiFi6 | x | x |
| ATX Power Control | ✓ | ✓ | Extra $15 | Extra $10 |
| Display | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | - | 1.68" 280x240 |
| More Features | Sync LED Strip / Smart Assistant | - | - | - |
| Power Consumption | 0.4A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
| Power Input | USB-C or PoE | USB-C | USB-C | USB-C |
| Dimensions | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm |
| Price | ~~$79~~ $69 ATX / ~~$89~~ $79 Desk | $25 Lite / $50 Full(with ATX) | $89 no ATX / $102 with ATX | $69 no ATX / $79 with ATX |
## 📂 Project Structure
``` shell
├── kvmapp # APP update package
@@ -58,33 +93,7 @@ The Lichee NanoKVM is now available in three versions:
├── ...
```
## Tech Specs
| Products | NanoKVM (Lite) | NanoKVM (Full) | PiKVM V4 |
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |
| Compute Units | LicheeRV Nano(RISCV) | LicheeRV Nano(RISCV) | CM4 (ARM) |
| Resolution | 1080P @ 60fps | 1080P @ 60fps | 1080P @ 60fps |
| Video encoding | MJPEG, H264 | MJPEG, H264 | MJPEG, H264 |
| Video delay | 90~230ms | 90~230ms | 100~230ms |
| UEFI/BIOS | ✓ | ✓ | ✓ |
| Virtual HID | ✓ | ✓ | ✓ |
| Virtual CD-ROM | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ |
| Wake-on-LAN | ✓ | ✓ | ✓ |
| ETH | 100M/10M | 100M/10M | 1000M/100M/10M |
| ATX power control | None,Users can connect by their own | USB interface IO control board | RJ-45 interface IO control board |
| OLED | None,Users can connect by their own | 128x64 0.96" white | 128x32 0.91" white |
| UART | 2 | 2 | 1 |
| TF Card | None | ✓ | ✓ |
| Expansion | None | PoE | WiFi/LTE |
| Power consumption | 0.2A@5V | 0.2A@5V | Peak 2.6A@5V |
| Power input | PC USB can be powered | PC USB or auxiliary power supply | DC 5V 3A power supply is required |
| Cooling | Silent fanless | Silent fanless | Fan cooling |
| Size | 23x37x15mm ~1/30 PiKVM V4 size | 40x36x36mm ~1/7 PiKVM V4 size | 120x68x44mm |
![NanoKVM PCB Pinout](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
## Hardware platform NanoKVM
## 🔩 Hardware Platform (NanoKVM Cube/PCIe)
NanoKVM is based on Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html),you can find specifications, schematics, and dimensional drawings [here](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano).
@@ -98,58 +107,33 @@ Note: Out of the 256MB memory in SG2002, 158MB is currently allocated for the mu
* [NanoKVM-B Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf)
* [NanoKVM img](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
## Roadmap
![NanoKVM PCB Pinout](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
**Enhancements:**
## 🤝 Contributing
* [x] Refactor the HDMI module and remove the libmaixcam_lib.so dependency
* [x] Disable SSH by default and add an enable/disable toggle in the web UI
* [x] Disable Tailscale by default and add an enable/disable toggle in the web UI
* [x] Add JWT configuration options to the configuration file
* [x] Secure password storage with bcrypt
We welcome contributions! Here's how you can help:
**Distribution:**
1. Fork the repository.
2. Create a feature branch.
3. Commit your changes.
4. Push to the branch.
5. Open a Pull Request.
* [x] Distribute applications via GitHub
* [x] Implement integrity checks for online updates
* [ ] Support offline updates
Please keep your pull requests small and focused to facilitate easier review and merging.
**Bug Fixes:**
> 🎁 **Contributors who submit high-quality Pull Requests may receive a NanoKVM Cube, PCIe, or Pro as a token of our appreciation!**
* [x] Fix the DNS issue
* [x] Fix the CSRF vulnerability
**Features**
* [ ] [79](https://github.com/sipeed/NanoKVM/issues/79) Support Zerotier
* [ ] [99](https://github.com/sipeed/NanoKVM/issues/99) Support WireGuard
* [ ] [249](https://github.com/sipeed/NanoKVM/issues/249) Add Mouse Jiggler
## Contributing
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
Thanks for your interest in contributing to NanoKVM!
Please keep your pull requests small and focused. This will make it easier to review and merge.
> Developers who have contributed high-quality Pull Requests will receive a NanoKVM Cube, PCIe, or Pro as a token of our appreciation.
## Where to buy
## 🛒 Where to Buy
* [Aliexpress(global except USA&Russia)](https://www.aliexpress.com/item/1005007369816019.html)
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
* [Preorder (anyother country that not support in Aliexpress or Taobao)](https://sipeed.com/nanokvm)
* [Preorder (any other country that not support in Aliexpress or Taobao)](https://sipeed.com/nanokvm)
## Community
## 💬 Community & Support
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
* QQ group: 703230713
## License
## 📜 License
This project is licensed under the GPL-3.0 License - see the LICENSE file for details.

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

@@ -6,39 +6,77 @@ NanoKVM
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<h3>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html"> Quick Start </a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM"> Hardware </a>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 快速开始</a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ 硬件信息</a>
|
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 固件发布</a>
</h3>
[English](./README.md) | 中文 | [日本語](./README_JA.md)
[English](./README.md) | 中文 | [日本語](./README_JA.md) | [한국어](./README_KO.md)
</div>
> Your NanoKVM Power by RISC-V !
> 如果您在使用过程中有任何问题或建议,请在这里提交 issue, 或在 [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)告诉我们.
## 🌟 NanoKVM 是什么?
## 简介
NanoKVM 是一系列紧凑型开源 IP-KVM 设备。NanoKVM 基于 LicheeRV Nano (RISC-V) 构建,让您能够身临其境般的远程访问和控制计算机,是管理服务器、嵌入式系统或任何无头设备的理想之选。
Lichee NanoKVM 是基于 LicheeRV Nano 的 IP-KVM 产品,继承了 LicheeRV Nano的极致体积 和 强大功能。
## 📦 产品系列
Lichee NanoKVM 目前有三个版本:
我们提供多种 NanoKVM 版本以满足您的需求:
* NanoKVM-Cube Lite 为基础版配置,适合 具有一定DIY能力的个人用户 和 有批量需求的企业用户。
* NanoKVM-Cube Full 为完整版配置,带精致外壳和完整配件,内置开机即用的系统镜像卡,推荐个人用户购买。
* NanoKVM-PCle 为全新形态,内置 PCle 挡板,可固定在机箱内部。NanoKVM-PCle 在 NanoKVM-Cube 基础上增加了可选的 WiFi 和 PoE 功能;拥有自家的 PCle 插槽,可从主板 PCle 插槽取电;另外有线连接(ETH)也更加稳定,满足更多专业需求。
* **NanoKVM-Cube Lite**:为 DIY 爱好者和需要批量部署的企业用户提供的准系统套件。
* **NanoKVM-Cube Full**:包含外壳、配件以及预装系统 SD 卡的完整套装。开箱即用,非常适合个人用户。
* **NanoKVM-PCIe**:独特的外形设计,配备 PCIe 支架,可安装在机箱内部,并直接从 PCIe 插槽供电。可选配 WiFi 和 PoE 功能。
### 🚀 NanoKVM-Pro
根据大众的需求,我们将 NanoKVM 升级到了 **NanoKVM-Pro**:
* **分辨率升级**:从 1080P 升级到 **4K@30fps / 2K@60fps**。
* **网络升级**:从百兆网口升级到超快的 **GbE + WiFi 6**。
* **延迟升级**:编码器加速延迟从 100-150 毫秒减少到 **50-100 毫秒**。
> NanoKVM-Pro 现已开启预售![点击此处查看产品详情](https://sipeed.com/nanokvm/pro)。
<div align="center">
![NanoKVM-Cube](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_1.jpg)
![NanoKVM-PCIe](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM-PCIe.png)
![NanoKVM Product Family](https://cdn.sipeed.com/public/nanokvm-products.jpg)
</div>
> 对于关注安全的用户,可以阅读此问题 [#301](https://github.com/sipeed/NanoKVM/issues/301),您会找到有关所有安全问题的解释。GitHub 作为一个开放和透明的平台,为讨论所谓的“后门”担忧提供了空间。开源使产品更加安全!
## 🛠️ 技术规格
## 项目结构
| 产品 | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
| 核心 | AX631 2xA53 1.5G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
| 内存&存储 | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMc | 256M DDR3 + 16G eMMC |
| 系统 | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM |
| 分辨率 | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps |
| HDMI 环出 | 4K 环出 | x | x | x |
| 视频编码 | MJPEG / H.264 / H.265 | MJPEG / H264 | MJPEG / H264 | MJPEG / H264 |
| 音频输出 | ✓ | x | ✓ | x |
| UEFI / BIOS | ✓ | ✓ | ✓ | ✓ |
| 模拟 USB 键鼠 | ✓ | ✓ | ✓ | ✓ |
| 模拟 USB ISO | ✓ | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ | x |
| 局域网唤醒 | ✓ | ✓ | ✓ | ✓ |
| 网页终端 | ✓ | ✓ | ✓ | ✓ |
| 串口终端 | 3 channels | 2 channels | x | 1 channel |
| 用户脚本 | ✓ | ✓ | x | x |
| 存储 | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s |
| 网口 | 1000M | 100M | 1000M | 100M |
| PoE | 可选 | 可选 | x | x |
| WiFi | 可选 WiFi6 | 可选 WiFi6 | x | x |
| ATX 电源控制 | ✓ | ✓ | ✓ | ✓ |
| 显示屏 | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | - | 1.68" 280x240 |
| 特色功能 | LED 同步灯带 / 智能助手 | - | - | - |
| 功耗 | 0.4A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
| 电源输入 | USB-C or PoE | USB-C | USB-C | USB-C |
| 尺寸 | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm |
| 价格 | ~~$79~~ $69 ATX / ~~$89~~ $79 Desk | $25 Lite / $50 Full(带ATX) | $89 无ATX / $102 带ATX | $69 无ATX / $79 带ATX |
## 📂 项目结构
``` shell
├── kvmapp # APP更新包
@@ -59,33 +97,7 @@ Lichee NanoKVM 目前有三个版本:
└── CHANGELOG.md
```
## 参数
| 产品 | NanoKVM (Lite) | NanoKVM (Full) | PiKVM V4 |
| --- | --- | --- | --- |
| 计算单元 | LicheeRV Nano(RISCV) | LicheeRV Nano(RISCV) | CM4 (ARM) |
| 分辨率 | 1080P @ 60fps | 1080P @ 60fps | 1080P @ 60fps |
| 视频编码 | MJPEG, H264 | MJPEG, H264 | MJPEG, H264 |
| 视频延迟 | 90~230ms | 90~230ms | 100~230ms |
| UEFI/BIOS | ✓ | ✓ | ✓ |
| 模拟USB键鼠 | ✓ | ✓ | ✓ |
| 模拟USB存储 | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ |
| Wake-on-LAN | ✓ | ✓ | ✓ |
| ETH | 100M/10M | 100M/10M | 1000M/100M/10M |
| ATX电源控制 | 无,用户可自行连接 | USB接口IO控制板 | RJ45接口IO控制板 |
| OLED显示 | 无,用户可自行扩展 | 128x64 0.96" white | 128x32 0.91" white |
| 外接串口 | 2路 | 2路 | 1路 |
| TF卡 | 无,用户自备 | 有,开机即用 | 有 |
| 扩展配件 | 无 | PoE | WiFi/LTE |
| 功耗 | 0.2A@5V | 0.2A@5V | Peak 2.6A@5V |
| 电源输入 | PC USB即可供电 | PC USB即可供电 <br> 也支持额外辅助供电 | 需要DC 5V 3A供电 |
| 散热 | 静音无风扇 | 静音无风扇 | 需要风扇主动散热 |
| 尺寸 | 23x37x15mm <br> ~1/30 PiKVM V4 体积 | 40x36x36mm <br/> ~1/7 PiKVM V4 体积 | 120x68x44mm |
![](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
## NanoKVM 硬件平台
## 🔩 硬件平台(NanoKVM Cube/PCIe)
NanoKVM 基于 Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html) 核心板搭建,这部分硬件的规格书、原理图、尺寸图等均可在这里找到:[点击这里](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano)
@@ -99,13 +111,29 @@ NanoKVM 镜像在LicheeRV Nano SDK 和 MaixCDK 基础上构建,可以兼容使
* [NanoKVM-B 原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_HDMI_MIPI_31011.pdf)
* [NanoKVM 镜像下载](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
## 购买渠道
![NanoKVM PCB Pinout](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
## 🤝 贡献代码
我们十分欢迎任何人来贡献代码!您可以通过以下方式提供帮助:
1. Fork 本仓库.
2. 创建一个分支.
3. 提交你的代码.
4. 推送代码到分支.
5. 创建一个PR.
请保持你的 PR 尽量简单且集中,以便于更轻松地审查和合并。
> 🎁 **提交高质量 PR 的贡献者可能会收到 NanoKVM Cube、PCIe 或 Pro 作为感谢礼物!**
## 🛒 购买渠道
* [Aliexpress(全球,除了美国和俄罗斯)](https://www.aliexpress.com/item/1005007369816019.html)
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
* [Preorder (其它不支持淘宝和速卖通的国家)](https://sipeed.com/nanokvm)
## 社区
## 💬 社区
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
* QQ group: 703230713

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
}

45
server/config/file.go Normal file
View File

@@ -0,0 +1,45 @@
package config
import (
"os"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
const ConfigurationFile = "/etc/kvm/server.yaml"
func Read() (*Config, error) {
data, err := os.ReadFile(ConfigurationFile)
if err != nil {
log.Errorf("failed to read config: %v", err)
return nil, err
}
var conf Config
if err := yaml.Unmarshal(data, &conf); err != nil {
log.Fatalf("failed to unmarshal config: %v", err)
return nil, err
}
log.Debugf("read %s successfully", ConfigurationFile)
return &conf, nil
}
func Write(conf *Config) error {
data, err := yaml.Marshal(&conf)
if err != nil {
log.Errorf("failed to marshal config: %v", err)
return err
}
err = os.WriteFile(ConfigurationFile, data, 0644)
if err != nil {
log.Errorf("failed to write config: %v", err)
return err
}
log.Debugf("write to %s successfully", ConfigurationFile)
return nil
}

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

@@ -3,8 +3,10 @@ package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -14,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"
@@ -36,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()
@@ -58,7 +63,6 @@ func run() {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
if conf.Authentication == "disable" {
r.Use(cors.AllowAll())
}
@@ -67,20 +71,17 @@ func run() {
httpAddr := fmt.Sprintf(":%d", conf.Port.Http)
httpsAddr := fmt.Sprintf(":%d", conf.Port.Https)
log.Printf("proto: %s, port: %d %d\n", conf.Proto, conf.Port.Http, conf.Port.Https)
if conf.Proto == "https" {
r.Use(middleware.Tls())
go func() {
if err := r.Run(httpAddr); err != nil {
panic("start http server failed")
r.Use(middleware.Tls())
err := r.RunTLS(httpsAddr, conf.Cert.Crt, conf.Cert.Key)
if err != nil {
panic("start https server failed")
}
}()
if err := r.RunTLS(httpsAddr, conf.Cert.Crt, conf.Cert.Key); err != nil {
panic("start https server failed")
}
runRedirect(httpAddr, httpsAddr)
} else {
if err := r.Run(httpAddr); err != nil {
panic("start http server failed")
@@ -88,6 +89,26 @@ func run() {
}
}
func runRedirect(httpPort string, httpsPort string) {
err := http.ListenAndServe(httpPort, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
host := req.Host
if strings.Contains(host, httpPort) {
host = strings.Split(host, httpPort)[0]
}
targetURL := "https://" + host + req.URL.String()
if httpsPort != ":443" {
targetURL = "https://" + host + httpsPort + req.URL.String()
}
http.Redirect(w, req, targetURL, http.StatusTemporaryRedirect)
}))
if err != nil {
panic("start http server failed")
}
}
func dispose() {
common.GetKvmVision().Close()
}

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

@@ -87,6 +87,10 @@ type GetOLEDRsp struct {
Sleep int `json:"sleep"`
}
type GetGetHdmiStateRsp struct {
Enabled bool `json:"enabled"`
}
type GetSSHStateRsp struct {
Enabled bool `json:"enabled"`
}
@@ -128,3 +132,7 @@ type SetWebTitleReq struct {
type GetWebTitleRsp struct {
Title string `json:"title"`
}
type SetTlsReq struct {
Enabled bool `validate:"omitempty"`
}

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

@@ -2,8 +2,9 @@ package router
import (
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/stream/h264"
"NanoKVM-Server/service/stream/direct"
"NanoKVM-Server/service/stream/mjpeg"
"NanoKVM-Server/service/stream/webrtc"
"github.com/gin-gonic/gin"
)
@@ -15,5 +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
api.GET("/stream/h264", webrtc.Connect) // h264 stream (webrtc)
api.GET("/stream/h264/direct", direct.Connect) // h264 stream (http)
}

View File

@@ -35,7 +35,11 @@ func vmRouter(r *gin.Engine) {
api.GET("/vm/oled", service.GetOLED) // get OLED configuration
api.POST("/vm/oled", service.SetOLED) // set OLED configuration
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi (pcie only)
// Only supported by PCIe version
api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
api.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
api.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
api.GET("/vm/ssh", service.GetSSHState) // get SSH state
api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
@@ -57,5 +61,7 @@ func vmRouter(r *gin.Engine) {
api.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
api.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
api.POST("/vm/tls", service.SetTls) // enable/disable TLS
api.POST("/vm/system/reboot", service.Reboot) // reboot system
}

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,6 +16,75 @@ 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) {
@@ -28,145 +97,101 @@ func (s *Service) Paste(c *gin.Context) {
}
if len(req.Content) > 1024 {
rsp.ErrRsp(c, -2, "")
rsp.ErrRsp(c, -2, "content too long")
return
}
s.hid.kbMutex.Lock()
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", char)
log.Debugf("unknown key '%c' (rune: %d)", char, char)
continue
}
keyDown := []byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00}
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
// only handle shift. Need to handle all modifiers?
if key.Modifiers > 0 {
keyShift := []byte{0x00, 0x00, byte(225), 0x00, 0x00, 0x00, 0x00, 0x00}
s.hid.Write(s.hid.g0, keyShift)
}
s.hid.Write(s.hid.g0, keyDown)
s.hid.Write(s.hid.g0, keyUp)
if key.Modifiers > 0 {
s.hid.Write(s.hid.g0, keyUp)
}
time.Sleep(50 * time.Millisecond)
hid.WriteHid0(keyDown)
hid.WriteHid0(keyUp)
time.Sleep(30 * time.Millisecond)
}
s.hid.kbMutex.Unlock()
rsp.OkRsp(c)
log.Debugf("hid paste success, total %d", len(req.Content))
log.Debugf("hid paste success, total %d characters processed", len(req.Content))
}
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{
97: {0, 4}, // a
98: {0, 5}, // b
99: {0, 6}, // c
100: {0, 7}, // d
101: {0, 8}, // e
102: {0, 9}, // f
103: {0, 10}, // g
104: {0, 11}, // h
105: {0, 12}, // i
106: {0, 13}, // j
107: {0, 14}, // k
108: {0, 15}, // l
109: {0, 16}, // m
110: {0, 17}, // n
111: {0, 18}, // o
112: {0, 19}, // p
113: {0, 20}, // q
114: {0, 21}, // r
115: {0, 22}, // s
116: {0, 23}, // t
117: {0, 24}, // u
118: {0, 25}, // v
119: {0, 26}, // w
120: {0, 27}, // x
121: {0, 28}, // y
122: {0, 29}, // z
// Lowercase letters
'a': {0, 4}, 'b': {0, 5}, 'c': {0, 6}, 'd': {0, 7}, 'e': {0, 8},
'f': {0, 9}, 'g': {0, 10}, 'h': {0, 11}, 'i': {0, 12}, 'j': {0, 13},
'k': {0, 14}, 'l': {0, 15}, 'm': {0, 16}, 'n': {0, 17}, 'o': {0, 18},
'p': {0, 19}, 'q': {0, 20}, 'r': {0, 21}, 's': {0, 22}, 't': {0, 23},
'u': {0, 24}, 'v': {0, 25}, 'w': {0, 26}, 'x': {0, 27}, 'y': {0, 28},
'z': {0, 29},
49: {0, 30}, // 1
50: {0, 31}, // 2
51: {0, 32}, // 3
52: {0, 33}, // 4
53: {0, 34}, // 5
54: {0, 35}, // 6
55: {0, 36}, // 7
56: {0, 37}, // 8
57: {0, 38}, // 9
48: {0, 39}, // 0
// Uppercase letters (Modifier 2 typically means Left Shift)
'A': {2, 4}, 'B': {2, 5}, 'C': {2, 6}, 'D': {2, 7}, 'E': {2, 8},
'F': {2, 9}, 'G': {2, 10}, 'H': {2, 11}, 'I': {2, 12}, 'J': {2, 13},
'K': {2, 14}, 'L': {2, 15}, 'M': {2, 16}, 'N': {2, 17}, 'O': {2, 18},
'P': {2, 19}, 'Q': {2, 20}, 'R': {2, 21}, 'S': {2, 22}, 'T': {2, 23},
'U': {2, 24}, 'V': {2, 25}, 'W': {2, 26}, 'X': {2, 27}, 'Y': {2, 28},
'Z': {2, 29},
10: {0, 40}, // Enter
9: {0, 43}, // Tab
32: {0, 44}, // Space
45: {0, 45}, // -
61: {0, 46}, // =
91: {0, 47}, // [
93: {0, 48}, // ]
92: {0, 49}, // \
// Numbers
'1': {0, 30}, '2': {0, 31}, '3': {0, 32}, '4': {0, 33}, '5': {0, 34},
'6': {0, 35}, '7': {0, 36}, '8': {0, 37}, '9': {0, 38}, '0': {0, 39},
59: {0, 51}, // ;
39: {0, 52}, // '
96: {0, 53}, // `
44: {0, 54}, // ,
46: {0, 55}, // .
47: {0, 56}, // /
// Shifted numbers / Symbols
'!': {2, 30}, // Shift + 1
'@': {2, 31}, // Shift + 2
'#': {2, 32}, // Shift + 3
'$': {2, 33}, // Shift + 4
'%': {2, 34}, // Shift + 5
'^': {2, 35}, // Shift + 6
'&': {2, 36}, // Shift + 7
'*': {2, 37}, // Shift + 8
'(': {2, 38}, // Shift + 9
')': {2, 39}, // Shift + 0
65: {2, 4}, // A
66: {2, 5}, // B
67: {2, 6}, // C
68: {2, 7}, // D
69: {2, 8}, // E
70: {2, 9}, // F
71: {2, 10}, // G
72: {2, 11}, // H
73: {2, 12}, // I
74: {2, 13}, // J
75: {2, 14}, // K
76: {2, 15}, // L
77: {2, 16}, // M
78: {2, 17}, // N
79: {2, 18}, // O
80: {2, 19}, // P
81: {2, 20}, // Q
82: {2, 21}, // R
83: {2, 22}, // S
84: {2, 23}, // T
85: {2, 24}, // U
86: {2, 25}, // V
87: {2, 26}, // W
88: {2, 27}, // X
89: {2, 28}, // Y
90: {2, 29}, // Z
// Other common characters
'\n': {0, 40}, // Enter (Return)
'\t': {0, 43}, // Tab
' ': {0, 44}, // Space
'-': {0, 45}, // Hyphen / Minus
'=': {0, 46}, // Equals
'[': {0, 47}, // Left Square Bracket
']': {0, 48}, // Right Square Bracket
'\\': {0, 49}, // Backslash
33: {2, 30}, // !
64: {2, 31}, // @
35: {2, 32}, // #
36: {2, 33}, // $
37: {2, 34}, // %
94: {2, 35}, // ^
38: {2, 36}, // &
42: {2, 37}, // *
40: {2, 38}, // (
41: {2, 39}, // )
';': {0, 51}, // Semicolon
'\'': {0, 52}, // Apostrophe / Single Quote
'`': {0, 53}, // Grave Accent / Backtick
',': {0, 54}, // Comma
'.': {0, 55}, // Period / Dot
'/': {0, 56}, // Slash
95: {2, 45}, // _
43: {2, 46}, // +
123: {2, 47}, // {
125: {2, 48}, // }
124: {2, 49}, // |
// Shifted symbols
'_': {2, 45}, // Underscore (Shift + Hyphen)
'+': {2, 46}, // Plus (Shift + Equals)
'{': {2, 47}, // Left Curly Brace (Shift + Left Square Bracket)
'}': {2, 48}, // Right Curly Brace (Shift + Right Square Bracket)
'|': {2, 49}, // Pipe (Shift + Backslash)
58: {2, 51}, // :
34: {2, 52}, // "
126: {2, 53}, // ~
60: {2, 54}, // <
62: {2, 55}, // >
63: {2, 56}, // ?
':': {2, 51}, // Colon (Shift + Semicolon)
'"': {2, 52}, // Double Quote (Shift + Apostrophe)
'~': {2, 53}, // Tilde (Shift + Grave Accent)
'<': {2, 54}, // Less Than (Shift + Comma)
'>': {2, 55}, // Greater Than (Shift + Period)
'?': {2, 56}, // Question Mark (Shift + Slash)
}

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

@@ -0,0 +1,44 @@
package direct
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
var (
streamer = newStreamer()
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
)
func Connect(c *gin.Context) {
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("failed to upgrade to websocket: %s", err)
return
}
defer func() {
_ = ws.Close()
log.Debugf("h264 websocket disconnected: %s", ws.RemoteAddr())
}()
log.Debugf("h264 websocket connected: %s", ws.RemoteAddr())
_ = ws.SetReadDeadline(time.Time{})
streamer.addClient(ws)
defer streamer.removeClient(ws)
for {
if _, _, err := ws.ReadMessage(); err != nil {
log.Debugf("failed to read message (client disconnected): %s", err)
return
}
}
}

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

@@ -1,9 +1,11 @@
package vm
import (
"time"
"NanoKVM-Server/common"
"NanoKVM-Server/proto"
"time"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -17,7 +19,42 @@ func (s *Service) ResetHdmi(c *gin.Context) {
vision.SetHDMI(false)
time.Sleep(1 * time.Second)
vision.SetHDMI(true)
utils.PersistHDMIEnabled()
rsp.OkRsp(c)
log.Debug("reset hdmi")
}
func (s *Service) EnableHdmi(c *gin.Context) {
var rsp proto.Response
vision := common.GetKvmVision()
vision.SetHDMI(true)
utils.PersistHDMIEnabled()
rsp.OkRsp(c)
log.Debug("enable hdmi")
}
func (s *Service) DisableHdmi(c *gin.Context) {
var rsp proto.Response
vision := common.GetKvmVision()
vision.SetHDMI(false)
utils.PersistHDMIDisabled()
rsp.OkRsp(c)
log.Debug("disable hdmi")
}
func (s *Service) GetHdmiState(c *gin.Context) {
var rsp proto.Response
rsp.OkRspWithData(c, &proto.GetGetHdmiStateRsp{
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
}
}
}

76
server/service/vm/tls.go Normal file
View File

@@ -0,0 +1,76 @@
package vm
import (
"fmt"
"os/exec"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/config"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
)
func (s *Service) SetTls(c *gin.Context) {
var req proto.SetTlsReq
var rsp proto.Response
err := proto.ParseFormRequest(c, &req)
if err != nil {
rsp.ErrRsp(c, -1, fmt.Sprintf("invalid arguments: %s", err))
return
}
if req.Enabled {
err = enableTls()
} else {
err = disableTls()
}
if err != nil {
log.Errorf("failed to set TLS: %s", err)
rsp.ErrRsp(c, -2, "operation failed")
return
}
rsp.OkRsp(c)
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
}
func enableTls() error {
if err := utils.GenerateCert(); err != nil {
return err
}
conf, err := config.Read()
if err != nil {
return err
}
conf.Proto = "https"
conf.Cert.Crt = "/etc/kvm/server.crt"
conf.Cert.Key = "/etc/kvm/server.key"
if err := config.Write(conf); err != nil {
return err
}
return nil
}
func disableTls() error {
conf, err := config.Read()
if err != nil {
return err
}
conf.Proto = "http"
if err := config.Write(conf); err != nil {
return err
}
return nil
}

100
server/utils/cert.go Normal file
View File

@@ -0,0 +1,100 @@
package utils
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"time"
log "github.com/sirupsen/logrus"
)
func GenerateCert() error {
var (
host = "localhost"
ipAddress = []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}
validFor = time.Hour * 24 * 365 * 10
certFile = "/etc/kvm/server.crt"
keyFile = "/etc/kvm/server.key"
)
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Errorf("failed to generate RSA private key: %v", err)
return err
}
publicKey := &privateKey.PublicKey
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
log.Errorf("failed to generate serial number: %v", err)
return err
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: host,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(validFor),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: false,
DNSNames: []string{host},
IPAddresses: ipAddress,
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey, privateKey)
if err != nil {
log.Errorf("failed to create certificate: %v", err)
return err
}
// generate certificate
certOut, err := os.Create(certFile)
if err != nil {
log.Errorf("failed to create %s: %v", certFile, err)
return err
}
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
log.Errorf("failed to encode %s: %v", certFile, err)
return err
}
_ = certOut.Sync()
_ = certOut.Close()
log.Debugf("%s generated", certFile)
// generate private key
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) // 权限 0600
if err != nil {
log.Errorf("failed to create %s: %v", keyFile, err)
return err
}
privateBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
log.Errorf("failed to marshal private key: %v", err)
return err
}
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privateBytes}); err != nil {
log.Errorf("failed to encode %s: %v", keyFile, err)
return err
}
_ = keyOut.Sync()
_ = keyOut.Close()
log.Debugf("%s generated", keyFile)
return nil
}

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",
@@ -34,21 +34,22 @@
"react-simple-keyboard": "^3.8.19",
"semver": "^7.6.3",
"vaul": "^0.9.9",
"websocket": "^1.0.35"
"websocket": "^1.0.35",
"yocto-queue": "^1.2.1"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@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",
@@ -57,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": {

1860
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

@@ -62,6 +62,29 @@ export function resetHdmi() {
return http.post('/api/vm/hdmi/reset');
}
// get HDMI state
export function getHdmiState() {
return http.get('/api/vm/hdmi');
}
// enable HDMI
export function enableHdmi() {
return http.post('/api/vm/hdmi/enable');
}
// disable HDMI
export function disableHdmi() {
return http.post('/api/vm/hdmi/disable');
}
// set HDMI state
export function setHdmiState(enabled: boolean) {
if (enabled) {
return enableHdmi();
}
return disableHdmi();
}
// get SSH state
export function getSSHState() {
return http.get('/api/vm/ssh');
@@ -132,6 +155,11 @@ export function disableMdns() {
return http.post('/api/vm/mdns/disable');
}
// enable / disable TLS
export function setTLS(enabled: boolean) {
return http.post('/api/vm/tls', { enabled });
}
// reboot
export function reboot() {
return http.post('/api/vm/system/reboot');

View File

@@ -103,3 +103,7 @@
.keyboard-header {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
.hg-candidate-box {
display: none !important;
}

View File

@@ -1,6 +1,9 @@
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { useAtom } from 'jotai';
import { Helmet, HelmetData } from 'react-helmet-async';
import { getWebTitle } from '@/api/vm.ts';
import { existToken } from '@/lib/cookie.ts';
import { webTitleAtom } from '@/jotai/settings.ts';
type HeadProps = {
@@ -11,7 +14,17 @@ type HeadProps = {
const helmetData = new HelmetData({});
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
const webTitle = useAtomValue(webTitleAtom);
const [webTitle, setWebTitle] = useAtom(webTitleAtom);
useEffect(() => {
if (!existToken()) return;
getWebTitle().then((rsp) => {
if (rsp.data?.title) {
setWebTitle(rsp.data.title);
}
});
}, []);
return (
<Helmet

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

@@ -2,58 +2,57 @@ const de = {
translation: {
head: {
desktop: 'Entfernter Desktop',
login: 'Einloggen',
login: 'Anmelden',
changePassword: 'Passwort ändern',
terminal: 'Terminal',
wifi: 'Wi-Fi'
},
auth: {
login: 'Einloggen',
placeholderUsername: 'Bitte den Benutzernamen eingeben',
placeholderPassword: 'Bitte das Passwort eingeben',
placeholderPassword2: 'Passwort wiederholen',
noEmptyUsername: 'Der Benutzername darf nicht leer sein',
noEmptyPassword: 'Das Passwort darf nicht leer sein',
noAccount:
'Fehler beim Auslsen der Benutzerinformation, bitte die Seite neu laden oder das Passwort zurücksetzen',
login: 'Anmelden',
placeholderUsername: 'Benutzername',
placeholderPassword: 'Passwort',
placeholderPassword2: 'Bitte Passwort erneut eingeben',
noEmptyUsername: 'Benutzername benötigt',
noEmptyPassword: 'Passwort benötigt',
noAccount: 'Abfragen der Benutzerdaten fehlgeschlagen, bitte die Seite neu laden oder Passwort zurücksetzen',
invalidUser: 'Falscher Benutzername oder falsches Passwort',
error: 'Unerwarteter Fehler',
changePassword: 'Passwort ändern',
changePasswordDesc:
'Für die Sicherheit Ihres Geräts ändern Sie bitte das Web-Login-Passwort.',
differentPassword: 'Passwörter stimmt nicht überein',
illegalUsername: 'Benutzername beinhaltet ungültige Zeichen',
illegalPassword: 'Passwort beinhaltet ungültige Zeichen',
changePasswordDesc: 'Für die Sicherheit Ihres Geräts ändern Sie bitte das Passwort!',
differentPassword: 'Passwörter stimmen nicht überein',
illegalUsername: 'Benutzername enthält ungültige Zeichen',
illegalPassword: 'Passwort enthält ungültige Zeichen',
forgetPassword: 'Passwort vergessen',
ok: 'Ok',
cancel: 'Abbrechen',
loginButtonText: 'Einloggen',
loginButtonText: 'Anmelden',
tips: {
reset1:
'To reset the passwords, pressing and holding the BOOT button on the NanoKVM for 10 seconds.',
reset2: 'For detailed steps, please consult this document:',
reset3: 'Web default account:',
reset4: 'SSH default account:',
change1: 'Please note that this action will change the following passwords:',
change2: 'Web login password',
change3: 'System root password (SSH login password)',
change4: 'To reset the passwords, press and hold the BOOT button on the NanoKVM.'
'Um das Passwort zurückzusetzen, drücken und halten Sie den BOOT Knopf auf dem NanoKVM für 10 Sekunden.',
reset2: 'Für detailliertere Anweisungen lesen Sie folgendes Dokument:',
reset3: 'Web Standard-Account:',
reset4: 'SSH Standard-Account:',
change1: 'Bitte beachten Sie, dass diese Aktion folgende Passwörter ändert:',
change2: 'Web Anmelde-Passwort',
change3: 'System root Passwort (SSH Anmelde-Passwort)',
change4: 'Um die Passwörter zurückzusetzen, drücken und halten Sie den BOOT Knopf auf dem NanoKVM.'
}
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi for NanoKVM',
success: 'Please check the network status of NanoKVM and visit the new IP address.',
failed: 'Operation failed, please try again.',
description: 'Wi-Fi Konfiguration für NanoKVM',
success: 'Bitte überprüfen Sie den Netzwerk-Status des NanoKVM und greifen Sie über die neue IP Adresse darauf zu.',
failed: 'Aktion fehlgeschlagen, bitte erneut versuchen.',
confirmBtn: 'Ok',
finishBtn: 'Finished'
finishBtn: 'Fertig'
},
screen: {
video: 'Videomodus',
title: 'Bildschirm',
video: 'Video Modus',
resolution: 'Auflösung',
auto: 'Automatisch',
autoTips:
'Falls es bei bestimmten Auflösungen zu Screen-Tearing oder einem Offset des Mauszeigers kommen sollte, bitte die Auflösung des Remote Hosts anpassen oder den automatischen Modus ausschalten.',
"Bildverzerrungen oder ein versetzter Mauszeiger können bei bestimmten Auflösungen auftreten. Versuchen Sie, die Auflösung des entfernten Hosts anzupassen oder den automatischen Modus zu deaktivieren.",
fps: 'FPS',
customizeFps: 'Anpassen',
quality: 'Qualität',
@@ -61,193 +60,283 @@ const de = {
qualityHigh: 'Hoch',
qualityMedium: 'Mittel',
qualityLow: 'Niedrig',
frameDetect: 'Frame Detect',
frameDetect: 'Bilderkennung',
frameDetectTip:
'Berechnet den Unterschied zwischen den Einzelbildern. Beendet die Liveübertragung des Videostreams wenn keine Änderungen auf dem Bildschirm des Hosts festgestellt werden kann.',
resetHdmi: 'Reset HDMI'
resetHdmi: 'HDMI zurücksetzen'
},
keyboard: {
title: 'Tastatur',
paste: 'Einfügen',
tips: 'Nur Standard-Tastaturbuchstaben und Symbole werden unterstützt.',
tips: 'Server Tastaturbelegung',
placeholder: 'Bitte eingeben',
submit: 'Senden',
virtual: 'Virtuelle Tastatur',
ctrlaltdel: 'Ctrl+Alt+Del'
virtual: 'Tastatur',
ctrlaltdel: 'Ctrl+Alt+Del',
dropdownEnglish: 'Englisch',
dropdownGerman: 'Deutsch'
},
mouse: {
title: 'Maus',
cursor: 'Cursor',
default: 'Standard Cursor',
pointer: 'Zeiger Cursor',
cell: 'Zellen Cursor',
text: 'Text Cursor',
grab: 'Greif Cursor',
hide: 'Cursor ausblenden',
hide: 'Versteckter Cursor',
mode: 'Maus Modus',
absolute: 'Absoluter Modus',
relative: 'Relativer Modus',
requestPointer:
'Der Relative Modus wird benutzt. Bitte auf den Desktop klicken um einen Mauszeiger anzuzeigen.',
resetHid: 'Eingeabegeräte zurücksetzen'
requestPointer: 'Relativer Modus aktiv. Klicken Sie auf den Desktop um den Mauszeiger zu sehen.',
resetHid: 'HID zurücksetzen',
hidOnly: {
title: 'HID-Only Modus',
desc: 'Wenn Ihre Maus und Tastatur nicht mehr reagieren und das Zurücksetzen der HID-Verbindung nicht hilft, könnte es sich um ein Kompatibilitätsproblem zwischen dem NanoKVM und dem Gerät handeln. Versuchen Sie, den HID-Only Modus zu aktivieren, um die Kompatibilität zu verbessern.',
tip1: 'Die Aktivierung des HID-Only Modus entfernt das virtuelle U-Laufwerk und das virtuelle Netzwerk.',
tip2: 'Im HID-Only Modus ist das Einbinden von Systemabbilder deaktiviert.',
tip3: 'NanoKVM wird nach dem Wechsel in den neuen Modus automatisch neu gestartet.',
enable: 'HID-Only Modus aktivieren',
disable: 'HID-Only Modus deaktivieren'
}
},
image: {
title: 'ISO oder Festplatten Abbilder',
loading: 'Laden...',
title: 'Bilder',
loading: 'Lädt...',
empty: 'Nichts gefunden',
mountFailed: 'Mounten fehlgeschlagen',
mountDesc:
'Bei einigen Systemen muss die virtuelle Festplatte auf dem Remote-Host ausgeworfen werden, bevor das Image gemountet werden kann.',
cdrom: 'Binden Sie das Abbild im CD-ROM Modus ein',
mountFailed: 'Einbinden fehlgeschlagen',
mountDesc:
"In einigen Systemen ist es notwendig, die virtuelle Festplatte auf dem entfernten Host auszuwerfen, bevor das Image eingebunden werden kann.",
refresh: 'Bilder aktualisieren',
tips: {
title: 'Wie man Abbilder hochlädt',
usb1: 'Den NanoKVM via USB mit dem Computer verbinden.',
usb2: 'Stellen sie sicher, das das Virtuelle Festplatte eingebunden ist. (Einstellungen - Virtuelle Festplatte).',
usb3: 'Öffnen sie die Virtuelle Festplatte auf ihrem Computer und kopieren sie die Abbilddatei in das root-Verzeichnis des USB-Geräts.',
scp1: 'Stellen sie sicher, das sich der NanoKVM und ihr Computer im gleichen lokalen Netzwerk befinden.',
scp2: 'Öffnen sie ein Terminal auf ihrem Computer und benutzen sie den SCP-Befehl um die Abbilddatei in das /data Verzeichnis des NanoKVM zu kopieren.',
scp3: 'Beispiel: scp ihr-abbild-verzeichnis root@ihre-nanokvm-ip:/data',
tfCard: 'TF-Karte',
tf1: 'Diese Mehthode wird auf Linuxsystemen unterstützt',
tf2: 'Entfernen sie die TF-Karte aus dem NanoKVM (gilt nur für die fertig zusammengebaute Version, bitte das Gehäuse vorher auseinanderbauen).',
tf3: 'Legen sie die TF-Karte in den Kartenleser ein und verbinden sie diesen mit dem Computer.',
tf4: 'Kopieren sie die Abbild-Datei in das /data Verzeichnis der TF-Karte.',
tf5: 'Legen sie die TF-Karte in das NanoKVM ein.'
title: 'So laden Sie Dateien hoch',
usb1: 'Verbinden Sie den NanoKVM über USB mit Ihrem Computer.',
usb2: 'Stellen Sie sicher, dass die virtuelle Festplatte eingebunden ist (Einstellungen – Virtuelle Festplatte).',
usb3: 'Öffnen Sie die virtuelle Festplatte auf Ihrem Computer und kopieren Sie die Image-Datei in das Stammverzeichnis der virtuellen Festplatte.',
scp1: 'Stellen Sie sicher, dass sich der NanoKVM und Ihr Computer im selben lokalen Netzwerk befinden.',
scp2: 'Öffnen Sie ein Terminal auf Ihrem Computer und verwenden Sie den SCP-Befehl, um die Image-Datei in das Verzeichnis /data auf dem NanoKVM hochzuladen.',
scp3: 'Beispiel: scp your-image-path root@your-nanokvm-ip:/data',
tfCard: 'TF-Karte',
tf1: 'Diese Methode wird unter Linux-Systemen unterstützt.',
tf2: 'Entnehmen Sie die TF-Karte aus dem NanoKVM (bei der FULL-Version muss zuvor das Gehäuse geöffnet werden).',
tf3: 'Stecken Sie die TF-Karte in einen Kartenleser und verbinden Sie diesen mit Ihrem Computer.',
tf4: 'Kopieren Sie die Image-Datei in das Verzeichnis /data auf der TF-Karte.',
tf5: 'Setzen Sie die TF-Karte wieder in den NanoKVM ein.'
}
},
script: {
title: 'Skript',
title: 'Skripte',
upload: 'Hochladen',
run: 'Ausführen',
runBackground: 'Im Hintergrund ausführen',
runFailed: 'Ausführung abgebrochen',
runFailed: '',
attention: 'Achtung',
delDesc: 'Sind sie sicher, dass sie diese Datei löschen möchten?',
delDesc: 'Möchten Sie diese Datei wirklich löschen?',
confirm: 'Ja',
cancel: 'Nein',
delete: 'Löschen',
close: 'Schließen'
close: 'Schliessen'
},
terminal: {
title: 'Terminal',
nanokvm: 'NanoKVM Terminal',
serial: 'Terminal über den Seriellen Port',
serialPort: 'Serieller Port',
serialPortPlaceholder: 'Serielle Portnummer eingeben',
serial: 'Serieller Anschluss Terminal',
serialPort: 'Serieller Anschluss',
serialPortPlaceholder: 'Bitte seriellen Anschluss angeben',
baudrate: 'Baudrate',
parity: 'Parität',
parityNone: 'Keine',
parityEven: 'Gerade',
parityOdd: 'Ungerade',
flowControl: 'Fluss-Kontrolle',
flowControlNone: 'Keine',
flowControlSoft: 'Wenig',
flowControlHard: 'Viel',
dataBits: 'Daten bits',
stopBits: 'Stopp bits',
confirm: 'Ok'
},
wol: {
title: 'Wake-on-LAN',
sending: 'Befehl senden...',
sending: 'Sende Befehl...',
sent: 'Befehl gesendet',
input: 'Bitte die MAC eingeben',
input: 'Bitte MAC Adresse eingeben',
ok: 'Ok'
},
download: {
title: 'Systemabbild Downloader',
input: 'Bitte geben Sie die URL für das Remote-Systemabbild ein',
ok: 'Ok',
disabled: '/data Partition ist nur-lesbar, daher kann das Systemabbild nicht heruntergeladen werden'
},
power: {
title: 'Einschalten',
reset: 'Neustart',
power: 'Einschalten',
powerShort: 'Einschalten (Kurzes Drücken)',
powerLong: 'Einschalten (Langes Drücken)'
title: 'Power',
showConfirm: 'Bestätigung',
showConfirmTip: 'Diese Aktionen benötigen eine zusätzliche Bestätigung',
reset: 'Zurücksetzen',
power: 'Power',
powerShort: 'Power (Kurzer Klick)',
powerLong: 'Power (Langer Klick)',
resetConfirm: 'Reset-Aktion durchführen?',
powerConfirm: 'Power-Aktion durchführen?',
okBtn: 'Ja',
cancelBtn: 'Nein'
},
settings: {
title: 'Settings',
title: 'Einstellungen',
about: {
title: 'Über NanoKVM',
information: 'Information',
information: 'Informationen',
ip: 'IP',
mdns: 'mDNS',
application: 'Versionsnummer',
applicationTip: 'NanoKVM web application version',
image: 'Image Version',
imageTip: 'NanoKVM system image version',
deviceKey: 'Geräte-Key',
community: 'Community'
application: 'Applikations-Version',
applicationTip: 'NanoKVM Web Applikations-Version',
image: 'Systemabbild-Version',
imageTip: 'NanoKVM Systemabbild-Version',
deviceKey: 'Geräte Schlüssel',
community: 'Community',
hostname: 'Hostname',
hostnameUpdated: 'Hostname aktualisiert. Neustarten um zu übernehmen.',
ipType: {
'Wired': 'Kabel',
'Wireless': 'Drahtlos',
'Other': 'Andere'
}
},
appearance: {
title: 'Appearance',
display: 'Display',
language: 'Language',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar'
title: 'Erscheinungsbild',
display: 'Bildschirm',
language: 'Sprache',
menuBar: 'Menu-Leiste',
menuBarDesc: 'Symbole in der Menu-Leiste anzeigen',
webTitle: 'Web Titel',
webTitleDesc: 'Passen Sie den Web-Seite Titel an'
},
device: {
title: 'Device',
title: 'Gerät',
oled: {
title: 'OLED',
description: 'OLED screen automatically sleep',
0: 'Never',
15: '15 sec',
30: '30 sec',
60: '1 min',
180: '3 min',
300: '5 min',
600: '10 min',
1800: '30 min',
3600: '1 hour'
description: 'Schalte OLED Bildschirm aus nach',
0: 'Nie',
15: '15 Sek',
30: '30 Sek',
60: '1 Min',
180: '3 Min',
300: '5 Min',
600: '10 Min',
1800: '30 Min',
3600: '1 Stunde'
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi',
setBtn: 'Config'
description: 'Wi-Fi konfigurieren',
setBtn: 'Konfigurieren'
},
disk: 'Virtual Disk',
diskDesc: 'Mount virtual U-disk on the remote host',
network: 'Virtual Network',
networkDesc: 'Mount virtual network card on the remote host'
ssh: {
description: 'Aktiviere entfernten SSH-Zugang',
tip: 'Setzten Sie ein starkes Passwort vor dem aktivieren (Konto - Passwort ändern)'
},
advanced: 'Erweiterte Einstellungen',
swap: {
title: 'Swap',
disable: 'Deaktivieren',
description: 'Grösse der Swap-Datei festlegen',
tip: "Das Aktivieren dieser Funktion kann die Lebensdauer Ihrer SD-Karte verkürzen!"
},
mouseJiggler: {
title: 'Mausaktivitäts-Simulator',
description: 'Verhindert, dass der remote Host in den Energiesparmodus wechselt',
disable: 'Deaktivieren',
absolute: 'Absoluter Modus',
relative: 'Relativer Modus'
},
mdns: {
description: 'mDNS-Erkennungsdienst aktivieren',
tip: "Deaktivieren Sie den Dienst, wenn Sie ihn nicht benötigen"
},
hidOnly: 'HID-Only Mode',
disk: 'Virtuelle Festplatte',
diskDesc: 'Binde das virtuelle U-Laufwerk an den entfernten Host',
network: 'Virtuelles Netzwerk',
networkDesc: 'Binde die virtuelle Netzwerkkarte an den entfernten Host',
reboot: 'Neustarten',
rebootDesc: 'Sind Sie sicher dass Sie NanoKVM neustarten möchten?',
okBtn: 'Ja',
cancelBtn: 'Nein'
},
tailscale: {
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 50MB if using Tailscale. A Tailscale restart is required for the change to take effect.",
disable: 'Disable'
title: 'Speicher Optimierung',
tip: "Wenn die Speichernutzung das Limit überschreitet, wird die Speicherbereinigung aggressiver durchgeführt, um Speicher freizugeben. Es wird empfohlen, den Wert auf 75 MB zu setzen, wenn Tailscale verwendet wird. Ein Neustart von Tailscale ist erforderlich, damit die Änderung wirksam wird.",
disable: 'Deaktivieren'
},
restart: 'Are you sure to restart Tailscale?',
stop: 'Are you sure to stop Tailscale?',
stopDesc: 'Log out Tailscale and disable its automatic startup on boot.',
loading: 'Lade...',
notInstall: 'Tailscale wurde nicht gefunden! Bitte installieren.',
restart: 'Tailscale neu starten?',
stop: 'Tailscale stoppen?',
stopDesc: 'Von Tailscale abmelden und automatischen Start beim Booten deaktivieren.',
loading: 'Lädt...',
notInstall: 'Tailscale nicht gefunden! Bitte installieren.',
install: 'Installieren',
installing: 'Installation wird durchgeführt',
failed: 'Installation gescheitert',
retry:
'Bitte neu laden und noch einmal versuchen. Oder versuchen sie Tailscale manuell zu installieren',
download: 'Laden sie das',
package: 'Installationspaket',
unzip: 'und entpacken sie es manuell',
upTailscale: 'Hochladen von tailscale in das NanoKVM Verzeichnis /usr/bin/',
upTailscaled: 'Hochladen von tailscaled in das NanoKVM Verzeichnis /usr/sbin/',
refresh: 'Die aktuelle Seite neu laden',
installing: 'Installiere',
failed: 'Installation fehlgeschlagen',
retry: 'Bitte Seite neu laden und erneut versuchen oder manuelle Installation versuchen.',
download: 'Laden Sie das',
package: 'Installations-Paket herunter',
unzip: 'und entpacken Sie es',
upTailscale: 'Tailscale nach /usr/bin/ auf NanoKVM hochladen',
upTailscaled: 'Tailscaled nach /usr/bin/ auf NanoKVM hochladen',
refresh: 'Aktuelle Seite neu laden',
notLogin:
'Diese Geräte ist bisher noch nicht verknüpft. Bitte loggen sie sich in ihr Konto ein und verknüpfen sie dieses Gerät mit diesem.',
'Das Gerät konnte noch nicht gefunden werden. Bitte melden Sie sich an und verknüpfen Sie dieses Gerät mit Ihrem Konto.',
urlPeriod: 'Diese URL ist für 10 Minuten gültig',
login: 'Einloggen',
loginSuccess: 'Das Einloggen war erfolgreich',
enable: 'Tailscale aktivieren',
deviceName: 'Name des Geräts',
deviceIP: 'Geräte-IP',
account: 'Benutzerkonto',
logout: 'Ausloggen',
logout2: 'Wollen Sie sich wirklich ausloggen?',
okBtn: 'Yes',
cancelBtn: 'No'
login: 'Anmelden',
loginSuccess: 'Anmeldung erfolgreich',
enable: 'Tailscale einschalten',
deviceName: 'Geräte Name',
deviceIP: 'Geräte IP',
account: 'Konto',
logout: 'Abmelden',
logoutDesc: 'Möchten Sie sich wirklich abmelden?',
uninstall: 'Tailscale deinstallieren',
okBtn: 'Ja',
cancelBtn: 'Nein'
},
update: {
title: 'Nach neuem Update suchen',
queryFailed: 'Versionsnummer konnte nicht erkannt werden',
updateFailed: 'Update gescheitert. Bitte versuchen sie es erneut.',
isLatest: 'Die aktuelle Version ist bereits installiert.',
available:
'Ein Update ist verfügbar. Sind sie sicher, dass sie diese Version aktualisieren möchten?',
updating: 'Update wird gestartet. Bitte warten...',
title: 'Nach Aktualisierungen suchen',
queryFailed: 'Version konnte nicht abgefragt werden',
updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.',
isLatest: 'Sie haben bereits die aktuellste Version.',
available: 'Eine Aktualisierung ist verfügbar. Möchten sie diese jetzt durchführen?',
updating: 'Aktualisierung gestartet. Bitte warten...',
confirm: 'Bestätigen',
cancel: 'Abbrechen'
cancel: 'Abbrechen',
preview: 'Vorab-Versionen',
previewDesc: 'Erhalten Sie vorab Zugriff auf neue Funktionen und Verbesserungen',
previewTip:
'Bitte beachten Sie, dass Vorab-Versionen womöglich noch Fehler oder unvollständige Funktionen enthalten!'
},
account: {
title: 'Account',
webAccount: 'Web Account Name',
password: 'Password',
updateBtn: 'Update',
logoutBtn: 'Logout'
title: 'Konto',
webAccount: 'Web Konto Name',
password: 'Passwort',
updateBtn: 'Ändern',
logoutBtn: 'Abmelden',
logoutDesc: 'Möchten Sie sich wirklich abmelden?',
okBtn: 'Ja',
cancelBtn: 'Nein'
}
},
error: {
title: "Wir sind auf ein Problem gestossen",
refresh: 'Neuladen'
},
fullscreen: {
toggle: 'Vollbild ein/aus'
},
menu: {
collapse: 'Menu einblenden',
expand: 'Menu verbergen'
}
}
};

View File

@@ -49,6 +49,7 @@ const en = {
screen: {
title: 'Screen',
video: 'Video Mode',
videoDirectTips: 'Enable HTTPS in "Settings > Device" to use this mode',
resolution: 'Resolution',
auto: 'Automatic',
autoTips:
@@ -72,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',
@@ -86,6 +93,9 @@ const en = {
mode: 'Mouse mode',
absolute: 'Absolute mode',
relative: 'Relative mode',
speed: 'Wheel speed',
fast: 'Fast',
slow: 'Slow',
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.',
resetHid: 'Reset HID',
hidOnly: {
@@ -102,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.',
@@ -195,7 +212,12 @@ const en = {
deviceKey: 'Device Key',
community: 'Community',
hostname: 'Hostname',
hostnameUpdated: 'Hostname updated. Reboot to apply.'
hostnameUpdated: 'Hostname updated. Reboot to apply.',
ipType: {
Wired: 'Wired',
Wireless: 'Wireless',
Other: 'Other'
}
},
appearance: {
title: 'Appearance',
@@ -230,8 +252,13 @@ const en = {
description: 'Enable SSH remote access',
tip: 'Set a strong password before enabling (Account - Change Password)'
},
tls: {
description: 'Enable HTTPS protocol',
tip: 'Be aware: Using HTTPS can increase latency, especially with MJPEG video mode.'
},
advanced: 'Advanced Settings',
swap: {
title: 'Swap',
disable: 'Disable',
description: 'Set the swap file size',
tip: "Enabling this feature could shorten your SD card's usable life!"
@@ -247,6 +274,9 @@ const en = {
description: 'Enable mDNS discovery service',
tip: "Turning it off if it's not needed"
},
hdmi: {
description: 'Enable HDMI/monitor output'
},
hidOnly: 'HID-Only Mode',
disk: 'Virtual Disk',
diskDesc: 'Mount virtual U-disk on the remote host',
@@ -261,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?',
@@ -279,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',
@@ -291,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

@@ -40,20 +40,22 @@ const ko = {
},
wifi: {
title: 'Wi-Fi',
description: 'Wi-Fi 설정 for NanoKVM',
description: 'NanoKVM Wi-Fi 설정',
success: 'NanoKVM의 네트워크 상태를 확인하고 새 IP 주소로 접속하세요.',
failed: '작업이 실패했습니다. 다시 시도하세요.',
failed: '작업에 실패했습니다. 다시 시도하세요.',
confirmBtn: '확인',
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

@@ -5,7 +5,7 @@ const nl = {
login: 'Inloggen',
changePassword: 'Wachtwoord wijzigen',
terminal: 'Terminal',
wifi: 'Wi-Fi'
wifi: 'Wifi'
},
auth: {
login: 'Inloggen',
@@ -29,24 +29,23 @@ const nl = {
cancel: 'Annuleren',
loginButtonText: 'Inloggen',
tips: {
reset1:
'To reset the passwords, pressing and holding the BOOT button on the NanoKVM for 10 seconds.',
reset2: 'For detailed steps, please consult this document:',
reset3: 'Web default account:',
reset4: 'SSH default account:',
change1: 'Please note that this action will change the following passwords:',
change2: 'Web login password',
change3: 'System root password (SSH login password)',
change4: 'To reset the passwords, press and hold the BOOT button on the NanoKVM.'
reset1: 'Om de wachtwoorden opnieuw in te stellen, houdt u de BOOT-knop op de NanoKVM 10 seconden lang ingedrukt.',
reset2: 'Voor gedetailleerde stappen kunt u dit document raadplegen:',
reset3: 'Standaard webaccount:',
reset4: 'Standaard SSH-account:',
change1: 'Houd er rekening mee dat deze actie de volgende wachtwoorden zal wijzigen:',
change2: 'Web login wachtwoord',
change3: 'Systeem root-wachtwoord (SSH-inlogwachtwoord)',
change4: 'Om de wachtwoorden opnieuw in te stellen, houdt u de BOOT-knop op de NanoKVM ingedrukt.'
}
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi for NanoKVM',
success: 'Please check the network status of NanoKVM and visit the new IP address.',
failed: 'Operation failed, please try again.',
title: 'Wifi',
description: 'Wifi configureren voor NanoKVM',
success: 'Controleer de netwerkstatus van NanoKVM en bezoek het nieuwe IP-adres.',
failed: 'De bewerking is mislukt. Probeer het opnieuw.',
confirmBtn: 'Ok',
finishBtn: 'Finished'
finishBtn: 'Gereed'
},
screen: {
video: 'Videomodus',
@@ -162,18 +161,18 @@ const nl = {
community: 'Community'
},
appearance: {
title: 'Appearance',
display: 'Display',
language: 'Language',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar'
title: 'Uiterlijk',
display: 'Beeldscherm',
language: 'Taal',
menuBar: 'Menubalk',
menuBarDesc: 'Toon iconen in de menubalk'
},
device: {
title: 'Device',
title: 'Apparaat',
oled: {
title: 'OLED',
description: 'OLED screen automatically sleep',
0: 'Never',
description: 'OLED scherm automatisch slapen',
0: 'Nooit',
15: '15 sec',
30: '30 sec',
60: '1 min',
@@ -181,32 +180,32 @@ const nl = {
300: '5 min',
600: '10 min',
1800: '30 min',
3600: '1 hour'
3600: '1 uur'
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi',
title: 'Wifi',
description: 'Configureer wifi',
setBtn: 'Config'
},
disk: 'Virtual Disk',
diskDesc: 'Mount virtual U-disk on the remote host',
network: 'Virtual Network',
networkDesc: 'Mount virtual network card on the remote host'
disk: 'Virtuele schijf',
diskDesc: 'Koppel virtuele U-schijf aan de externe host',
network: 'Virtueel Netwerk',
networkDesc: 'Koppel virtueel netwerk kaart aan de externe host'
},
tailscale: {
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 50MB if using Tailscale. A Tailscale restart is required for the change to take effect.",
disable: 'Disable'
title: 'Geheugen optimalisatie',
tip: "Wanneer geheugen gebruik de limiet overschreid, garbage collection wordt agressiever uitgevoerd om geheugen vrij te maken. geadviseerd om 50MB te kiezen als Tailscale wordt gebruikt. Tailscale moet worden herstart om de wijziging door te voeren.",
disable: 'Uitschakelen'
},
restart: 'Are you sure to restart Tailscale?',
stop: 'Are you sure to stop Tailscale?',
stopDesc: 'Log out Tailscale and disable its automatic startup on boot.',
restart: 'Weet u zeker dat u Tailscale opnieuw wilt opstarten?',
stop: 'Weet u zeker dat u Tailscale wilt stoppen?',
stopDesc: 'Meld Tailscale af en schakel het automatisch opstarten bij het opstarten uit.',
loading: 'Laden...',
notInstall: 'Tailscale niet gevonden! Installeer a.u.b.',
install: 'Installeren',
installing: 'Installeren',
installing: 'Installeren bezig',
failed: 'Installatie mislukt',
retry: 'Vernieuw en probeer opnieuw. Of probeer handmatig te installeren',
download: 'Download het',
@@ -226,8 +225,8 @@ const nl = {
account: 'Account',
logout: 'Uitloggen',
logout2: 'Weet u zeker dat u wilt uitloggen?',
okBtn: 'Yes',
cancelBtn: 'No'
okBtn: 'Ja',
cancelBtn: 'Nee'
},
update: {
title: 'Controleren op updates',
@@ -241,10 +240,10 @@ const nl = {
},
account: {
title: 'Account',
webAccount: 'Web Account Name',
password: 'Password',
webAccount: 'Web Account Naam',
password: 'Wachtwoord',
updateBtn: 'Update',
logoutBtn: 'Logout'
logoutBtn: 'Afmelden'
}
}
}

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;

View File

@@ -14,8 +14,7 @@ const ru = {
placeholderPassword2: 'Введите пароль снова',
noEmptyUsername: 'Имя пользователя не может быть пустым',
noEmptyPassword: 'Пароль не может быть пустым',
noAccount:
'Не удалось получить информацию о пользователе, пожалуйста, обновите веб-страницу или сбросьте пароль',
noAccount: 'Не удалось получить информацию о пользователе, пожалуйста, обновите веб-страницу или сбросьте пароль',
invalidUser: 'Неверное имя пользователя или пароль',
error: 'Непредвиденная ошибка',
changePassword: 'Изменить пароль',
@@ -48,7 +47,9 @@ const ru = {
finishBtn: 'Готово'
},
screen: {
title: 'Экран',
video: 'Видеорежим',
videoDirectTips: 'Включите HTTPS в "Настройки > Устройство", чтобы использовать этот режим',
resolution: 'Разрешение',
auto: 'Автоматическое',
autoTips:
@@ -66,6 +67,7 @@ const ru = {
resetHdmi: 'Перезагрузить HDMI подсистему'
},
keyboard: {
title: 'Клавиатура',
paste: 'Вставка текста',
tips: 'Поддерживаются только стандартные буквы и символы клавиатуры',
placeholder: 'Текст для ввода',
@@ -74,6 +76,8 @@ const ru = {
ctrlaltdel: 'Ctrl+Alt+Del'
},
mouse: {
title: 'Мышь',
cursor: 'Стиль курсора',
default: 'Курсор по умолчанию',
pointer: 'Курсор: указатель',
cell: 'Курсор: крест',
@@ -84,7 +88,16 @@ const ru = {
absolute: 'Абсолютный режим',
relative: 'Относительный режим',
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: 'Образы',
@@ -94,6 +107,7 @@ const ru = {
mountFailed: 'Монтирование образа не удалось',
mountDesc:
'В некоторых системах необходимо отсоединить виртуальный диск на удаленном хосте перед монтированием образа.',
refresh: 'Обновить список образов',
tips: {
title: 'Как загрузить',
usb1: 'Подключите NanoKVM к вашему компьютеру через USB.',
@@ -130,6 +144,16 @@ const ru = {
serialPort: 'COM-порт',
serialPortPlaceholder: 'Введите COM-порт',
baudrate: 'Скорость передачи',
parity: 'Чётность',
parityNone: 'Нет',
parityEven: 'Чётная',
parityOdd: 'Нечётная',
flowControl: 'Управление потоком',
flowControlNone: 'Нет',
flowControlSoft: 'Программное',
flowControlHard: 'Аппаратное',
dataBits: 'Бит данных',
stopBits: 'Стоп-бит',
confirm: 'ОК'
},
wol: {
@@ -147,10 +171,16 @@ const ru = {
},
power: {
title: 'Питание',
showConfirm: 'Подтверждение',
showConfirmTip: 'Если включено, операции с питанием потребуют дополнительного подтверждения',
reset: 'Экстренная перезагрузка',
power: 'Питание',
powerShort: 'Питание (короткое нажатие)',
powerLong: 'Питание (длинное нажатие)'
powerLong: 'Питание (длинное нажатие)',
resetConfirm: 'Вы уверены, что хотите выполнить экстренную перезагрузку?',
powerConfirm: 'Вы уверены, что хотите нажать кнопку питания?',
okBtn: 'Да',
cancelBtn: 'Нет'
},
settings: {
title: 'Настройки',
@@ -164,14 +194,23 @@ const ru = {
image: 'Версия прошивки',
imageTip: 'Версия системного образа NanoKVM',
deviceKey: 'Ключ устройства',
community: 'Сообщество'
community: 'Сообщество',
hostname: 'Имя хоста',
hostnameUpdated: 'Имя хоста изменено. Перезагрузите NanoKVM, чтобы настройки вступили в силу.',
ipType: {
'Wired': 'Проводное',
'Wireless': 'Беспроводное',
'Other': 'Другое'
}
},
appearance: {
title: 'Внешний вид',
display: 'Отображение',
language: 'Язык',
menuBar: 'Панель меню',
menuBarDesc: 'Иконки для отображения в панели меню'
menuBarDesc: 'Иконки для отображения в панели меню',
webTitle: 'Заголовок страницы',
webTitleDesc: 'Изменить заголовок страницы'
},
device: {
title: 'Устройство',
@@ -197,10 +236,37 @@ const ru = {
description: 'Включить удаленный доступ по SSH',
tip: 'Задайте сильный пароль перед включением (Аккаунт - Пароль - Обновить)'
},
tls: {
description: 'Включить HTTPS Protocol',
tip: 'Имейте в виду: использование HTTPS может увеличить задержку, особенно в режиме видео MJPEG.'
},
advanced: 'Дополнительные настройки',
swap: {
title: 'Файл подкачки',
disable: 'Отключить',
description: 'Задать размер файла подкачки',
tip: 'Включение этой опции может негативно сказаться на долговечности SD-карты!',
},
mouseJiggler: {
title: 'Дрожалка для мыши',
description: 'Включите данную опцию, если хост засыпает',
disable: 'Отключить',
absolute: 'Абсолютный режим',
relative: 'Относительный режим'
},
mdns: {
description: 'Включить сервис обнаружения mDNS',
tip: "Выключите, если нет необходимости"
},
hidOnly: 'Режим только HID',
disk: 'Виртуальный диск',
diskDesc: 'Смонтировать виртуальный USB диск на удаленном хосте',
network: 'Виртуальная сеть',
networkDesc: 'Смонтировать виртуальное сетевое устройство на удаленном хосте'
networkDesc: 'Смонтировать виртуальное сетевое устройство на удаленном хосте',
reboot: 'Перезагрузка',
rebootDesc: 'Вы уверены, что хотите перезагрузить NanoKVM?',
okBtn: 'Да',
cancelBtn: 'Нет'
},
tailscale: {
title: 'Tailscale',
@@ -224,7 +290,8 @@ const ru = {
upTailscale: 'Переместите tailscale в каталог /usr/bin/ на NanoKVM',
upTailscaled: 'Переместите tailscaled в каталог /usr/sbin/ на NanoKVM',
refresh: 'Обновите текущую страницу',
notLogin: 'Устройство не привязано. Войдите, чтобы привязать его к аккаунту.',
notLogin:
'Устройство не привязано. Войдите, чтобы привязать его к аккаунту.',
urlPeriod: 'Этот адрес действителен в течение 10 минут',
login: 'Войти',
loginSuccess: 'Вход выполнен',
@@ -233,7 +300,7 @@ const ru = {
deviceIP: 'IP адрес устройства',
account: 'Аккаунт',
logout: 'Выход',
logout2: 'Вы действительно хотите выйти?',
logoutDesc: 'Вы действительно хотите выйти?',
uninstall: 'Удалить Tailscale',
okBtn: 'Да',
cancelBtn: 'Нет'
@@ -246,19 +313,33 @@ const ru = {
available: 'Доступно обновление. Вы уверены, что хотите обновить?',
updating: 'Начато обновление. Пожалуйста, подождите...',
confirm: 'Подтвердить',
cancel: 'Отмена'
cancel: 'Отмена',
preview: 'Ранние обновления',
previewDesc: 'Получайте ранний доступ к новым функциям и улучшениям',
previewTip:
'Обратите внимание: в ранних версиях могут быть ошибки или незавершённый функционал!'
},
account: {
title: 'Аккаунт',
webAccount: 'Имя веб-аккаунта',
password: 'Пароль',
updateBtn: 'Обновить',
logoutBtn: 'Выйти'
logoutBtn: 'Выйти',
logoutDesc: 'Вы действительно хотите выйти?',
okBtn: 'Да',
cancelBtn: 'Нет'
}
},
error: {
title: "У нас возникла проблема",
refresh: 'Обновить страницу'
},
fullscreen: {
toggle: 'Полноэкранный режим'
},
menu: {
collapse: 'Свернуть меню',
expand: 'Развернуть меню'
}
}
};

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

@@ -10,46 +10,46 @@ const uk = {
auth: {
login: 'Вхід',
placeholderUsername: "Введіть ім'я користувача",
placeholderPassword: 'введіть пароль',
placeholderPassword2: 'введіть пароль ще раз',
noEmptyUsername: "ім'я користувача не може бути порожнім",
noEmptyPassword: 'пароль не може бути порожнім',
noAccount:
'Не вдалося отримати інформацію про користувача, оновіть веб-сторінку або скиньте пароль',
invalidUser: "недійсне ім'я користувача або пароль",
error: 'якась халепа! непередбачена помилка :(',
placeholderPassword: 'Введіть пароль',
placeholderPassword2: 'Введіть пароль ще раз',
noEmptyUsername: "Ім'я користувача не може бути порожнім",
noEmptyPassword: 'Пароль не може бути порожнім',
noAccount: 'Не вдалося отримати інформацію про користувача, оновіть веб-сторінку або скиньте пароль',
invalidUser: "Недійсне ім'я користувача або пароль",
error: 'Якась халепа! Непередбачена помилка :(',
changePassword: 'Змінити пароль',
changePasswordDesc:
'Для безпеки вашого пристрою, будь ласка, змініть пароль для входу в веб-інтерфейс.',
differentPassword: 'паролі не збігаються',
illegalUsername: "ім'я користувача містить недопустимі символи",
illegalPassword: 'пароль містить недопустимі символи',
changePasswordDesc: 'Для безпеки вашого пристрою, будь ласка, змініть пароль для входу в веб-інтерфейс.',
differentPassword: 'Паролі не збігаються',
illegalUsername: "Ім'я користувача містить недопустимі символи",
illegalPassword: 'Пароль містить недопустимі символи',
forgetPassword: 'Забули пароль',
ok: 'Ок',
cancel: 'Скасувати',
loginButtonText: 'Увійти',
tips: {
reset1:
'To reset the passwords, pressing and holding the BOOT button on the NanoKVM for 10 seconds.',
reset2: 'For detailed steps, please consult this document:',
reset3: 'Web default account:',
reset4: 'SSH default account:',
change1: 'Please note that this action will change the following passwords:',
change2: 'Web login password',
change3: 'System root password (SSH login password)',
change4: 'To reset the passwords, press and hold the BOOT button on the NanoKVM.'
'Щоб скинути паролі, натискаючи та тримаючи кнопку завантаження на Nanokvm протягом 10 секунд.',
reset2: 'Щоб отримати детальні кроки, зверніться до цього документа:',
reset3: 'Обліковий запис вебінтерфейс за замовчуванням:',
reset4: 'Обліковий запис SSH за замовчуванням:',
change1: 'Зверніть увагу, що ця дія змінить наступні паролі:',
change2: 'Пароль для входу у веб-інтерфейс',
change3: 'Системний root пароль (пароль для входу в систему по SSH)',
change4: 'Щоб скинути паролі, натисніть та утримуйте кнопку "Завантаження" на Nanokvm.'
}
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi for NanoKVM',
success: 'Please check the network status of NanoKVM and visit the new IP address.',
failed: 'Operation failed, please try again.',
confirmBtn: 'Ok',
finishBtn: 'Finished'
description: 'Налаштування Wi-Fi для Nanokvm',
success: 'Будь ласка, перевірте статус мережі NANOKVM та відвідайте нову IP -адресу.',
failed: 'Операція не вдалася, спробуйте ще раз.',
confirmBtn: 'Так',
finishBtn: 'Готово'
},
screen: {
title: 'Екран',
video: 'Відеорежим',
videoDirectTips: 'Увімкніть HTTPS у "Налаштування > Пристрій", щоб використовувати цей режим',
resolution: 'Роздільна здатність',
auto: 'Автоматично',
autoTips:
@@ -64,9 +64,10 @@ const uk = {
frameDetect: 'Виявлення кадрів',
frameDetectTip:
'Обчислює різницю між кадрами. Зупиняє передачу відеопотоку, коли на екрані віддаленого хоста не виявлено змін.',
resetHdmi: 'Reset HDMI'
resetHdmi: 'Перезавантажити HDMI підсистему'
},
keyboard: {
title: 'Клавіатура',
paste: 'Вставити',
tips: 'Підтримуються лише стандартні літери та символи клавіатури',
placeholder: 'Будь ласка, введіть',
@@ -75,6 +76,8 @@ const uk = {
ctrlaltdel: 'Ctrl+Alt+Del'
},
mouse: {
title: 'Миша',
cursor: 'Стиль курсору',
default: 'Курсор за замовчуванням',
pointer: 'Курсор-стрілка',
cell: 'Курсор-таблиця',
@@ -84,17 +87,27 @@ const uk = {
mode: 'Режим миші',
absolute: 'Абсолютний режим',
relative: 'Відносний режим',
requestPointer:
'Використовується відносний режим. Будь ласка, натисніть на робочий стіл, щоб отримати курсор миші.',
resetHid: 'Скинути HID'
requestPointer: 'Використовується відносний режим. Будь ласка, натисніть на робочий стіл, щоб отримати курсор миші.',
resetHid: 'Скинути HID',
hidOnly: {
title: 'Режим лише HID',
desc: 'Якщо ваша миша та клавіатура перестають відповідати та скинути HID, це не допомагає, це може бути проблемою сумісності між NanoKVM та пристроєм. Спробуйте ввімкнути режим "лише HID" лише для кращої сумісності.',
tip1: 'Увімкнення режиму "лише HID" демонтує віртуальний U-диск і віртуальну мережу',
tip2: 'У режимі "лише HID" монтування образів буде вимкнено',
tip3: 'NANOKVM автоматично перезавантажиться після перемикання режимів',
enable: 'Увімкнути режим "лише HID"',
disable: 'Ввимкнути режим "лише HID"'
}
},
image: {
title: 'Зображення',
title: 'Образи',
loading: 'Завантаження...',
empty: 'Нічого не знайдено',
mountFailed: 'Не вдалося змонтувати',
cdrom: 'Встановіть образ в режимі CD-ROM',
mountFailed: 'Не вдалося змонтувати образ у режимі CD-ROM',
mountDesc:
'У деяких системах необхідно витягнути віртуальний диск на віддаленому хості перед монтуванням файлу образа.',
refresh: 'Оновіть список образів',
tips: {
title: 'Як завантажити',
usb1: "Під'єднайте NanoKVM до вашого комп'ютера через USB.",
@@ -112,7 +125,7 @@ const uk = {
}
},
script: {
title: 'Скрипт',
title: 'Скрипти',
upload: 'Завантажити',
run: 'Запустити',
runBackground: 'Запустити у фоновому режимі',
@@ -131,6 +144,16 @@ const uk = {
serialPort: 'Послідовний порт',
serialPortPlaceholder: 'Будь ласка, введіть послідовний порт',
baudrate: 'Швидкість передачі',
parity: 'Парність',
parityNone: 'Без парності',
parityEven: 'Парний',
parityOdd: 'Непарний',
flowControl: 'Керування потоком',
flowControlNone: 'Без керування',
flowControlSoft: "М'яке керування",
flowControlHard: 'Жорстке керування',
dataBits: 'Біти даних',
stopBits: 'Стопові біти',
confirm: 'Ок'
},
wol: {
@@ -140,71 +163,123 @@ const uk = {
input: 'Будь ласка, введіть MAC',
ok: 'Ок'
},
download: {
title: 'Завантажувач образів',
input: 'Введіть URL-адресу віддаленого образу',
ok: 'Так',
disabled: 'Розділ даних /data у режимі лише для читання, тому ми не можемо завантажити образ'
},
power: {
title: 'Живлення',
showConfirm: 'Підтвердження',
showConfirmTip: 'Енергетичні операції потребують додаткового підтвердження',
reset: 'Скидання',
power: 'Живлення',
powerShort: 'Живлення (коротке натискання)',
powerLong: 'Живлення (довге натискання)'
powerLong: 'Живлення (довге натискання)',
resetConfirm: 'Продовжити роботу зі скиданням?',
powerConfirm: 'Продовжувати роботу живлення?',
okBtn: 'Так',
cancelBtn: 'Ні'
},
settings: {
title: 'Settings',
title: 'Налаштування',
about: {
title: 'Про NanoKVM',
information: 'Інформація',
ip: 'IP',
mdns: 'mDNS',
application: 'Версія додатку',
applicationTip: 'NanoKVM web application version',
application: 'Версія додатка',
applicationTip: 'Версія веб-додатка NanoKVM',
image: 'Версія образу',
imageTip: 'NanoKVM system image version',
imageTip: 'Версія системного образу NanoKVM',
deviceKey: 'Ключ пристрою',
community: 'Спільнота'
community: 'Спільнота',
hostname: "Ім'я вузла",
hostnameUpdated: "Ім'я вузла оновлено. Перезавантажте систему, щоб застосувати зміни.",
ipType: {
Wired: 'Дротове',
Wireless: 'Бездротове',
Other: 'Інший'
}
},
appearance: {
title: 'Appearance',
display: 'Display',
language: 'Language',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar'
title: 'Зовнішній вигляд',
display: 'Відображення',
language: 'Мова',
menuBar: 'Панель меню',
menuBarDesc: 'Піктограми відображення в панелі меню',
webTitle: 'Веб-назва',
webTitleDesc: 'Налаштуйте назву веб-сторінки'
},
device: {
title: 'Device',
title: 'Пристрій',
oled: {
title: 'OLED',
description: 'OLED screen automatically sleep',
0: 'Never',
15: '15 sec',
30: '30 sec',
60: '1 min',
180: '3 min',
300: '5 min',
600: '10 min',
1800: '30 min',
3600: '1 hour'
title: 'OLED-екран',
description: 'Автоматичне відключення OLED-екрана',
0: 'Ніколи',
15: '15 сек',
30: '30 сек',
60: '1 хв',
180: '3 хв',
300: '5 хв',
600: '10 хв',
1800: '30 хв',
3600: '1 година'
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi',
setBtn: 'Config'
description: 'Налаштування Wi-Fi',
setBtn: 'Конфігурація'
},
disk: 'Virtual Disk',
diskDesc: 'Mount virtual U-disk on the remote host',
network: 'Virtual Network',
networkDesc: 'Mount virtual network card on the remote host'
ssh: {
description: 'Увімкніть віддалений доступ по SSH',
tip: 'Перед ввімкненням встановіть надійний пароль (Обліковий запис - Зміна пароля)'
},
tls: {
description: 'Увімкнути протокол HTTPS',
tip: 'Будьте в курсі: Використання HTTPS може збільшити затримку, особливо в режимі відео MJPEG.'
},
advanced: 'Розширені налаштування',
swap: {
title: 'Swap (файл підкачки)',
disable: 'Відключено',
description: 'Встановіть розмір файлу свопу',
tip: "Увімкнення цієї опції може вивести з ладу SD карту!"
},
mouseJiggler: {
title: 'Налаштування пацюка',
description: 'Не дозволяйте віддаленому хосту спати',
disable: 'Відключено',
absolute: 'Абсолютний режим',
relative: 'Відносний режим'
},
mdns: {
description: 'Увімкнути службу MDNS Discovery',
tip: "Вимкнути, якщо це не потрібно"
},
hidOnly: 'Режим лише HID',
disk: 'Віртуальний диск',
diskDesc: 'Монтувати віртуальний U-диск на віддаленому хості',
network: 'Віртуальна мережа',
networkDesc: 'Встановити віртуальну мережеву карту на віддаленому хості',
reboot: 'Перезавантажити',
rebootDesc: 'Ви впевнені, що хочете перезавантажити Nanokvm?',
okBtn: 'Так',
cancelBtn: 'Ні'
},
tailscale: {
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 50MB if using Tailscale. A Tailscale restart is required for the change to take effect.",
disable: 'Disable'
title: "Оптимізація пам'яті",
tip: "Коли використання пам’яті перевищує обмеження, збирання сміття проводиться більш агресивно, щоб спробувати звільнити пам’ять. Рекомендується встановити 50 Мб, якщо використовувати Tailscale. Після зміни налаштувань потрібно перезавантажити Tailscale.",
disable: 'Відключено'
},
restart: 'Are you sure to restart Tailscale?',
stop: 'Are you sure to stop Tailscale?',
stopDesc: 'Log out Tailscale and disable its automatic startup on boot.',
restart: 'Ви впевнені, що бажаєте перезавантажити Tailscale?',
stop: 'Ви впевнені, що бажаєте зупинити Tailscale?',
stopDesc: 'Вийдіть з Tailscale і вимкніть автоматичний запуск при завантаженні.',
loading: 'Завантаження...',
notInstall: 'Tailscale не знайдено! Будь ласка, встановіть клієнт Tailscale.',
notInstall: 'Tailscale не знайдено! Будь ласка, встановіть його.',
install: 'Встановити',
installing: 'Встановлення',
failed: 'Не вдалося встановити',
@@ -212,8 +287,8 @@ const uk = {
download: 'Завантажте',
package: 'пакет встановлення',
unzip: 'та розпакуйте його',
upTailscale: 'Завантажте tailscale до каталогу /usr/bin/ на NanoKVM',
upTailscaled: 'Завантажте tailscaled до каталогу /usr/sbin/ на NanoKVM',
upTailscale: 'Завантажте Tailscale до каталогу /usr/bin/ на NanoKVM',
upTailscaled: 'Завантажте Tailscaled до каталогу /usr/sbin/ на NanoKVM',
refresh: 'Оновіть поточну сторінку',
notLogin:
"Пристрій ще не прив'язаний. Будь ласка, увійдіть і прив'яжіть цей пристрій до вашого облікового запису.",
@@ -225,9 +300,10 @@ const uk = {
deviceIP: 'IP пристрою',
account: 'Обліковий запис',
logout: 'Вийти',
logout2: 'Ви впевнені, що хочете вийти?',
okBtn: 'Yes',
cancelBtn: 'No'
logoutDesc: 'Ви впевнені, що хочете вийти?',
uninstall: 'Видалити Tailscale',
okBtn: 'Так',
cancelBtn: 'Ні'
},
update: {
title: 'Перевірити оновлення',
@@ -237,15 +313,33 @@ const uk = {
available: 'Доступне оновлення. Ви впевнені, що хочете оновити?',
updating: 'Оновлення розпочато. Будь ласка, зачекайте...',
confirm: 'Підтвердити',
cancel: 'Скасувати'
cancel: 'Скасувати',
preview: 'О бета релізах',
previewDesc: 'Отримайте ранній доступ до нових функцій та вдосконалень',
previewTip:
'Будь ласка, майте на увазі, що випуски бета релізів можуть містити помилки або неповну функціональність!'
},
account: {
title: 'Account',
webAccount: 'Web Account Name',
password: 'Password',
updateBtn: 'Update',
logoutBtn: 'Logout'
title: 'Обліковий запис',
webAccount: "Ім'я облыкового запису у веб-інтерфейсі",
password: 'Пароль',
updateBtn: 'Зміна',
logoutBtn: 'Вийти',
logoutDesc: 'Ви впевнені, що хочете вийти?',
okBtn: 'Так',
cancelBtn: 'Ні'
}
},
error: {
title: "Ми зіткнулися з проблемою",
refresh: 'Оновити'
},
fullscreen: {
toggle: 'Перемикатися на повному екрані'
},
menu: {
collapse: 'Згорнути меню',
expand: 'Розгорнути меню'
}
}
};

View File

@@ -46,7 +46,9 @@ const zh = {
finishBtn: '完成'
},
screen: {
title: '屏幕',
video: '视频模式',
videoDirectTips: '该模式需启用 HTTPS,请前往「设置 - 设备」中开启',
resolution: '分辨率',
auto: '自动',
autoTips:
@@ -63,6 +65,7 @@ const zh = {
resetHdmi: '重置 HDMI'
},
keyboard: {
title: '键盘',
paste: '粘贴',
tips: '仅支持标准键盘的字母和符号',
placeholder: '请输入内容',
@@ -82,6 +85,9 @@ const zh = {
mode: '鼠标模式',
absolute: '绝对模式',
relative: '相对模式',
speed: '滚轮速度',
fast: '快',
slow: '慢',
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。',
resetHid: '重置 HID',
hidOnly: {
@@ -98,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 连接到你的电脑;',
@@ -214,6 +227,10 @@ const zh = {
description: '启用 SSH 远程访问',
tip: '启用前请务必设置强密码(帐号 - 修改密码)'
},
tls: {
description: '启用 HTTPS 协议',
tip: '注意:使用 HTTPS 可能导致延迟增加,特别是在 MJPEG 视频模式下。'
},
advanced: '高级设置',
swap: {
disable: '禁用',
@@ -231,6 +248,9 @@ const zh = {
description: '启用 mDNS 发现服务',
tip: '如果您未使用此功能,建议将其关闭'
},
hdmi: {
description: '启用 HDMI/显示器 输出功能'
},
hidOnly: 'HID-Only 模式',
disk: '虚拟U盘',
diskDesc: '在远程主机中挂载虚拟U盘',
@@ -243,8 +263,11 @@ const zh = {
title: 'Tailscale',
memory: {
title: '内存优化',
tip: '当内存占用超过限制时,会更积极地执行垃圾回收来尝试释放内存。如果使用 Tailscale 推荐设置为 50MB,需重启 Tailscale 后生效。',
disable: '关闭'
tip: '当内存占用超过限制时,会更积极地执行垃圾回收来尝试释放内存。需重启 Tailscale 后生效。'
},
swap: {
title: '交换内存',
tip: '如果启用内存优化后依然存在问题,可以尝试开启交换内存。启用后会将交换文件设置为256MB,可以在「设置 - 设备」中修改该选项。'
},
restart: '取定要重启 Tailscale 吗?',
stop: '确定要停止 Tailscale 吗?',
@@ -261,6 +284,8 @@ const zh = {
upTailscale: '将 tailscale 文件上传到 /usr/bin/ 目录',
upTailscaled: '将 tailscaled 文件上传到 /usr/sbin/ 目录',
refresh: '刷新页面',
notRunning: 'Tailscale 尚未运行,请先执行启动操作',
run: '启动',
notLogin: '该设备尚未绑定,请点击登录并将这台设备绑定到您的账号。',
urlPeriod: '该链接10分钟内有效',
login: '登录',
@@ -272,6 +297,9 @@ const zh = {
logout: '退出',
logoutDesc: '确定要退出吗?',
uninstall: '卸载 Tailscale',
uninstallDesc: '确定要卸载 Tailscale 吗?',
reboot: '重启',
rebootDesc: '你确定要重启 NanoKVM 吗?',
okBtn: '确认',
cancelBtn: '取消'
},

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

@@ -6,5 +6,8 @@ export const mouseStyleAtom = atom('cursor-default');
// mouse mode: absolute or relative
export const mouseModeAtom = atom('absolute');
// mouse scroll interval (unit: ms)
export const scrollIntervalAtom = atom(0);
// hid mode: normal or hid-only
export const hidModeAtom = atom<'normal' | 'hid-only'>('normal');

View File

@@ -2,7 +2,12 @@ import { atom } from 'jotai';
import { Resolution } from '@/types';
// video mode: h264 or mjpeg
export const isHdmiEnabledAtom = atom(true);
// video mode
// direct: stream H.264 over HTTP
// h264: stream H.264 over WebRTC
// mjpeg: stream JPEG over HTTP
export const videoModeAtom = atom('');
// browser screen resolution

View File

@@ -9,8 +9,10 @@ const GOP_KEY = 'nano-kvm-gop';
const FRAME_DETECT_KEY = 'nano-kvm-frame-detect';
const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style';
const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode';
const MOUSE_SCROLL_INTERVAL_KEY = 'nanokvm-kvm-mouse-scroll-interval';
const SKIP_UPDATE_KEY = 'nano-kvm-check-update';
const KEYBOARD_LAYOUT_KEY = 'nano-kvm-keyboard-layout';
const KEYBOARD_SYSTEM_KEY = 'nano-kvm-keyboard-system';
const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language';
const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password';
const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items';
const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm';
@@ -106,7 +108,7 @@ export function setGop(gop: number) {
export function getFrameDetect(): boolean {
const enabled = localStorage.getItem(FRAME_DETECT_KEY);
return enabled ? Boolean(enabled) : true;
return enabled === 'true';
}
export function setFrameDetect(enabled: boolean) {
@@ -129,9 +131,18 @@ export function setMouseMode(mouse: string) {
localStorage.setItem(MOUSE_MODE_KEY, mouse);
}
export function getMouseScrollInterval() {
const interval = localStorage.getItem(MOUSE_SCROLL_INTERVAL_KEY);
return interval ? Number(interval) : null;
}
export function setMouseScrollInterval(interval: number): void {
localStorage.setItem(MOUSE_SCROLL_INTERVAL_KEY, String(interval));
}
export function getSkipUpdate() {
const skip = getWithExpiry(SKIP_UPDATE_KEY);
return skip ? Boolean(skip) : false;
return skip === 'true';
}
export function setSkipUpdate(skip: boolean) {
@@ -139,12 +150,20 @@ export function setSkipUpdate(skip: boolean) {
setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry);
}
export function setKeyboardLayout(layout: string) {
localStorage.setItem(KEYBOARD_LAYOUT_KEY, layout);
export function setKeyboardSystem(system: string) {
localStorage.setItem(KEYBOARD_SYSTEM_KEY, system);
}
export function getKeyboardLayout() {
return localStorage.getItem(KEYBOARD_LAYOUT_KEY);
export function getKeyboardSystem() {
return localStorage.getItem(KEYBOARD_SYSTEM_KEY);
}
export function setKeyboardLanguage(language: string) {
localStorage.setItem(KEYBOARD_LANGUAGE_KEY, language);
}
export function getKeyboardLanguage() {
return localStorage.getItem(KEYBOARD_LANGUAGE_KEY);
}
export function setSkipModifyPassword(skip: boolean) {
@@ -154,7 +173,7 @@ export function setSkipModifyPassword(skip: boolean) {
export function getSkipModifyPassword() {
const skip = getWithExpiry(SKIP_MODIFY_PASSWORD_KEY);
return skip ? Boolean(skip) : false;
return skip === 'true';
}
export function setMenuDisabledItems(items: string[]) {
@@ -169,7 +188,7 @@ export function getMenuDisabledItems(): string[] {
export function getPowerConfirm() {
const enabled = localStorage.getItem(POWER_CONFIRM_KEY);
return enabled ? Boolean(enabled) : false;
return enabled === 'true';
}
export function setPowerConfirm(enabled: boolean) {

View File

@@ -1,14 +1,12 @@
import { useEffect } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { useMediaQuery } from 'react-responsive';
import { getWebTitle } from '@/api/vm.ts';
import { getResolution, getVideoMode } from '@/lib/localstorage.ts';
import * as storage from '@/lib/localstorage.ts';
import { client } from '@/lib/websocket.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
import { webTitleAtom } from '@/jotai/settings.ts';
import { Head } from '@/components/head.tsx';
import { Keyboard } from './keyboard';
@@ -25,20 +23,13 @@ export const Desktop = () => {
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
const [resolution, setResolution] = useAtom(resolutionAtom);
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
const setWebTitle = useSetAtom(webTitleAtom);
useEffect(() => {
const cookieVideoMode = getVideoMode();
setVideoMode(cookieVideoMode ? cookieVideoMode : window.RTCPeerConnection ? 'h264' : 'mjpeg');
const mode = getVideoMode();
setVideoMode(mode);
const cookieResolution = getResolution();
setResolution(cookieResolution ? cookieResolution : { width: 0, height: 0 });
getWebTitle().then((rsp) => {
if (rsp.data?.title) {
setWebTitle(rsp.data.title);
}
});
const res = storage.getResolution() || { width: 0, height: 0 };
setResolution(res);
const timer = setInterval(() => {
client.send([0]);
@@ -51,6 +42,20 @@ export const Desktop = () => {
};
}, []);
function getVideoMode() {
const defaultVideoMode = window.RTCPeerConnection ? 'h264' : 'mjpeg';
const cookieVideoMode = storage.getVideoMode();
if (cookieVideoMode) {
if (cookieVideoMode === 'direct' && !window.VideoDecoder) {
return defaultVideoMode;
}
return cookieVideoMode;
}
return defaultVideoMode;
}
return (
<>
<Head title={t('head.desktop')} />

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

@@ -26,6 +26,42 @@ export const KeyboardCodes: Map<string, number> = new Map([
['KeyY', 28],
['KeyZ', 29],
['RusA', 4],
['RusB', 5],
['RusC', 6],
['RusD', 7],
['RusE', 8],
['RusF', 9],
['RusG', 10],
['RusH', 11],
['RusI', 12],
['RusJ', 13],
['RusK', 14],
['RusL', 15],
['RusM', 16],
['RusN', 17],
['RusO', 18],
['RusP', 19],
['RusQ', 20],
['RusR', 21],
['RusS', 22],
['RusT', 23],
['RusU', 24],
['RusV', 25],
['RusW', 26],
['RusX', 27],
['RusY', 28],
['RusZ', 29],
['RusBracketLeft', 47],
['RusBracketRight', 48],
['RusBackslash', 49],
['RusSemicolon', 51],
['RusQuote', 52],
['RusComma', 54],
['RusPeriod', 55],
['RusSlash', 56],
['Digit1', 30],
['Digit2', 31],
['Digit3', 32],
@@ -48,6 +84,8 @@ export const KeyboardCodes: Map<string, number> = new Map([
['BracketRight', 48],
['Backslash', 49],
['IntlBackslash', 49],
['Backquote_azerty', 100],
['IntlBackslash_qwertz', 100],
['Semicolon', 51],
['Quote', 52],

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { AppleOutlined, WindowsOutlined } from '@ant-design/icons';
import { ConfigProvider, Segmented, theme } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { XIcon } from 'lucide-react';
@@ -10,9 +9,10 @@ import { Drawer } from 'vaul';
import 'react-simple-keyboard/build/css/index.css';
import '@/assets/styles/keyboard.css';
import { ConfigProvider, Segmented, Select, theme } from 'antd';
import { useMediaQuery } from 'react-responsive';
import { getKeyboardLayout, setKeyboardLayout } from '@/lib/localstorage.ts';
import * as storage from '@/lib/localstorage.ts';
import { client } from '@/lib/websocket.ts';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
@@ -31,18 +31,58 @@ export const VirtualKeyboard = () => {
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom);
const [layout, setLayout] = useState('default');
const [keyboardLayout, setKeyboardLayout] = useState('default');
const [keyboardSystem, setKeyboardSystem] = useState('win');
const [keyboardLanguage, setKeyboardLanguage] = useState('en');
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([]);
const keyboardRef = useRef<any>(null);
const systems = [
{ value: 'win', icon: <WindowsOutlined /> },
{ value: 'mac', icon: <AppleOutlined /> }
];
const languages = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'Deutsch' },
{ value: 'ru', label: 'Russian' }
];
useEffect(() => {
const keyboardLayout = getKeyboardLayout();
if (keyboardLayout && ['default', 'mac'].includes(keyboardLayout)) {
setLayout(keyboardLayout);
const system = storage.getKeyboardSystem();
if (system && ['win', 'mac'].includes(system)) {
setKeyboardSystem(system);
}
const language = storage.getKeyboardLanguage();
if (language && languages.some((lng) => lng.value === language)) {
setKeyboardLanguage(language);
}
}, []);
useEffect(() => {
const layoutMap = new Map([
['en', 'default'],
['ru', 'rus'],
['de', 'qwertz'],
['fr', 'azerty']
]);
if (keyboardLanguage === 'en' && keyboardSystem === 'mac') {
setKeyboardLayout('mac');
return;
}
if (layoutMap.has(keyboardLanguage)) {
setKeyboardLayout(layoutMap.get(keyboardLanguage)!);
return;
}
setKeyboardLayout('default');
}, [keyboardSystem, keyboardLanguage]);
function onKeyPress(key: string) {
if (modifierKeys.includes(key)) {
if (activeModifierKeys.includes(key)) {
@@ -66,8 +106,7 @@ export const VirtualKeyboard = () => {
}
function sendKeydown(key: string) {
const specialKey = specialKeyMap.get(key);
const code = KeyboardCodes.get(specialKey ? specialKey : key);
const code = getKeyCode(key);
if (!code) {
console.log('unknown code: ', key);
return;
@@ -78,6 +117,37 @@ export const VirtualKeyboard = () => {
client.send([1, code, ...modifiers]);
}
function getKeyCode(key: string) {
// AZERTY: swap A↔Q and Z↔W on French physical positions
if (keyboardLanguage === 'fr' && key.endsWith('_azerty')) {
const base = key.replace('_azerty', '');
if (base === 'KeyA') return KeyboardCodes.get('KeyQ');
if (base === 'KeyQ') return KeyboardCodes.get('KeyA');
if (base === 'KeyZ') return KeyboardCodes.get('KeyW');
if (base === 'KeyW') return KeyboardCodes.get('KeyZ');
// all other labels use their own code
return KeyboardCodes.get(base);
}
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) {
return KeyboardCodes.get(specialKey);
}
return KeyboardCodes.get(key);
}
function sendKeyup() {
sendModifierKeyUp();
client.send([1, 0, 0, 0, 0, 0]);
@@ -121,6 +191,16 @@ export const VirtualKeyboard = () => {
setActiveModifierKeys([]);
}
function selectSystem(system: string) {
setKeyboardSystem(system);
storage.setKeyboardSystem(system);
}
function selectLanguage(language: string) {
setKeyboardLanguage(language);
storage.setKeyboardLanguage(language);
}
function getButtonTheme(): KeyboardButtonTheme[] {
const theme = [{ class: 'hg-double', buttons: doubleKeys.join(' ') }];
@@ -132,11 +212,6 @@ export const VirtualKeyboard = () => {
return theme;
}
function selectLayout(value: string) {
setKeyboardLayout(value);
setLayout(value);
}
return (
<Drawer.Root open={isKeyboardOpen} onOpenChange={setIsKeyboardOpen} modal={false}>
<Drawer.Portal>
@@ -148,22 +223,30 @@ export const VirtualKeyboard = () => {
>
{/* header */}
<div className="flex items-center justify-between px-3 py-1">
<div className="flex flex-row">
<ConfigProvider
theme={{
algorithm: theme.defaultAlgorithm
}}
>
<Segmented
options={[
{ label: 'Win', value: 'default', icon: <WindowsOutlined /> },
{ label: 'Mac', value: 'mac', icon: <AppleOutlined /> }
]}
value={layout}
onChange={selectLayout}
<ConfigProvider
theme={{
algorithm: theme.defaultAlgorithm
}}
>
<div className="flex items-center space-x-5">
<Select
size="small"
style={{ minWidth: 90 }}
defaultValue={keyboardLanguage}
options={languages}
onChange={selectLanguage}
/>
</ConfigProvider>
</div>
{keyboardLanguage === 'en' && (
<Segmented
size="small"
options={systems}
value={keyboardSystem}
onChange={selectSystem}
/>
)}
</div>
</ConfigProvider>
<div className="flex w-[100px] items-center justify-end">
<div
@@ -184,7 +267,7 @@ export const VirtualKeyboard = () => {
keyboardRef={(r) => (keyboardRef.current = r)}
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
layoutName={layout}
layoutName={keyboardLayout}
{...keyboardOptions}
/>

View File

@@ -1,3 +1,5 @@
// TODO: refactor it
// main keys
export const keyboardOptions = {
theme: 'simple-keyboard hg-theme-default',
@@ -18,6 +20,30 @@ export const keyboardOptions = {
'{capslock} KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote {enter}',
'{shiftleft} KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash {shiftright}',
'{controlleft} {altleft} {metaleft} {space} {metaright} {altright}'
],
rus: [
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal {backspace}',
'{tab} RusQ RusW RusE RusR RusT RusY RusU RusI RusO RusP RusBracketLeft RusBracketRight RusBackslash',
'{capslock} RusA RusS RusD RusF RusG RusH RusJ RusK RusL RusSemicolon RusQuote {enter}',
'{shiftleft} RusZ RusX RusC RusV RusB RusN RusM RusComma RusPeriod RusSlash {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
],
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}',
'{tab} KeyA_azerty KeyZ_azerty KeyE_azerty KeyR_azerty KeyT_azerty KeyY_azerty KeyU_azerty KeyI_azerty KeyO_azerty KeyP_azerty BracketLeft_azerty BracketRight_azerty Backslash_azerty',
'{capslock} KeyQ_azerty KeyS_azerty KeyD_azerty KeyF_azerty KeyG_azerty KeyH_azerty KeyJ_azerty KeyK_azerty KeyL_azerty Semicolon_azerty Quote_azerty {enter}',
'{shiftleft} KeyW_azerty KeyX_azerty KeyC_azerty KeyV_azerty KeyB_azerty KeyN_azerty KeyM_azerty Comma_azerty Period_azerty Slash_azerty {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
]
},
display: {
@@ -37,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',
@@ -88,8 +147,109 @@ export const keyboardOptions = {
'{winright}': 'Win',
'{altright}': 'Alt',
'{menu}': 'Menu',
'{controlright}': 'Ctrl'
'{controlright}': 'Ctrl',
RusQ: 'Й',
RusW: 'Ц',
RusE: 'У',
RusR: 'К',
RusT: 'Е',
RusY: 'Н',
RusU: 'Г',
RusI: 'Ш',
RusO: 'Щ',
RusP: 'З',
RusBracketLeft: 'Х',
RusBracketRight: 'Ъ',
RusBackslash: '/<br>\\',
RusA: 'Ф',
RusS: 'Ы',
RusD: 'В',
RusF: 'А',
RusG: 'П',
RusH: 'Р',
RusJ: 'О',
RusK: 'Л',
RusL: 'Д',
RusSemicolon: 'Ж',
RusQuote: 'Э',
RusZ: 'Я',
RusX: 'Ч',
RusC: 'С',
RusV: 'М',
RusB: 'И',
RusN: 'Т',
RusM: 'Ь',
RusComma: 'Б',
RusPeriod: 'Ю',
RusSlash: ',<br/>.',
// AZERTY specific display keys
// Row 1
Backquote_azerty: '&#60;<br/>&#62;',
Digit1_azerty: '&<br/>1',
Digit2_azerty: 'é<br/>2',
Digit3_azerty: '"<br/>#',
Digit4_azerty: "'<br/>{",
Digit5_azerty: '(<br/>[',
Digit6_azerty: '-<br/>|',
Digit7_azerty: 'è<br/>`',
Digit8_azerty: '_<br/>\\',
Digit9_azerty: 'ç<br/>^',
Digit0_azerty: 'à<br/>@',
Minus_azerty: ')<br/>]',
Equal_azerty: '=<br/>}',
// Row 2
KeyA_azerty: 'A',
KeyZ_azerty: 'Z',
KeyE_azerty: 'E<br/>€',
KeyR_azerty: 'R',
KeyT_azerty: 'T',
KeyY_azerty: 'Y',
KeyU_azerty: 'U',
KeyI_azerty: 'I',
KeyO_azerty: 'O',
KeyP_azerty: 'P',
BracketLeft_azerty: '¨<br/>^',
BracketRight_azerty: '£<br/>$',
Backslash_azerty: 'µ<br/>*',
// Row 3
KeyQ_azerty: 'Q',
KeyS_azerty: 'S',
KeyD_azerty: 'D',
KeyF_azerty: 'F',
KeyG_azerty: 'G',
KeyH_azerty: 'H',
KeyJ_azerty: 'J',
KeyK_azerty: 'K',
KeyL_azerty: 'L',
Semicolon_azerty: 'M',
Quote_azerty: '%<br/>ù',
// Row 4
KeyW_azerty: 'W',
KeyX_azerty: 'X',
KeyC_azerty: 'C',
KeyV_azerty: 'V',
KeyB_azerty: 'B',
KeyN_azerty: 'N',
KeyM_azerty: '?<br/>,',
Comma_azerty: '.<br/>;',
Period_azerty: '/<br/>:',
Slash_azerty: '§<br/>!'
},
// Enable layout-specific display
mergeDisplay: true,
layoutCandidates: {
default: 'default',
shift: 'shift',
azerty: 'azerty'
}
// ...remaining options...
};
// control keys
@@ -156,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
@@ -170,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

@@ -44,7 +44,7 @@ export const Cursor = () => {
);
return (
<Popover content={content} placement="rightTop" arrow={true} trigger="hover">
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
<MousePointerIcon size={18} />
<span>{t('mouse.cursor')}</span>

View File

@@ -5,18 +5,21 @@ import { MouseIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as ls from '@/lib/localstorage';
import { mouseModeAtom, mouseStyleAtom } from '@/jotai/mouse';
import { mouseModeAtom, mouseStyleAtom, scrollIntervalAtom } from '@/jotai/mouse';
import { MenuItem } from '@/components/menu-item.tsx';
import { Cursor } from './cursor.tsx';
import { HidMode } from './hid-mode.tsx';
import { MouseMode } from './mouse-mode.tsx';
import { ResetHid } from './reset-hid.tsx';
import { Speed } from './speed.tsx';
export const Mouse = () => {
const { t } = useTranslation();
const setMouseStyle = useSetAtom(mouseStyleAtom);
const setMouseMode = useSetAtom(mouseModeAtom);
const setScrollInterval = useSetAtom(scrollIntervalAtom);
useEffect(() => {
const mouseStyle = ls.getMouseStyle();
@@ -28,12 +31,18 @@ export const Mouse = () => {
if (mouseMode) {
setMouseMode(mouseMode);
}
const interval = ls.getMouseScrollInterval();
if (interval) {
setScrollInterval(interval);
}
}, []);
const content = (
<div className="flex flex-col space-y-1">
<Cursor />
<MouseMode />
<Speed />
<Divider style={{ margin: '10px 0' }} />
<HidMode />

View File

@@ -47,7 +47,7 @@ export const MouseMode = () => {
);
return (
<Popover content={content} placement="rightTop" arrow={true} trigger="hover">
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
<SquareDashedMousePointerIcon size={18} />
<span>{t('mouse.mode')}</span>

View File

@@ -15,6 +15,7 @@ export const ResetHid = () => {
if (isResetting) return;
setIsResetting(true);
client.send([1, 0, 0, 0, 0, 0]);
client.close();
api.reset().finally(() => {

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { Popover, Slider } from 'antd';
import { useAtom } from 'jotai';
import { GaugeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as storage from '@/lib/localstorage.ts';
import { scrollIntervalAtom } from '@/jotai/mouse.ts';
const MAX_INTERVAL = 300;
export const Speed = () => {
const { t } = useTranslation();
const [scrollInterval, setScrollInterval] = useAtom(scrollIntervalAtom);
const [scrollSpeed, setScrollSpeed] = useState(100);
useEffect(() => {
const speed = interval2Speed(scrollInterval);
setScrollSpeed(speed);
}, [scrollInterval]);
function update(speed: number): void {
const interval = speed2Interval(speed);
setScrollInterval(interval);
storage.setMouseScrollInterval(interval);
}
function interval2Speed(interval: number) {
if (interval === MAX_INTERVAL) {
return 0;
}
return ((MAX_INTERVAL - interval) * 100) / MAX_INTERVAL;
}
function speed2Interval(speed: number) {
return MAX_INTERVAL - speed * (MAX_INTERVAL / 100);
}
const content = (
<div className="h-[150px] w-[60px] py-3">
<Slider
vertical
marks={{
0: <span>{t('mouse.slow')}</span>,
100: <span>{t('mouse.fast')}</span>
}}
range={false}
included={false}
step={10}
defaultValue={scrollSpeed}
onChange={update}
/>
</div>
);
return (
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [14, 0] }}>
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
<GaugeIcon size={18} />
<span>{t('mouse.speed')}</span>
</div>
</Popover>
);
};

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