Compare commits

...

99 Commits
2.3.0 ... 2.3.1

Author SHA1 Message Date
wj-xiao
cc072ab158 release: nanokvm@2.3.1 2025-12-26 19:12:33 +08:00
wenjie
b0a01a9837 Merge pull request #611 from lowtech-guy/main
Add user-friendly build system design
2025-12-26 18:27:02 +08:00
wj-xiao
2d575b916c feat:support setting mouse wheel direction 2025-12-26 16:44:12 +08:00
wj-xiao
1f908ef181 fix: add the restart_phy command 2025-12-26 16:11:28 +08:00
wj-xiao
fb2adf13ee refactor: refactor offline update function 2025-12-26 15:38:05 +08:00
wj-xiao
ad9eb44ad8 fix: web terminal may not close properly 2025-12-26 10:53:21 +08:00
wj-xiao
b4962e7a58 fix: custom port might not work 2025-12-26 10:49:29 +08:00
wj-xiao
14614e5e35 ui: optimize menu bar ui 2025-12-26 10:22:59 +08:00
wj-xiao
9a93ab24f5 refactor: refactor virtual devices and HID 2025-12-26 10:06:17 +08:00
wj-xiao
aaad552633 refactor: refactor the Wi-Fi module 2025-12-24 16:48:24 +08:00
wenjie
79cc1cc2bf Merge pull request #692 from Alexander-Ger-Reich/main
Upload lokal ISO files and Offline Updater
2025-12-24 09:48:02 +08:00
wenjie
01bcda6886 Merge pull request #698 from deftdawg/oled_sleep_param-fix
Fix oled_sleep_param only retaining lowest 8-bits of value
2025-12-23 16:13:09 +08:00
wenjie
ec49bffe06 Merge pull request #687 from Deses/ES-lang-update
Updated Spanish translation with all missing strings
2025-12-23 15:32:14 +08:00
wenjie
2db5d86a3c Merge pull request #686 from pekishev/paste_rus
Add support for pasting russian text
2025-12-23 15:31:06 +08:00
wenjie
da6d4840f8 Merge pull request #685 from imguoguo/add-scale
feat: add video scale
2025-12-23 15:19:02 +08:00
wenjie
9afa21a0b0 Merge pull request #679 from acidflash/main
Add missing variables from last version
2025-12-23 15:17:35 +08:00
wenjie
6168069301 Merge pull request #674 from kmw0410/main
update korean translation
2025-12-23 15:01:16 +08:00
wenjie
ffe71ae6b1 Merge pull request #673 from imguoguo/add-autostart-upstream
feat: add autostart feature
2025-12-23 14:59:28 +08:00
wj-xiao
a6a97df4e7 Update NanoKVM-PCIe schematic 2025-12-19 14:21:33 +08:00
deftdawg
f05467f45c Fix oled_sleep_param only retaining lowest 8-bits of value
providing oled_sleep_param with a 16-bit value (i.e. >256), results unpredictable display sleep times as only the low 8-bits are retained due to uint8_t (i.e. 5 minutes 300 seconds; shuts off in ~44s /  300s - 256s), this patch switches to uint16_t which should allow for up to sleeps of up to 65535 seconds.
2025-12-18 16:39:58 -05:00
Alexander-Ger-Reich
969e3a5e6a Update update.go 2025-12-13 16:00:09 +01:00
Alexander-Ger-Reich
f0e7dc069c Update update.go 2025-12-13 15:55:10 +01:00
Alexander-Ger-Reich
f5f70bd015 Update update.go 2025-12-13 15:46:07 +01:00
Alexander-Ger-Reich
aa80e1d7cc Update index.tsx 2025-12-13 15:45:23 +01:00
Alexander-Ger-Reich
d6b03a345b Update en.ts 2025-12-13 15:44:46 +01:00
Alexander-Ger-Reich
c0cfcfa31a Update de.ts 2025-12-13 15:44:30 +01:00
Alexander-Ger-Reich
afdbb179ad Update update.go 2025-12-13 15:37:44 +01:00
Alexander-Ger-Reich
ed9b7c5126 Update index.tsx 2025-12-13 15:37:17 +01:00
Alexander-Ger-Reich
551329a41d Update en.ts 2025-12-13 15:36:52 +01:00
Alexander-Ger-Reich
b3c6b6c53d Update de.ts 2025-12-13 15:36:39 +01:00
Alexander-Ger-Reich
5a9cd18f94 Update en.ts 2025-12-13 15:36:28 +01:00
Alexander-Ger-Reich
63c8fc8eb5 Update en.ts 2025-12-13 15:33:07 +01:00
Alexander-Ger-Reich
3305c69e2d Update de.ts 2025-12-13 15:32:55 +01:00
Alexander-Ger-Reich
56a0468a53 Update index.tsx 2025-12-13 14:55:25 +01:00
Alexander-Ger-Reich
52416dfdd7 Update application.go 2025-12-13 14:54:20 +01:00
Alexander-Ger-Reich
bcd0b717ee Update application.go 2025-12-13 14:54:03 +01:00
Alexander-Ger-Reich
d9c9eae4f9 Create index.tsx 2025-12-13 14:46:26 +01:00
Alexander-Ger-Reich
c1a21c2d69 Update service.go 2025-12-13 14:44:42 +01:00
Alexander-Ger-Reich
8d822948cf Update en.ts 2025-12-13 14:43:44 +01:00
Alexander-Ger-Reich
7404a205a5 Update de.ts 2025-12-13 14:43:29 +01:00
Alexander-Ger-Reich
d774bf8c58 Update download.tsx 2025-12-13 14:42:55 +01:00
Alexander-Ger-Reich
0e1fae8751 Update application.ts 2025-12-13 10:53:28 +01:00
Alexander-Ger-Reich
3ff64483df Update application.go 2025-12-13 10:51:36 +01:00
Alexander-Ger-Reich
fed92254d2 Update application.go 2025-12-13 10:50:09 +01:00
Alexander-Ger-Reich
dec1621850 Update version.go 2025-12-13 10:49:15 +01:00
Alexander-Ger-Reich
116bb50364 Update service.go 2025-12-13 10:04:10 +01:00
wj-xiao
957d86d64c update README 2025-12-12 11:59:04 +08:00
Alexander-Ger-Reich
1fa6f48d4e Update service.go 2025-12-11 23:17:57 +01:00
Alexander-Ger-Reich
0610f6fd1a Update download.tsx 2025-12-11 23:17:33 +01:00
Alexander-Ger-Reich
ade9ac863f Update de.ts 2025-12-11 22:40:30 +01:00
Alexander-Ger-Reich
61ff3c5b23 Update en.ts 2025-12-11 22:40:16 +01:00
Alexander-Ger-Reich
19076b2508 Update en.ts 2025-12-11 00:07:29 +01:00
Alexander-Ger-Reich
e175147674 Update de.ts 2025-12-11 00:06:54 +01:00
Alexander-Ger-Reich
2cc64d2208 Update download.tsx 2025-12-11 00:03:56 +01:00
Alexander-Ger-Reich
346315b4bb Update download.tsx 2025-12-11 00:03:26 +01:00
Alexander-Ger-Reich
648c9f866e Update service.go 2025-12-11 00:02:01 +01:00
Alexander-Ger-Reich
b5866088fc Update download.tsx 2025-12-11 00:01:27 +01:00
Alexander-Ger-Reich
cd954b6f24 Update download.go 2025-12-10 19:27:20 +01:00
Alexander-Ger-Reich
f27102d344 Update download.tsx 2025-12-10 00:25:33 +01:00
Alexander-Ger-Reich
ba8e8b7a5c Merge pull request #5 from Alexander-Ger-Reich/patch-6
Update en.ts
2025-12-10 00:24:25 +01:00
Alexander-Ger-Reich
cb3834bb71 Update en.ts 2025-12-10 00:24:04 +01:00
Alexander-Ger-Reich
8ec134c182 Merge pull request #4 from Alexander-Ger-Reich/patch-4
Update de.ts
2025-12-10 00:22:42 +01:00
Alexander-Ger-Reich
631d944d70 Merge pull request #3 from Alexander-Ger-Reich/patch-1
Update download.tsx
2025-12-10 00:21:40 +01:00
Alexander-Ger-Reich
f539576a49 Merge branch 'main' into patch-1 2025-12-10 00:21:30 +01:00
Alexander-Ger-Reich
fa11347f1b Merge pull request #2 from Alexander-Ger-Reich/patch-2
Update download.tsx
2025-12-10 00:18:55 +01:00
Alexander-Ger-Reich
34c66dbd8b Merge pull request #1 from Alexander-Ger-Reich/patch-5
Update en.ts
2025-12-10 00:18:21 +01:00
Alexander-Ger-Reich
3d5c14405c Update en.ts 2025-12-10 00:10:29 +01:00
Alexander-Ger-Reich
9e4408409f Update de.ts 2025-12-10 00:10:04 +01:00
Alexander-Ger-Reich
9b629c8dc0 Update download.tsx 2025-12-09 23:49:34 +01:00
Alexander-Ger-Reich
8da7a70bfc Update download.tsx 2025-12-09 23:23:40 +01:00
Deses
ea6ac3c903 Updated Spanish translation with all missing strings (compared with English) and fixed lots of little gramatical errors. 2025-12-09 05:04:13 +01:00
Yury Pekishev
6b783677e5 Use Dropdown with languages at paste
Fix translation
2025-12-09 01:31:02 +03:00
Yury Pekishev
1cc31c30a3 revert invisible change 2025-12-09 00:56:52 +03:00
Yury Pekishev
92d5f63679 Merge branch 'main' into paste_rus
# Conflicts:
#	web/src/pages/desktop/menu/keyboard/paste.tsx
2025-12-09 00:50:09 +03:00
Yury Pekishev
9fd3728aee Merge branch 'main' of https://github.com/pekishev/NanoKVM
# Conflicts:
#	web/src/pages/desktop/menu/keyboard/paste.tsx
2025-12-09 00:46:28 +03:00
Yury Pekishev
916803d0f0 Merge branch 'main' into paste_rus 2025-12-08 23:56:46 +03:00
Guoguo
8de298d245 feat: add video scale
Patch from https://github.com/sipeed/NanoKVM-USB/pull/50
2025-12-08 19:17:35 +08:00
wenjie
75fc8d09ce Merge pull request #681 from scpcom/fix-virtual-media-split-merge
S03usbdev: fix virtual media split merge
2025-12-02 09:32:14 +08:00
scpcom
b157b9fe28 S03usbdev: fix virtual media split merge 2025-12-01 23:34:17 +01:00
wenjie
53a800d252 Merge pull request #530 from tpretz/usb-disable
Add setting for disabling the usb-gadget
2025-12-01 17:51:35 +08:00
wenjie
124868e093 Merge branch 'main' into usb-disable 2025-12-01 17:51:28 +08:00
Thomas Pressnell
e684936d15 server side for full gadget disable
starting on frontend

# Conflicts:
#	kvmapp/system/init.d/S03usbdev
2025-12-01 17:26:41 +08:00
wenjie
5958fd5c6f Merge pull request #489 from tpretz/virtual-media-split
Virtual Media Changes
2025-12-01 15:30:02 +08:00
wenjie
4d7ee39ccf Merge branch 'main' into virtual-media-split 2025-12-01 15:28:50 +08:00
Jonas Byström
1bb91518b6 Add missing variables from last version 2025-11-30 21:19:55 +01:00
wj-xiao
2d0123cb18 Update README 2025-11-28 19:02:08 +08:00
kmw_
455bc19fab fix typo in ummountDesc 2025-11-27 21:11:08 +09:00
wj-xiao
0003be478e Fix Tailscale startup script
- The device no longer automatically switches to router mode
- Resolved IPv6 connectivity issues
2025-11-27 10:13:22 +08:00
kmw0410
d448f11861 update korean translation 2025-11-26 21:53:26 +09:00
Guoguo
53924dc8ef feat: add autostart feature 2025-11-26 15:58:19 +08:00
Yury Pekishev
0d1dca3e0a translateRuToEnWithPunctuation 2025-10-30 14:40:59 +03:00
Low Tech
dda98e2a61 Merge branch 'sipeed:main' into main 2025-09-20 16:07:25 +08:00
lowtech-guy
e8016c1d51 remove: comment the cache server line 2025-08-26 05:58:34 +08:00
lowtech-guy
c1e2bed94e add: add build system design for human friendly 2025-08-26 03:40:51 +08:00
Thomas Pressnell
393328eaf6 Revert "cleanup and simplification" and adding reset flag
This reverts commit 7771d1f326.
2025-05-26 07:47:48 +01:00
Thomas Pressnell
7771d1f326 cleanup and simplification 2025-05-25 21:44:53 +01:00
Thomas Pressnell
2a34eb55e6 Update image.go
set imageNone to newline
2025-05-25 19:22:58 +01:00
Thomas Pressnell
9a0c77bb8b server side for full gadget disable
starting on frontend
2025-05-24 16:28:52 +01:00
Thomas Pressnell
a1df6376ec split /data attachment from kvm media attachment
backend config for media control

remove .disabled

adding settings toggle

add default text
2025-05-24 13:29:35 +01:00
98 changed files with 3091 additions and 1138 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
Dockerfile
!server/go.mod
server/go.sum
server/NanoKVM-Server
web
support/sg2002/kvm_system/build
support/sg2002/kvm_system/dist
support/sg2002/kvm_system/CMakeLists.txt
support/sg2002/kvm_vision_test/build
support/sg2002/kvm_vision_test/dist
support/sg2002/kvm_vision_test/CMakeLists.txt

View File

@@ -1,3 +1,33 @@
## 2.3.1 (2025-12-26)
### Features
* Added support for offline application updates (thanks to [@Alexander-Ger-Reich](https://github.com/Alexander-Ger-Reich))
* Implemented support for uploading ISO images (thanks to [@Alexander-Ger-Reich](https://github.com/Alexander-Ger-Reich))
* Extended clipboard compatibility to support Russian characters (thanks to [@pekishev](https://github.com/pekishev))
* Added configuration options for video scaling
* Added support for configuring mouse scroll wheel direction
### Bug Fixes
* Resolved an issue where custom port may not be accessible
* Fixed a bug where Web Terminal and Serial Terminal may not disconnect when the page is closed
### Security
* Enhanced validation logic on the Wi-Fi configuration page. Requests are now rejected when the device is not in AP mode to prevent unauthorized changes
### Chore
* Added a Docker image environment to support building `libkvm.so` and `NanoKVM-Server` (thanks to [@lowtech-guy](https://github.com/lowtech-guy))
* Optimized the Settings UI
### Localization
* Updated Korean translation (thanks to [@kmw0410](https://github.com/kmw0410))
* Updated Swedish translation (thanks to [@acidflash](https://github.com/acidflash))
* Updated Spanish translation (thanks to [@Deses](https://github.com/Deses))
## 2.3.0 [0e30a26](https://github.com/sipeed/NanoKVM/commit/0e30a26db42cbc08416662b49a88a5d16ad93424) (2025-11-26)
### Features

97
Makefile Normal file
View File

@@ -0,0 +1,97 @@
# Makefile for NanoKVM Project
# Configuration
IMAGE_NAME := nanokvm-builder
UID := $(shell id -u)
GID := $(shell id -g)
PWD := $(shell pwd)
# Docker run common parameters
DOCKER_RUN_BASE := docker run -e UID=$(UID) -e GID=$(GID) -v $(PWD):/home/build/NanoKVM --rm
# Build commands
GO_BUILD_CMD := cd /home/build/NanoKVM/server && go mod tidy && CGO_ENABLED=1 GOOS=linux GOARCH=riscv64 CC=riscv64-unknown-linux-musl-gcc CGO_CFLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" go build
SUPPORT_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_system && ./build kvm_system add_to_kvmapp
.PHONY: help check-root builder-image rebuild-image check-image shell app support all clean
# Default target
all: app support
# Help target
help:
@echo "NanoKVM Build System"
@echo ""
@echo "Available targets:"
@echo " help - Show this help message"
@echo " check-image - Check builder Docker image and show versions"
@echo " builder-image - Build Docker image if not exists"
@echo " rebuild-image - Force rebuild Docker image"
@echo " shell - Enter interactive builder environment"
@echo " app - Build Go application server"
@echo " support - Build hardware support libraries"
@echo " all - Build both app and support (default)"
@echo " clean - Clean build artifacts"
@echo ""
@echo "Prerequisites:"
@echo " - Docker must be installed and running"
@echo " - Must not run as root user"
# Security check - prevent running as root
check-root:
@if [ "$$(id -u)" -eq 0 ]; then \
echo "Can't run as root"; \
exit 1; \
fi
# Check if builder image exists and show versions
check-image: check-root
@echo "Checking builder image..."
@echo "Golang version: " && \
docker run --rm -i $(IMAGE_NAME) go version && \
echo "" && \
echo "Host-tools version:" && \
docker run --rm -i $(IMAGE_NAME) riscv64-unknown-linux-musl-gcc -v && \
echo ""
# Build Docker image if it doesn't exist
builder-image: check-root
@if ! docker image inspect $(IMAGE_NAME) >/dev/null 2>&1; then \
echo "Building Docker image..."; \
docker build -t $(IMAGE_NAME) -f docker/Dockerfile ./; \
else \
echo "Docker image $(IMAGE_NAME) already exists."; \
fi
# Force rebuild Docker image
rebuild-image: check-root
@echo "Force rebuilding Docker image..."
@docker build --no-cache -t $(IMAGE_NAME) -f docker/Dockerfile ./
# Enter interactive shell (equivalent to build.sh with no arguments)
shell: check-root builder-image
@echo "Switching into builder..."
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c ". ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM ; exec bash"
# Build Go application
app: check-root builder-image
@echo "Building app..."
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c '$(GO_BUILD_CMD)'
# Build hardware support libraries
support: check-root builder-image
@echo "Building support..."
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c '$(SUPPORT_BUILD_CMD)'
# Clean build artifacts
clean:
@echo "Cleaning build artifacts..."
@if [ -f server/NanoKVM-Server ]; then \
rm -f server/NanoKVM-Server; \
echo "Removed server/NanoKVM-Server"; \
fi
@if [ -d support/sg2002/build ]; then \
rm -rf support/sg2002/build; \
echo "Removed support/sg2002/build"; \
fi
@echo "Clean completed."

View File

@@ -1,55 +1,47 @@
NanoKVM
======
# NanoKVM
<div align="center">
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<h3>
<br>
<img src="https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png" alt="NanoKVM" style="margin: 20px 0;">
<h3>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 Quick Start</a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ Hardware Details</a>
|
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 Firmware Releases</a>
</h3>
English | [中文](./README_ZH.md) | [日本語](./README_JA.md) | [한국어](./README_KO.md)
</h3>
<br>
</div>
> For the recent discussion regarding the **built-in microphone**, please check [Issue #693](https://github.com/sipeed/NanoKVM/issues/693).
## 🌟 What is NanoKVM?
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.
NanoKVM is a series of compact, open-source IP-KVM devices based on the LicheeRV Nano (RISC-V). NanoKVM allows you to remotely access and control computers as if you were sitting right in front of them – perfect for managing servers, embedded systems, or any headless machine.
## 📦 Product Family
We offer several NanoKVM versions to suit your needs:
* **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-Pro: The 4K Powerhouse
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).
* **NanoKVM-Cube Lite:** A barebones kit for DIY enthusiasts and enterprise users requiring bulk deployment.
* **NanoKVM-Cube Full:** A complete package featuring a sleek case, accessories, and a pre-flashed system SD card. Ready to use out-of-the-box; ideal for individual users.
* **NanoKVM-PCIe:** A unique form factor with a PCIe bracket for internal chassis mounting. It draws power directly from the PCIe slot and supports optional WiFi and PoE functions.
* **[NanoKVM-Pro:](https://github.com/sipeed/NanoKVM-Pro)** Based on popular demand, the **Pro version** delivers significant performance upgrades:
* **Resolution:** Upgraded from 1080P to stunning **4K@30fps / 2K@60fps**.
* **Network:** Blazing fast **1Gbps Ethernet + PoE + WiFi 6** ((upgraded from 100Mbps).
* **Latency:** Encoder accelerated latency reduced from 100-150ms to **50-100ms**.
<div align="center">
![NanoKVM Product Family](https://cdn.sipeed.com/public/nanokvm-products.jpg)
<img src="https://cdn.sipeed.com/public/nanokvm-products.jpg" alt="NanoKVM Product Family" width="80%" style="margin: 20px 0;">
</div>
> If you are looking for a USB-based KVM solution, check out [NanoKVM-USB](https://github.com/sipeed/NanoKVM-USB).
## 🛠️ Technical Specifications
| Product | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
| Feature | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
| Core | AX631 2xA53 1.5G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
| Core | AX630C 2xA53 1.2G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
| Memory & Storage | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMc | 256M DDR3 + 16G eMMC |
| System | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM |
| Resolution | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps |
@@ -62,7 +54,7 @@ Based on popular demand, we've upgraded NanoKVM to the **NanoKVM-Pro**:
| IPMI | ✓ | ✓ | ✓ | x |
| Wake-on-LAN | ✓ | ✓ | ✓ | ✓ |
| Web Terminal | ✓ | ✓ | ✓ | ✓ |
| Serial Terminal | 3 channels | 2 channels | x | 1 channel |
| Serial Terminal | 2 channels | 2 channels | x | 1 channel |
| Custom Scripts | ✓ | ✓ | x | x |
| Storage | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s |
| Ethernet | 1000M | 100M | 1000M | 100M |
@@ -71,31 +63,28 @@ Based on popular demand, we've upgraded NanoKVM to the **NanoKVM-Pro**:
| 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 Consumption | 0.6A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
| Power Input | USB-C or PoE | USB-C | USB-C | USB-C |
| Dimensions | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm |
| 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
│ ├── jpg_stream # Compatible for direct updates from very old versions
│ ├── kvm_new_app # Triggers necessary components for kvm_system update
│ ├── kvm_system # kvm_system application
│ ├── server # NanoKVM front-end and back-end applications
│ └── system # Necessary system components
├── web # NanoKVM front-end
├── server # NanoKVM back-end
├── support # Auxiliary functions (image subsystem, system status, system updates, screen, keys, etc.)
│ ├── sg2002 # NanoKVM-Lite/Full/PCIe
│ └── h618 # NanoKVM-Pro
│ ├── jpg_stream # Legacy support for direct updates from older versions
│ ├── kvm_new_app # Triggers components for kvm_system updates
│ ├── kvm_system # Core KVM application
│ ├── server # Front-end and back-end integration
│ └── system # Essential system components
├── web # NanoKVM Front-end (UI)
├── server # NanoKVM Back-end (Service)
├── support # Auxiliary modules (Image subsystem, status, updates, OLED, HID, etc.)
├── ...
```
## 🔩 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).
NanoKVM is based on Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html). You can find specifications, schematics, and dimensional drawings in the [download station](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano).
The NanoKVM Lite is constructed by the LicheeRV Nano and HDMItoCSI board, and the NanoKVM FULL adds the NanoKVM-A/B board and shell to the NanoKVM Lite. The HDMItoCSI board is used to convert the HDMI signal; NanoKVM-A, including OLED, ATX control output (USB Type-C interface), auxiliary power supply and ATX power on/off and reset buttons; The NanoKVM-B is connected to the plate at one end and the computer at the other end is connected to the computer ATX-Pin, which is used to remotely control the power of the computer.
@@ -105,9 +94,12 @@ Note: Out of the 256MB memory in SG2002, 158MB is currently allocated for the mu
* [NanoKVM-A Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
* [NanoKVM-B Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf)
* [NanoKVM-PCIe Schematic](https://cn.dl.sipeed.com/fileList/KVM/KVM_PCIE/HDK/01_Schematic/SCH_nanoKVM_PCIE_3105D_2025-12-19.pdf)
* [NanoKVM img](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)
<div align="center">
<img src="https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg" alt="NanoKVM PCB Pinout" width="80%" style="margin: 20px 0;">
</div>
## 🤝 Contributing
@@ -131,8 +123,10 @@ Please keep your pull requests small and focused to facilitate easier review and
## 💬 Community & Support
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
* [Discord](https://discord.gg/V4sAZ9XWpN)
* QQ group: 703230713
* email: [support@sipeed.com](mailto:support@sipeed.com)
* [FAQ](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/faq.html)
## 📜 License

View File

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

View File

@@ -1,139 +0,0 @@
NanoKVM
======
<div align="center">
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<h3>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 빠른 시작</a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ 하드웨어 정보</a>
|
<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

@@ -1,139 +0,0 @@
NanoKVM
======
<div align="center">
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<h3>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 快速开始</a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ 硬件信息</a>
|
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 固件发布</a>
</h3>
[English](./README.md) | 中文 | [日本語](./README_JA.md) | [한국어](./README_KO.md)
</div>
## 🌟 NanoKVM 是什么?
NanoKVM 是一系列紧凑型开源 IP-KVM 设备。NanoKVM 基于 LicheeRV Nano (RISC-V) 构建,让您能够身临其境般的远程访问和控制计算机,是管理服务器、嵌入式系统或任何无头设备的理想之选。
## 📦 产品系列
我们提供多种 NanoKVM 版本以满足您的需求:
* **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 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 |
| 局域网唤醒 | ✓ | ✓ | ✓ | ✓ |
| 网页终端 | ✓ | ✓ | ✓ | ✓ |
| 串口终端 | 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更新包
│ ├── jpg_stream # 兼容从非常老的版本中直接更新
│ ├── kvm_new_app # 触发 kvm_system 更新必要组件
│ ├── kvm_system # kvm_system 应用
│ ├── server # NanoKVM 前后端应用
│ └── system # 必要系统组件
├── server # NanoKVM 后端
├── support # 辅助功能(图像子系统、系统状态、系统更新、屏幕、按键……)
│ ├── sg2002 # NanoKVM-Lite/Full/PCIe
│ └── h618 # NanoKVM-Pro
├── web # NanoKVM 前端
├── LICENSE
├── README_JA.md
├── README.md
├── README_ZH.md
└── CHANGELOG.md
```
## 🔩 硬件平台(NanoKVM 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 E 和 HDMItoCSI 小板构成,NanoKVM FULL 在 NanoKVM Lite 基础上增加 NanoKVM-A/B 板和外壳。HDMItoCSI板用于转换HDMI信号;NanoKVM-A 包含 OLED、ATX控制输出(TypeC接口形式)、辅助供电(TypeC接口)以及ATX开关机、复位按键;NanoKVM-B 一端连接A板,一端连接电脑ATX针脚,用于电脑的远程开关机。
NanoKVM 镜像在LicheeRV Nano SDK 和 MaixCDK 基础上构建,可以兼容使用 LicheeRV Nano 的资料,反之LicheeRV Nano 或其他 SG2002 产品无法使用KVM软件。如果您想在 LicheeRV Nano 或 MaixCam 上构建 HDMI输入相关应用,请与我们联系,以获得技术支持。
注: SG2002的256MB内存中, 目前划分105MB用于多媒体子系统, NanoKVM会在视频图像采集和处理中使用这部分内存.
* [NanoKVM-A 原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
* [NanoKVM-B 原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_HDMI_MIPI_31011.pdf)
* [NanoKVM 镜像下载](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
![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

79
docker/Dockerfile Normal file
View File

@@ -0,0 +1,79 @@
FROM ubuntu:24.04 AS base_apt
# RUN echo 'Acquire::http::Proxy "http://cache.lan:3142";' > /etc/apt/apt.conf.d/00cacher
RUN userdel -r ubuntu
RUN apt update \
&& apt install wget git cmake build-essential python3 python3-venv python3-pip autoconf automake libtool gosu -y
ARG DOCKER_USER=build
RUN useradd -m "$DOCKER_USER"
USER $DOCKER_USER
FROM base_apt AS host_tools
USER root
RUN wget -O /tmp/host-tools.tar.gz https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz \
&& tar -C /usr/local -zxvf /tmp/host-tools.tar.gz \
&& rm -f /tmp/host-tools.tar.gz \
&& export PATH=$PATH:/usr/local/host-tools/gcc/riscv64-linux-musl-x86_64/bin \
&& echo "Installed host-tools gcc" && riscv64-unknown-linux-musl-gcc -v
FROM base_apt AS golang
USER root
RUN bash -c 'wget -O /tmp/go.tar.gz https://go.dev/dl/go1.25.0.linux-$([ `uname -m` == "x86_64" ] && echo "amd64" || uname -m).tar.gz' \
&& tar -C /usr/local -zxvf /tmp/go.tar.gz \
&& export PATH=$PATH:/usr/local/go/bin \
&& echo "Installed golang" && go version
COPY server /tmp/go_cache
ENV PATH="$PATH:/usr/local/go/bin"
RUN cd /tmp/go_cache \
&& echo "Caching go dep" \
&& go mod download \
&& go mod tidy \
&& cd / \
&& rm -rf /tmp/go_cache
FROM base_apt AS sdk
RUN cd ~ \
&& git clone https://github.com/Sipeed/MaixCDK \
&& cd MaixCDK \
&& python3 -m venv . \
&& . ./bin/activate \
&& pip install -U -r requirements.txt
COPY --chown=$DOCKER_USER . /home/$DOCKER_USER/NanoKVM
RUN cd ~/MaixCDK \
&& . ./bin/activate \
&& cd ~/NanoKVM/support/sg2002 \
&& ./build kvm_system
FROM base_apt
# Install golang
COPY --from=golang /usr/local/go /usr/local/go
COPY --chown=$DOCKER_USER --from=golang /root/go /home/$DOCKER_USER/go
ENV PATH="$PATH:/usr/local/go/bin"
# Install host tools
COPY --from=host_tools /usr/local/host-tools /usr/local/host-tools
ENV PATH="$PATH:/usr/local/host-tools/gcc/riscv64-linux-musl-x86_64/bin"
# Install SDK
COPY --from=sdk /home/$DOCKER_USER/MaixCDK /home/$DOCKER_USER/MaixCDK
COPY --chmod=+x docker/entrypoint /entrypoint
RUN echo "Verify build tools" \
&& echo "Installed host-tools gcc" && riscv64-unknown-linux-musl-gcc -v \
&& echo "Installed golang" && go version
USER root
RUN chmod 0755 /entrypoint \
&& sed "s/\$DOCKER_USER/$DOCKER_USER/g" -i /entrypoint
ENTRYPOINT ["/entrypoint"]

22
docker/entrypoint Normal file
View File

@@ -0,0 +1,22 @@
#!/bin/sh
set -e
set -u
: "${UID:=0}"
: "${GID:=${UID}}"
if [ "$#" = 0 ]
then set -- "$(command -v bash 2>/dev/null || command -v sh)" -l
fi
if [ "$UID" != 0 ]
then
usermod -u "$UID" "$DOCKER_USER" 2>/dev/null && {
groupmod -g "$GID" "$DOCKER_USER" 2>/dev/null ||
usermod -a -G "$GID" "$DOCKER_USER"
}
set -- gosu "${UID}:${GID}" "${@}"
fi
exec "$@"

View File

@@ -75,7 +75,7 @@ start_usb_dev(){
fi
echo 2 > functions/hid.GS1/protocol
echo 4 > functions/hid.GS1/report_length
echo -ne \\x5\\x1\\x9\\x2\\xa1\\x1\\x9\\x1\\xa1\\x0\\x5\\x9\\x19\\x1\\x29\\x3\\x15\\x0\\x25\\x1\\x95\\x3\\x75\\x1\\x81\\x2\\x95\\x1\\x75\\x5\\x81\\x3\\x5\\x1\\x9\\x30\\x9\\x31\\x9\\x38\\x15\\x81\\x25\\x7f\\x75\\x8\\x95\\x3\\x81\\x6\\xc0\\xc0 > functions/hid.GS1/report_desc
echo -ne \\x5\\x1\\x9\\x2\\xa1\\x1\\x9\\x1\\xa1\\x0\\x5\\x9\\x19\\x1\\x29\\x3\\x15\\x0\\x25\\x1\\x95\\x3\\x75\\x1\\x81\\x2\\x95\\x1\\x75\\x5\\x81\\x3\\x5\\x1\\x9\\x30\\x9\\x31\\x9\\x38\\x15\\x81\\x25\\x7f\\x75\\x8\\x95\\x3\\x81\\x6\\xc0\\xc0 > functions/hid.GS1/report_desc
ln -s functions/hid.GS1 configs/c.1
# touchpad
@@ -149,4 +149,11 @@ case "$1" in
start_usb_host
start_usb_dev
;;
restart_phy)
echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/unbind
sleep 1
echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/bind
start_usb_host
start_usb_dev
;;
esac

View File

@@ -57,7 +57,7 @@ start_usb_dev(){
fi
echo 2 > functions/hid.GS1/protocol
echo 4 > functions/hid.GS1/report_length
echo -ne \\x5\\x1\\x9\\x2\\xa1\\x1\\x9\\x1\\xa1\\x0\\x5\\x9\\x19\\x1\\x29\\x3\\x15\\x0\\x25\\x1\\x95\\x3\\x75\\x1\\x81\\x2\\x95\\x1\\x75\\x5\\x81\\x3\\x5\\x1\\x9\\x30\\x9\\x31\\x9\\x38\\x15\\x81\\x25\\x7f\\x75\\x8\\x95\\x3\\x81\\x6\\xc0\\xc0 > functions/hid.GS1/report_desc
echo -ne \\x5\\x1\\x9\\x2\\xa1\\x1\\x9\\x1\\xa1\\x0\\x5\\x9\\x19\\x1\\x29\\x3\\x15\\x0\\x25\\x1\\x95\\x3\\x75\\x1\\x81\\x2\\x95\\x1\\x75\\x5\\x81\\x3\\x5\\x1\\x9\\x30\\x9\\x31\\x9\\x38\\x15\\x81\\x25\\x7f\\x75\\x8\\x95\\x3\\x81\\x6\\xc0\\xc0 > functions/hid.GS1/report_desc
ln -s functions/hid.GS1 configs/c.1
# touchpad
@@ -102,4 +102,11 @@ case "$1" in
start_usb_host
start_usb_dev
;;
restart_phy)
echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/unbind
sleep 1
echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/bind
start_usb_host
start_usb_dev
;;
esac

View File

@@ -2,24 +2,26 @@
. /etc/profile
APFLAG_FILE=/tmp/wifiap
gen_hostapd_conf() {
ssid="${1}"
pass="${2}"
echo "ctrl_interface=/var/run/hostapd"
echo "ctrl_interface_group=0"
echo "ssid=${ssid}"
echo "hw_mode=g"
echo "channel=1"
echo "beacon_int=100"
echo "dtim_period=2"
echo "max_num_sta=255"
echo "rts_threshold=-1"
echo "fragm_threshold=-1"
echo "macaddr_acl=0"
echo "auth_algs=3"
echo "wpa=2"
echo "wpa_passphrase=${pass}"
echo "ieee80211n=1"
ssid="${1}"
pass="${2}"
echo "ctrl_interface=/var/run/hostapd"
echo "ctrl_interface_group=0"
echo "ssid=${ssid}"
echo "hw_mode=g"
echo "channel=1"
echo "beacon_int=100"
echo "dtim_period=2"
echo "max_num_sta=255"
echo "rts_threshold=-1"
echo "fragm_threshold=-1"
echo "macaddr_acl=0"
echo "auth_algs=3"
echo "wpa=2"
echo "wpa_passphrase=${pass}"
echo "ieee80211n=1"
}
gen_udhcpd_conf() {
@@ -61,19 +63,16 @@ start() {
chown root:root /etc/kvm/wifi.ssid /etc/kvm/wifi.pass
chmod 644 /etc/kvm/wifi.ssid /etc/kvm/wifi.pass
fi
if [ -e /etc/kvm/wifi.ssid ]
then
ssid=`cat /etc/kvm/wifi.ssid`
if [ -e /etc/kvm/wifi.ssid ]; then
ssid=$(cat /etc/kvm/wifi.ssid)
fi
if [ -e /etc/kvm/wifi.pass ]
then
pass=`cat /etc/kvm/wifi.pass`
if [ -e /etc/kvm/wifi.pass ]; then
pass=$(cat /etc/kvm/wifi.pass)
fi
echo "ctrl_interface=/var/run/wpa_supplicant" > /etc/wpa_supplicant.conf
wpa_passphrase "$ssid" "$pass" >> /etc/wpa_supplicant.conf
echo "ctrl_interface=/var/run/wpa_supplicant" >/etc/wpa_supplicant.conf
wpa_passphrase "$ssid" "$pass" >>/etc/wpa_supplicant.conf
wpa_supplicant -B -i wlan0 -c /etc/wpa_supplicant.conf
if [ ! -e /boot/wifi.nodhcp ]
then
if [ ! -e /boot/wifi.nodhcp ]; then
(udhcpc -i wlan0 -t 10 -T 1 -A 5 -b -p /run/udhcpc.wlan0.pid) &
fi
}
@@ -82,33 +81,27 @@ ap_start() {
echo "wifi mode: ap"
id2=$(printf "%d" 0x$(sha512sum /sys/class/cvi-base/base_uid | head -c 2))
id3=$(printf "%d" 0x$(sha512sum /sys/class/cvi-base/base_uid | head -c 4 | tail -c 2))
if [ "$id2" = "$id3" ]
then
if [ "$id2" = "$id3" ]; then
id2=$((id2 + 1))
fi
if [ "$id2" -ge 255 ]
then
if [ "$id2" -ge 255 ]; then
id2=253
fi
if [ "$id3" -ge 255 ]
then
if [ "$id3" -ge 255 ]; then
id3=254
fi
ssid=""
pass=""
if [ -e /kvmapp/kvm/ap.ssid ]
then
ssid=`cat /kvmapp/kvm/ap.ssid`
if [ -e /kvmapp/kvm/ap.ssid ]; then
ssid=$(cat /kvmapp/kvm/ap.ssid)
fi
if [ -e /kvmapp/kvm/ap.pass ]
then
pass=`cat /kvmapp/kvm/ap.pass`
if [ -e /kvmapp/kvm/ap.pass ]; then
pass=$(cat /kvmapp/kvm/ap.pass)
fi
gen_hostapd_conf "$ssid" "$pass" > /etc/hostapd.conf
gen_hostapd_conf "$ssid" "$pass" >/etc/hostapd.conf
ipv4_prefix=10.$id3.$id2
if [ ! -e /etc/udhcpd.wlan0.conf ]
then
gen_udhcpd_conf wlan0 "${ipv4_prefix}" > /etc/udhcpd.wlan0.conf
if [ ! -e /etc/udhcpd.wlan0.conf ]; then
gen_udhcpd_conf wlan0 "${ipv4_prefix}" >/etc/udhcpd.wlan0.conf
fi
# gen_dnsmasq_conf "${ipv4_prefix}" > /etc/dnsmasq.conf
@@ -126,50 +119,52 @@ ap_start() {
# dnsmasq --pid-file=/tmp/dnsmasq.run -C /etc/dnsmasq.conf
hostapd -B -i wlan0 /etc/hostapd.conf
udhcpd -S /etc/udhcpd.wlan0.conf
if [ ! -f "$APFLAG_FILE" ]; then
touch "$APFLAG_FILE"
fi
}
stop() {
ps -ef|grep hostapd|grep -v grep|awk '{print $1}'|xargs kill -2 || true
ps -ef|grep "udhcpd -S /etc/udhcpd.wlan0.conf" |grep -v grep|awk '{print $1}'|xargs kill -2 || true
killall wpa_supplicant || true
if [ -e /run/udhcpc.wlan0.pid ]
then
kill `cat /run/udhcpc.wlan0.pid` || true
rm -f /run/udhcpc.wlan0.pid
fi
if [ -e /var/run/udhcpd.wlan0.pid ]
then
kill `cat /var/run/udhcpd.wlan0.pid` || true
rm -f /var/run/udhcpd.wlan0.pid
fi
if [ -e /tmp/dnsmasq.run ]
then
kill `cat /tmp/dnsmasq.run` || true
rm -f /tmp/dnsmasq.run
fi
# if [ -e /etc/wpa_supplicant.conf]
# then
# echo > /etc/wpa_supplicant.conf
# fi
airmon-ng stop wlan0mon || true
ps -ef | grep hostapd | grep -v grep | awk '{print $1}' | xargs kill -2 || true
ps -ef | grep "udhcpd -S /etc/udhcpd.wlan0.conf" | grep -v grep | awk '{print $1}' | xargs kill -2 || true
killall wpa_supplicant || true
if [ -e /run/udhcpc.wlan0.pid ]; then
kill $(cat /run/udhcpc.wlan0.pid) || true
rm -f /run/udhcpc.wlan0.pid
fi
if [ -e /var/run/udhcpd.wlan0.pid ]; then
kill $(cat /var/run/udhcpd.wlan0.pid) || true
rm -f /var/run/udhcpd.wlan0.pid
fi
if [ -e /tmp/dnsmasq.run ]; then
kill $(cat /tmp/dnsmasq.run) || true
rm -f /tmp/dnsmasq.run
fi
# if [ -e /etc/wpa_supplicant.conf]
# then
# echo > /etc/wpa_supplicant.conf
# fi
airmon-ng stop wlan0mon || true
if [ -f "$APFLAG_FILE" ]; then
rm "$APFLAG_FILE"
fi
}
restart() {
stop
start
stop
start
}
if [ "${1}" = "start" ]
then
start
elif [ "${1}" = "stop" ]
then
stop
elif [ "${1}" = "ap" ]
then
if [ "${1}" = "start" ]; then
start
elif [ "${1}" = "stop" ]; then
stop
ap_start
elif [ "${1}" = "restart" ]
then
restart
elif [ "${1}" = "ap" ]; then
stop
ap_start
elif [ "${1}" = "restart" ]; then
restart
fi

View File

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

View File

@@ -21,8 +21,3 @@ type ChangePasswordReq struct {
type IsPasswordUpdatedRsp struct {
IsUpdated bool `json:"isUpdated"`
}
type ConnectWifiReq struct {
Ssid string `validate:"required"`
Password string `valid:"required"`
}

View File

@@ -17,28 +17,14 @@ type SetMacNameReq struct {
Name string `form:"name" validate:"required"`
}
type TailscaleState string
const (
TailscaleNotInstall TailscaleState = "notInstall"
TailscaleNotRunning TailscaleState = "notRunning"
TailscaleNotLogin TailscaleState = "notLogin"
TailscaleStopped TailscaleState = "stopped"
TailscaleRunning TailscaleState = "running"
)
type GetTailscaleStatusRsp struct {
State TailscaleState `json:"state"`
Name string `json:"name"`
IP string `json:"ip"`
Account string `json:"account"`
}
type LoginTailscaleRsp struct {
Url string `json:"url"`
}
type GetWifiRsp struct {
Supported bool `json:"supported"`
Connected bool `json:"connected"`
Supported bool `json:"supported"`
ApMode bool `json:"apMode"`
Connected bool `json:"connected"`
Ssid string `json:"ssid"`
}
type ConnectWifiReq struct {
Ssid string `validate:"required"`
Password string `valid:"required"`
}

22
server/proto/tailscale.go Normal file
View File

@@ -0,0 +1,22 @@
package proto
type TailscaleState string
const (
TailscaleNotInstall TailscaleState = "notInstall"
TailscaleNotRunning TailscaleState = "notRunning"
TailscaleNotLogin TailscaleState = "notLogin"
TailscaleStopped TailscaleState = "stopped"
TailscaleRunning TailscaleState = "running"
)
type GetTailscaleStatusRsp struct {
State TailscaleState `json:"state"`
Name string `json:"name"`
IP string `json:"ip"`
Account string `json:"account"`
}
type LoginTailscaleRsp struct {
Url string `json:"url"`
}

View File

@@ -55,8 +55,18 @@ type DeleteScriptReq struct {
Name string `validate:"required"`
}
// autostart
type GetAutostartRsp struct {
Files []string `json:"files"`
}
type UploadAutostartReq struct {
Content string `json:"content"`
}
type GetVirtualDeviceRsp struct {
Network bool `json:"network"`
Media bool `json:"media"`
Disk bool `json:"disk"`
}

View File

@@ -11,8 +11,9 @@ func applicationRouter(r *gin.Engine) {
service := application.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api.GET("/application/version", service.GetVersion) // get application version
api.POST("/application/update", service.Update) // update application
api.GET("/application/version", service.GetVersion) // get application version
api.POST("/application/update", service.Update) // update application
api.POST("/application/update/offline", service.OfflineUpdate) // update application offline
api.GET("/application/preview", service.GetPreview) // get preview updates state
api.POST("/application/preview", service.SetPreview) // set preview updates state

View File

@@ -14,4 +14,5 @@ func downloadRouter(r *gin.Engine) {
api.POST("/download/image", service.DownloadImage) // download image
api.GET("/download/image/status", service.StatusImage) // download image
api.GET("/download/image/enabled", service.ImageEnabled) // download image
api.POST("/download/file", service.DownloadImageFile) // download image
}

View File

@@ -10,7 +10,7 @@ import (
func networkRouter(r *gin.Engine) {
service := network.NewService()
r.POST("/api/network/wifi", service.ConnectWifi) // connect Wi-Fi
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
api := r.Group("/api").Use(middleware.CheckToken())
@@ -18,5 +18,8 @@ func networkRouter(r *gin.Engine) {
api.GET("/network/wol/mac", service.GetMac) // get mac list
api.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
api.POST("/network/wol/mac/name", service.SetMacName) // set mac name
api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
api.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
api.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
}

View File

@@ -63,5 +63,10 @@ func vmRouter(r *gin.Engine) {
api.POST("/vm/tls", service.SetTls) // enable/disable TLS
api.GET("/vm/autostart", service.GetAutostart) // get autostart list
api.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
api.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
api.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script
api.POST("/vm/system/reboot", service.Reboot) // reboot system
}

View File

@@ -0,0 +1,76 @@
package application
import (
"fmt"
"os"
"sync"
"NanoKVM-Server/utils"
log "github.com/sirupsen/logrus"
)
var (
mutex sync.Mutex
isUpdating bool
)
func acquireUpdateLock() bool {
mutex.Lock()
defer mutex.Unlock()
if isUpdating {
return false
}
isUpdating = true
return true
}
func releaseUpdateLock() {
mutex.Lock()
defer mutex.Unlock()
isUpdating = false
}
func installPackage(source string) error {
dir, err := utils.UnTarGz(source, CacheDir)
if err != nil {
return fmt.Errorf("failed to decompress app: %w", err)
}
if err := backupCurrentApp(); err != nil {
return err
}
if err := applyUpdate(dir); err != nil {
return err
}
if err := utils.ChmodRecursively(AppDir, 0o755); err != nil {
return fmt.Errorf("failed to chmod: %w", err)
}
return nil
}
func backupCurrentApp() error {
if err := os.RemoveAll(BackupDir); err != nil {
return fmt.Errorf("failed to remove backup: %w", err)
}
if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil {
return fmt.Errorf("failed to backup app: %w", err)
}
return nil
}
func applyUpdate(sourceDir string) error {
if err := utils.MoveFilesRecursively(sourceDir, AppDir); err != nil {
// Try to restore backup on failure
if restoreErr := utils.MoveFilesRecursively(BackupDir, AppDir); restoreErr != nil {
log.Errorf("Failed to restore backup after update failure: %v", restoreErr)
}
return fmt.Errorf("failed to move update in place: %w", err)
}
return nil
}

View File

@@ -0,0 +1,84 @@
package application
import (
"fmt"
"io"
"os"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
const (
sentinelPath = "/tmp/.download_in_progress"
tickerInterval = 2500 * time.Millisecond
sentinelPermission = 0644
)
// progressWriter wraps an io.Writer to track and report upload progress.
type progressWriter struct {
writer io.Writer
written int64
totalSize int64
ticker *time.Ticker
done chan struct{}
}
func newProgressWriter(w io.Writer, totalSize int64) *progressWriter {
pw := &progressWriter{
writer: w,
totalSize: totalSize,
ticker: time.NewTicker(tickerInterval),
done: make(chan struct{}),
}
go pw.reportProgress()
return pw
}
func (pw *progressWriter) reportProgress() {
for {
select {
case <-pw.done:
return
case <-pw.ticker.C:
pw.updateSentinel()
}
}
}
func (pw *progressWriter) updateSentinel() {
if pw.totalSize == 0 {
return
}
content, err := os.ReadFile(sentinelPath)
if err != nil {
log.Error("Failed to read sentinel file")
return
}
parts := strings.SplitN(string(content), ";", 2)
if len(parts) == 0 {
return
}
percentage := float64(pw.written) / float64(pw.totalSize) * 100
newContent := fmt.Sprintf("%s;%.2f%%", parts[0], percentage)
if err := os.WriteFile(sentinelPath, []byte(newContent), sentinelPermission); err != nil {
log.Error("Failed to update sentinel file")
}
}
func (pw *progressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.written += int64(n)
return n, err
}
func (pw *progressWriter) Stop() {
pw.ticker.Stop()
close(pw.done)
}

View File

@@ -8,7 +8,6 @@ import (
"net/http"
"os"
"os/exec"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -22,28 +21,14 @@ 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()
if !acquireUpdateLock() {
rsp.ErrRsp(c, -1, "update already in progress")
return
}
isUpdating = true
updateMutex.Unlock()
defer func() {
updateMutex.Lock()
isUpdating = false
updateMutex.Unlock()
}()
defer releaseUpdateLock()
if err := update(); err != nil {
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
@@ -74,7 +59,6 @@ func update() error {
// download
target := fmt.Sprintf("%s/%s", CacheDir, latest.Name)
if err := download(latest.Url, target); err != nil {
log.Errorf("download app failed: %s", err)
return err
@@ -86,34 +70,9 @@ func update() error {
return err
}
// decompress
dir, err := utils.UnTarGz(target, CacheDir)
log.Debugf("untar: %s", dir)
if err != nil {
log.Errorf("decompress app failed: %s", err)
return err
}
// backup old version
if err := os.RemoveAll(BackupDir); err != nil {
log.Errorf("remove backup failed: %s", err)
return err
}
if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil {
log.Errorf("backup app failed: %s", err)
return err
}
// update
if err := utils.MoveFilesRecursively(dir, AppDir); err != nil {
log.Errorf("failed to move update back in place: %s", err)
return err
}
// modify permissions
if err := utils.ChmodRecursively(AppDir, 0o755); err != nil {
log.Errorf("chmod failed: %s", err)
// install
if err := installPackage(target); err != nil {
log.Errorf("failed to install package: %v", err)
return err
}
@@ -124,7 +83,7 @@ func download(url string, target string) (err error) {
for i := range maxTries {
log.Debugf("attempt #%d/%d", i+1, maxTries)
if i > 0 {
time.Sleep(time.Second * 3) // wait for 3 seconds before retrying the download attempt
time.Sleep(time.Second * 3)
}
var req *http.Request

View File

@@ -0,0 +1,176 @@
package application
import (
"fmt"
"io"
"mime/multipart"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
var validFilenameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
func (s *Service) OfflineUpdate(c *gin.Context) {
var rsp proto.Response
if !acquireUpdateLock() {
rsp.ErrRsp(c, -1, "update already in progress")
return
}
defer releaseUpdateLock()
if err := offlineUpdate(c); err != nil {
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
return
}
rsp.OkRsp(c)
log.Debugf("offline update application success")
time.Sleep(1 * time.Second)
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
}
func offlineUpdate(c *gin.Context) error {
_ = os.RemoveAll(CacheDir)
_ = os.MkdirAll(CacheDir, 0o755)
defer func() {
_ = os.RemoveAll(CacheDir)
}()
if err := checkDownloadInProgress(); err != nil {
return err
}
if err := createSentinelFile(); err != nil {
return err
}
defer removeSentinelFile()
reader, err := c.Request.MultipartReader()
if err != nil {
log.Errorf("Invalid multipart data: %v", err)
return fmt.Errorf("invalid multipart data: %w", err)
}
target, err := processUpload(reader, c.Request.ContentLength)
if err != nil {
log.Errorf("failed to upload install package: %v", err)
return err
}
if err := installPackage(target); err != nil {
log.Errorf("failed to install package: %v", err)
return err
}
return nil
}
func checkDownloadInProgress() error {
if _, err := os.Stat(sentinelPath); err == nil {
log.Debug("Download in progress")
return fmt.Errorf("download already in progress")
}
return nil
}
func createSentinelFile() error {
if err := os.WriteFile(sentinelPath, []byte("downloading"), sentinelPermission); err != nil {
log.Errorf("Failed to create sentinel file: %v", err)
return fmt.Errorf("failed to create sentinel file: %w", err)
}
return nil
}
func processUpload(reader *multipart.Reader, contentLength int64) (string, error) {
var outPath string
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("failed to read multipart: %w", err)
}
if part.FormName() != "file" {
continue
}
outPath, err = saveUploadedFile(part, contentLength)
if err != nil {
return "", err
}
}
if outPath == "" {
return "", fmt.Errorf("no file uploaded")
}
return outPath, nil
}
func saveUploadedFile(part *multipart.Part, contentLength int64) (string, error) {
filename := part.FileName()
if filename == "" {
return "", fmt.Errorf("no filename provided")
}
if err := validateFilename(filename); err != nil {
return "", err
}
outPath := filepath.Join(CacheDir, filename)
out, err := os.Create(outPath)
if err != nil {
return "", fmt.Errorf("failed to create output file: %w", err)
}
defer out.Close()
pw := newProgressWriter(out, contentLength)
defer pw.Stop()
if _, err := io.Copy(pw, part); err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
return outPath, nil
}
func validateFilename(filename string) error {
baseName := filepath.Base(filename)
// Check if the path contains directory components
if baseName != filename {
log.Warnf("Path detected in filename: %s", filename)
return fmt.Errorf("path detected in filename")
}
// Check for path traversal attempts
if strings.Contains(filename, "..") {
log.Warnf("Path traversal attempt: %s", filename)
return fmt.Errorf("invalid filename: path traversal detected")
}
// Validate filename characters
if !validFilenameRegex.MatchString(filename) {
log.Warnf("Invalid filename characters: %s", filename)
return fmt.Errorf("invalid filename: contains invalid characters")
}
return nil
}
func removeSentinelFile() {
_ = os.Remove(sentinelPath)
}

View File

@@ -1,7 +1,6 @@
package application
import (
"NanoKVM-Server/proto"
"encoding/json"
"fmt"
"io"
@@ -10,6 +9,8 @@ import (
"strings"
"time"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
@@ -36,15 +37,15 @@ func (s *Service) GetVersion(c *gin.Context) {
log.Debugf("current version: %s", currentVersion)
// latest version
latestVersion := ""
latest, err := getLatest()
if err != nil {
rsp.ErrRsp(c, -1, "get latest version failed")
return
if err == nil && latest != nil {
latestVersion = latest.Version
}
rsp.OkRspWithData(c, &proto.GetVersionRsp{
Current: currentVersion,
Latest: latest.Version,
Latest: latestVersion,
})
}

View File

@@ -12,6 +12,7 @@ import (
"path/filepath"
"strings"
"time"
"regexp"
)
type Service struct{}
@@ -51,6 +52,28 @@ func (s *Service) ImageEnabled(c *gin.Context) {
})
}
func isISO9660(path string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
// ISO-9660 Magic "CD001" bei Offset 32769
_, err = f.Seek(0x8001, io.SeekStart)
if err != nil {
return false, err
}
buf := make([]byte, 5)
_, err = io.ReadFull(f, buf)
if err != nil {
return false, err
}
return string(buf) == "CD001", nil
}
func (s *Service) StatusImage(c *gin.Context) {
var rsp proto.Response
@@ -93,6 +116,170 @@ func (s *Service) StatusImage(c *gin.Context) {
})
}
func (s *Service) DownloadImageFile(c *gin.Context) {
var rsp proto.Response
log.Debug("DownloadImage")
// Set a sentinel file to mark that there is a download in progress
// This is to prevent multiple downloads at the same time
if _, err := os.Stat(sentinelPath); err == nil {
log.Debug("Download in progress")
rsp.ErrRsp(c, -1, "download in progress")
return
}
// Create the sentinel file
err := os.WriteFile(sentinelPath, []byte("start"), 0644)
if err != nil {
log.Error("Failed to create sentinel file")
rsp.ErrRsp(c, -1, "failed to create sentinel file")
return
}
// Multipart Reader direkt nutzen (keine FormFile!)
reader, err := c.Request.MultipartReader()
if err != nil {
log.Error("invalid multipart data")
rsp.ErrRsp(c, -1, "invalid multipart data")
defer os.Remove(sentinelPath)
return
}
var lw *loggingWriter
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
log.Error("failed to read part")
rsp.ErrRsp(c, -1, "failed to read part")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
if part.FormName() != "file" {
continue
}
filename := part.FileName()
if filename == "" {
log.Error("no filename")
rsp.ErrRsp(c, -1, "no filename")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
filename = filepath.Base(filename)
if filename != part.FileName() {
log.Warn("path detected in filename")
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
}
if strings.Contains(filename, "..") {
log.Warn("path traversal attempt")
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
}
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
rsp.ErrRsp(c, -1, "only .iso files allowed")
defer os.Remove(sentinelPath)
return
}
valid := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
if !valid.MatchString(filename) {
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
}
data, err := os.ReadFile(sentinelPath)
if err != nil {
log.Error("Read failed")
rsp.ErrRsp(c, -1, "Read failed")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
outPath := "/data/" + filename
out, err := os.Create(outPath)
if err != nil {
log.Error("cannot create file")
rsp.ErrRsp(c, -1, "cannot create file")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
defer out.Close()
if strings.Contains(string(data), "start") {
err = os.WriteFile(sentinelPath, []byte(filename), 0644)
if err != nil {
log.Error("Failed to create sentinel file")
rsp.ErrRsp(c, -1, "failed to create sentinel file")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
lw = &loggingWriter{writer: out, totalSize: c.Request.ContentLength}
lw.startTicker()
} else {
if !strings.Contains(string(data), filename) {
log.Error("failed")
rsp.ErrRsp(c, -1, "failed")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
}
// Direkt streamen → kein RAM-Bedarf außer kleinem Buffer
_, err = io.Copy(lw, part)
if err != nil {
log.Error("write failed")
rsp.ErrRsp(c, -1, "write failed")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
ok, err := isISO9660(outPath)
if err != nil || !ok {
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
}
lw.stopTicker()
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "idle",
File: "",
Percentage: "",
})
defer os.Remove(sentinelPath)
return
}
func (s *Service) DownloadImage(c *gin.Context) {
var req proto.MountImageReq
var rsp proto.Response

View File

@@ -59,7 +59,7 @@ func (h *Hid) mouseUp() {
func (h *Hid) mouseScroll(event []int) {
direction := 0x01
if event[3] > 0 {
if event[3] < 0 {
direction = -0x1
}

View File

@@ -20,25 +20,25 @@ type PasteReq struct {
}
func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
// wenn kein lang angegeben → Standardmap zurück
// if no language is specified → return base map
if lang == "" {
return base
}
// immer Kopie erstellen
// always create a copy of the base map
m := copyMap(base)
switch lang {
case "de":
// Y tauschen
// swap Y
m['y'] = Char{0, 29}
m['Y'] = Char{2, 29}
// Z tauschen
// swap Z
m['z'] = Char{0, 28}
m['Z'] = Char{2, 28}
// deutsche Sonderzeichen hinzufügen oder remappen
// add German special characters or remap them
m['\u00E4'] = Char{0, 52} // ä
m['\u00C4'] = Char{2, 52} // Ä
m['\u00F6'] = Char{0, 51} // ö
@@ -47,8 +47,8 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
m['\u00DC'] = Char{2, 47} // Ü
m['\u00DF'] = Char{0, 45} // ß
//Tauschen
m['^'] = Char{0, 53} // muss doppelt sein
// swap special characters
m['^'] = Char{0, 53} // must be double
m['/'] = Char{2, 36} // Shift + 7
m['('] = Char{2, 37} // Shift + 8
m['&'] = Char{2, 35} // Shift + 6
@@ -75,7 +75,7 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
m['-'] = Char{0, 56} // Shift + -
m['_'] = Char{2, 56} // Shift + -
//neu
// new special characters
m['\u00B4'] = Char{0, 46} // ´
m['\u00B0'] = Char{2, 53} // °
m['\u00A7'] = Char{2, 32} // §

View File

@@ -1,20 +1,26 @@
package network
import (
"NanoKVM-Server/proto"
"fmt"
"os"
"os/exec"
"strings"
"time"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
WiFiExistFile = "/etc/kvm/wifi_exist"
WiFiSSID = "/etc/kvm/wifi.ssid"
WiFiPasswd = "/etc/kvm/wifi.pass"
WiFiConnect = "/kvmapp/kvm/wifi_try_connect"
WiFiStateFile = "/kvmapp/kvm/wifi_state"
WiFiExistFile = "/etc/kvm/wifi_exist"
WiFiApModeFile = "/tmp/wifiap"
WiFiSSID = "/etc/kvm/wifi.ssid"
WiFiPasswd = "/etc/kvm/wifi.pass"
WiFiConnect = "/kvmapp/kvm/wifi_try_connect"
WiFiStateFile = "/kvmapp/kvm/wifi_state"
WiFiScript = "/etc/init.d/S30wifi"
)
func (s *Service) GetWifi(c *gin.Context) {
@@ -22,25 +28,55 @@ func (s *Service) GetWifi(c *gin.Context) {
data := &proto.GetWifiRsp{}
_, err := os.Stat(WiFiExistFile)
if err != nil {
data.Supported = isSupported()
if !data.Supported {
rsp.OkRspWithData(c, data)
return
}
data.Supported = true
data.ApMode = isAPMode()
content, err := os.ReadFile(WiFiStateFile)
if err != nil {
data.Connected = isConnected()
if !data.Connected {
rsp.OkRspWithData(c, data)
return
}
state := strings.ReplaceAll(string(content), "\n", "")
data.Connected = state == "1"
data.Ssid = getWiFiSsid()
if data.Ssid == "" {
data.Ssid = "Wi-Fi"
}
rsp.OkRspWithData(c, data)
log.Debugf("get wifi state: %s", state)
log.Debugf("get wifi state: %+v", data)
}
func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
var req proto.ConnectWifiReq
var rsp proto.Response
// Check Wi-Fi configuration mode
if !isSupported() || !isAPMode() {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -1, "invalid mode")
return
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(1 * time.Second)
rsp.ErrRsp(c, -2, "invalid parameters")
return
}
if err := connect(req.Ssid, req.Password); err != nil {
rsp.ErrRsp(c, -3, "failed to connect wifi")
return
}
time.Sleep(5 * time.Second)
rsp.OkRsp(c)
log.Debugf("set wifi ap mode successfully")
}
func (s *Service) ConnectWifi(c *gin.Context) {
@@ -52,24 +88,97 @@ func (s *Service) ConnectWifi(c *gin.Context) {
return
}
if err := os.WriteFile(WiFiSSID, []byte(req.Ssid), 0o644); err != nil {
log.Errorf("failed to save wifi ssid: %s", err)
rsp.ErrRsp(c, -2, "failed to save wifi")
if err := connect(req.Ssid, req.Password); err != nil {
rsp.ErrRsp(c, -2, "failed to connect wifi")
return
}
if err := os.WriteFile(WiFiPasswd, []byte(req.Password), 0o644); err != nil {
log.Errorf("failed to save wifi password: %s", err)
rsp.ErrRsp(c, -3, "failed to save wifi")
// Waiting for Wi-Fi connection
maxTries := 7
if isConnected() {
maxTries = 3
time.Sleep(12 * time.Second)
}
for range maxTries {
time.Sleep(2 * time.Second)
if isConnected() && getWiFiSsid() != "" {
rsp.OkRsp(c)
log.Debugf("set wifi successfully: %s", req.Ssid)
return
}
}
rsp.ErrRsp(c, -3, "failed to get wifi status")
return
}
func (s *Service) DisconnectWifi(c *gin.Context) {
var rsp proto.Response
command := fmt.Sprintf("%s stop", WiFiScript)
cmd := exec.Command("sh", "-c", command)
output, err := cmd.CombinedOutput()
if err != nil {
log.Errorf("failed to disconnect wifi: %s", output)
rsp.ErrRsp(c, -1, "failed to stop wifi")
return
}
time.Sleep(5 * time.Second)
_ = os.Remove(WiFiSSID)
_ = os.Remove(WiFiPasswd)
rsp.OkRsp(c)
log.Debugf("stop wifi successfully")
}
func connect(ssid string, password string) error {
if err := os.WriteFile(WiFiSSID, []byte(ssid), 0o644); err != nil {
log.Errorf("failed to save wifi ssid: %s", err)
return err
}
if err := os.WriteFile(WiFiPasswd, []byte(password), 0o644); err != nil {
log.Errorf("failed to save wifi password: %s", err)
return err
}
if err := os.WriteFile(WiFiConnect, nil, 0o644); err != nil {
log.Errorf("failed to connect wifi: %s", err)
rsp.ErrRsp(c, -4, "failed to connect wifi")
return
return err
}
rsp.OkRsp(c)
log.Debugf("set wifi successfully: %s", req.Ssid)
return nil
}
func isSupported() bool {
_, err := os.Stat(WiFiExistFile)
return err == nil
}
func isAPMode() bool {
_, err := os.Stat(WiFiApModeFile)
return err == nil
}
func isConnected() bool {
content, err := os.ReadFile(WiFiStateFile)
if err != nil {
return false
}
state := strings.ReplaceAll(string(content), "\n", "")
return state == "1"
}
func getWiFiSsid() string {
ssidByte, err := os.ReadFile(WiFiSSID)
if err != nil {
return ""
}
return strings.ReplaceAll(string(ssidByte), "\n", "")
}

View File

@@ -0,0 +1,104 @@
package vm
import (
"NanoKVM-Server/proto"
"fmt"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const autostartDirectory = "/etc/kvm/autostart"
func (s *Service) GetAutostart(c *gin.Context) {
var rsp proto.Response
var files []string
err := filepath.Walk(autostartDirectory, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
files = append(files, info.Name())
}
return nil
})
if err != nil {
rsp.ErrRsp(c, -1, "get autostart directory fail")
return
}
rsp.OkRspWithData(c, &proto.GetAutostartRsp{
Files: files,
})
log.Debugf("get autostart total %d", len(files))
}
func (s *Service) UploadAutostart(c *gin.Context) {
var req proto.UploadAutostartReq
var rsp proto.Response
fileName := c.Param("name")
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "parse form request fail")
return
}
if _, err := os.Stat(autostartDirectory); err != nil {
_ = os.MkdirAll(autostartDirectory, 0o755)
}
target := fmt.Sprintf("%s/%s", autostartDirectory, fileName)
f, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
rsp.ErrRsp(c, -1, "create file fail")
return
}
defer f.Close()
_, err = f.WriteString(req.Content)
if err != nil {
rsp.ErrRsp(c, -1, "write content fail")
return
}
rsp.OkRspWithData(c, fileName)
log.Debugf("upload autostart %s success", fileName)
}
func (s *Service) DeleteAutostart(c *gin.Context) {
var rsp proto.Response
fileName := c.Param("name")
file := fmt.Sprintf("%s/%s", autostartDirectory, fileName)
if err := os.Remove(file); err != nil {
log.Errorf("delete autostart file %s fail", fileName)
rsp.ErrRsp(c, -3, "remove file fail")
return
}
rsp.OkRsp(c)
log.Debugf("delete autostart %s success", fileName)
}
func (s *Service) GetAutostartContent(c *gin.Context) {
var rsp proto.Response
fileName := c.Param("name")
file := fmt.Sprintf("%s/%s", autostartDirectory, fileName)
content, err := os.ReadFile(file)
if err != nil {
rsp.ErrRsp(c, -1, "read file fail")
return
}
rsp.OkRspWithData(c, string(content))
log.Debugf("get autostart content %s success", fileName)
}

View File

@@ -50,6 +50,7 @@ func (s *Service) Terminal(c *gin.Context) {
defer func() {
_ = ptmx.Close()
_ = cmd.Process.Kill()
_ = cmd.Wait()
}()
go wsWrite(ws, ptmx)

View File

@@ -95,10 +95,11 @@ typedef struct {
int8_t type = -1; // cat /kvmapp/kvm/type
int8_t now_fps = -1; // cat /kvmapp/kvm/now_fps
int16_t qlty = -1; // cat /kvmapp/kvm/qlty
uint8_t oled_sleep_param = 0;
uint16_t oled_sleep_param = 0;
uint8_t oled_sleep_state = 0; // 0:wakeup; 1:sleep;
uint64_t oled_sleep_start = 0;
uint64_t ue_patch_state = 0;
} kvm_oled_state_t;
#endif // CONFIG_H_

View File

@@ -1,3 +1,3 @@
VITE_SERVER_IP=192.168.0.65
VITE_SERVER_IP=192.168.0.1
VITE_SERVER_PORT=80
VITE_WITH_CREDENTIALS=false

View File

@@ -12,6 +12,7 @@
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@radix-ui/react-scroll-area": "^1.2.10",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
@@ -22,7 +23,7 @@
"i18next": "^23.16.4",
"jotai": "^2.10.1",
"js-cookie": "^3.0.5",
"lucide-react": "^0.469.0",
"lucide-react": "^0.562.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-draggable": "^4.4.6",

189
web/pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@ant-design/icons':
specifier: ^5.6.1
version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@xterm/addon-attach':
specifier: ^0.11.0
version: 0.11.0(@xterm/xterm@5.5.0)
@@ -42,8 +45,8 @@ importers:
specifier: ^3.0.5
version: 3.0.5
lucide-react:
specifier: ^0.469.0
version: 0.469.0(react@18.3.1)
specifier: ^0.562.0
version: 0.562.0(react@18.3.1)
react:
specifier: ^18.3.1
version: 18.3.1
@@ -640,9 +643,15 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
'@radix-ui/primitive@1.1.0':
resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-compose-refs@1.1.0':
resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==}
peerDependencies:
@@ -652,6 +661,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.1':
resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==}
peerDependencies:
@@ -661,6 +679,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.2':
resolution: {integrity: sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==}
peerDependencies:
@@ -674,6 +701,15 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.1':
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.1':
resolution: {integrity: sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==}
peerDependencies:
@@ -744,6 +780,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.0.0':
resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==}
peerDependencies:
@@ -757,6 +806,32 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-scroll-area@1.2.10':
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.1.0':
resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==}
peerDependencies:
@@ -766,6 +841,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-callback-ref@1.1.0':
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
peerDependencies:
@@ -775,6 +859,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.1.0':
resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==}
peerDependencies:
@@ -802,6 +895,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.1':
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@rc-component/async-validator@5.0.4':
resolution: {integrity: sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==}
engines: {node: '>=14.x'}
@@ -1982,8 +2084,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.469.0:
resolution: {integrity: sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==}
lucide-react@0.562.0:
resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -3551,20 +3653,36 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.0': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-context@1.1.1(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-context@1.1.2(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-dialog@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -3587,6 +3705,12 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
'@radix-ui/react-direction@1.1.1(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-dismissable-layer@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -3644,6 +3768,16 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
'@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
'@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1)
@@ -3653,6 +3787,32 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
'@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.12)(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
'@radix-ui/react-slot@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1)
@@ -3660,12 +3820,25 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-slot@1.2.3(@types/react@18.3.12)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1)
@@ -3686,6 +3859,12 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
'@rc-component/async-validator@5.0.4':
dependencies:
'@babel/runtime': 7.26.0
@@ -5026,7 +5205,7 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.469.0(react@18.3.1):
lucide-react@0.562.0(react@18.3.1):
dependencies:
react: 18.3.1

View File

@@ -1,4 +1,5 @@
import { http } from '@/lib/http.ts';
import { getBaseUrl } from '@/lib/service.ts';
// get application version
export function getVersion() {
@@ -14,6 +15,16 @@ export function update() {
});
}
// offline update application
export function offlineUpdate(data: FormData) {
const baseUrl = getBaseUrl('http');
const url = `${baseUrl}/api/application/update/offline`;
return fetch(url, {
method: 'POST',
body: data
});
}
// enable/disable preview updates
export function setPreviewUpdates(enable: boolean) {
const data = {
@@ -26,4 +37,3 @@ export function setPreviewUpdates(enable: boolean) {
export function getPreviewUpdates() {
return http.get('/api/application/preview');
}

20
web/src/api/autostart.ts Normal file
View File

@@ -0,0 +1,20 @@
import { http } from '@/lib/http.ts';
export function getAutostart() {
return http.get('/api/vm/autostart');
}
export function uploadAutostart(name: string, content: string) {
return http.post('/api/vm/autostart/' + name, { content });
}
export function deleteAutostart(name: string) {
return http.request({
url: '/api/vm/autostart/' + name,
method: 'delete',
});
}
export function getAutostartContent(name: string) {
return http.get('/api/vm/autostart/' + name);
}

View File

@@ -31,11 +31,25 @@ export function getWiFi() {
return http.get('/api/network/wifi');
}
// connect wifi
export function connectWifi(ssid: string, password: string) {
// connect wifi without auth (only available in wifi configuration mode)
export function connectWifiNoAuth(ssid: string, password: string) {
const data = {
ssid,
password
};
return http.post('/api/network/wifi', data);
}
// connect wifi
export function connectWifi(ssid: string, password: string) {
const data = {
ssid,
password
};
return http.post('/api/network/wifi/connect', data);
}
// disconnect wifi
export function disconnectWifi() {
return http.post('/api/network/wifi/disconnect');
}

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import clsx from 'clsx';
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={clsx('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px]"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={clsx(
'flex touch-none select-none p-px transition-colors',
orientation === 'vertical' && 'h-full w-2 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2 flex-col border-t border-t-transparent',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full bg-neutral-500/30"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -14,7 +14,8 @@ const de = {
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',
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',
@@ -35,13 +36,15 @@ const de = {
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.'
change4:
'Um die Passwörter zurückzusetzen, drücken und halten Sie den BOOT Knopf auf dem NanoKVM.'
}
},
wifi: {
title: 'Wi-Fi',
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.',
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: 'Fertig'
@@ -52,7 +55,7 @@ const de = {
resolution: 'Auflösung',
auto: 'Automatisch',
autoTips:
"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.",
'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',
@@ -74,7 +77,8 @@ const de = {
virtual: 'Tastatur',
ctrlaltdel: 'Ctrl+Alt+Del',
dropdownEnglish: 'Englisch',
dropdownGerman: 'Deutsch'
dropdownGerman: 'Deutsch',
dropdownRussian: 'Russisch'
},
mouse: {
title: 'Maus',
@@ -88,7 +92,8 @@ const de = {
mode: 'Maus Modus',
absolute: 'Absoluter Modus',
relative: 'Relativer Modus',
requestPointer: 'Relativer Modus aktiv. Klicken Sie auf den Desktop um den Mauszeiger zu sehen.',
requestPointer:
'Relativer Modus aktiv. Klicken Sie auf den Desktop um den Mauszeiger zu sehen.',
resetHid: 'HID zurücksetzen',
hidOnly: {
title: 'HID-Only Modus',
@@ -106,23 +111,23 @@ const de = {
empty: 'Nichts gefunden',
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.",
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: '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.'
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: {
@@ -168,7 +173,11 @@ const de = {
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'
disabled:
'/data Partition ist nur-lesbar, daher kann das Systemabbild nicht heruntergeladen werden',
uploadbox: 'Datei hier ablegen oder klicken zum Auswählen',
inputfile: 'Bitte geben Sie die Datei für das Systemabbild an',
NoISO: 'Keine ISO'
},
power: {
title: 'Power',
@@ -199,9 +208,9 @@ const de = {
hostname: 'Hostname',
hostnameUpdated: 'Hostname aktualisiert. Neustarten um zu übernehmen.',
ipType: {
'Wired': 'Kabel',
'Wireless': 'Drahtlos',
'Other': 'Andere'
Wired: 'Kabel',
Wireless: 'Drahtlos',
Other: 'Andere'
}
},
appearance: {
@@ -242,7 +251,7 @@ const de = {
title: 'Swap',
disable: 'Deaktivieren',
description: 'Grösse der Swap-Datei festlegen',
tip: "Das Aktivieren dieser Funktion kann die Lebensdauer Ihrer SD-Karte verkürzen!"
tip: 'Das Aktivieren dieser Funktion kann die Lebensdauer Ihrer SD-Karte verkürzen!'
},
mouseJiggler: {
title: 'Mausaktivitäts-Simulator',
@@ -253,7 +262,7 @@ const de = {
},
mdns: {
description: 'mDNS-Erkennungsdienst aktivieren',
tip: "Deaktivieren Sie den Dienst, wenn Sie ihn nicht benötigen"
tip: 'Deaktivieren Sie den Dienst, wenn Sie ihn nicht benötigen'
},
hidOnly: 'HID-Only Mode',
disk: 'Virtuelle Festplatte',
@@ -269,7 +278,7 @@ const de = {
title: 'Tailscale',
memory: {
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.",
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: 'Tailscale neu starten?',
@@ -314,7 +323,15 @@ const de = {
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!'
'Bitte beachten Sie, dass Vorab-Versionen womöglich noch Fehler oder unvollständige Funktionen enthalten!',
offline: {
title: 'Offline Aktualisierung',
desc: 'Über lokales Installationspaket aktualisieren',
upload: 'Hochladen',
invalidName:
'Ungültiges Dateinamenformat. Bitte laden Sie von den GitHub-Releases herunter.',
updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.'
}
},
account: {
title: 'Konto',
@@ -328,7 +345,7 @@ const de = {
}
},
error: {
title: "Wir sind auf ein Problem gestossen",
title: 'Wir sind auf ein Problem gestossen',
refresh: 'Neuladen'
},
fullscreen: {

View File

@@ -41,12 +41,15 @@ const en = {
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi for NanoKVM',
success: 'Please check the network status of NanoKVM and visit the new IP address.',
success: 'Please go to the device to check the network status of NanoKVM.',
failed: 'Operation failed, please try again.',
invalidMode:
'The current mode does not support network setup. Please go to your device and enable Wi-Fi configuration mode.',
confirmBtn: 'Ok',
finishBtn: 'Finished'
},
screen: {
scale: 'Scale',
title: 'Screen',
video: 'Video Mode',
videoDirectTips: 'Enable HTTPS in "Settings > Device" to use this mode',
@@ -79,7 +82,8 @@ const en = {
'Clipboard permission denied. Please allow clipboard access in your browser.',
clipboardReadError: 'Failed to read clipboard',
dropdownEnglish: 'English',
dropdownGerman: 'German'
dropdownGerman: 'German',
dropdownRussian: 'Russian'
},
mouse: {
title: 'Mouse',
@@ -93,6 +97,9 @@ const en = {
mode: 'Mouse mode',
absolute: 'Absolute mode',
relative: 'Relative mode',
direction: 'Wheel direction',
scrollUp: 'Scroll up',
scrollDown: 'Scroll down',
speed: 'Wheel speed',
fast: 'Fast',
slow: 'Slow',
@@ -183,7 +190,10 @@ const en = {
title: 'Image Downloader',
input: 'Please enter a remote image URL',
ok: 'Ok',
disabled: '/data partition is RO, so we cannot download the image'
disabled: '/data partition is RO, so we cannot download the image',
uploadbox: 'Drop file here or click to select',
inputfile: 'Please enter the image File',
NoISO: 'No ISO'
},
power: {
title: 'Power',
@@ -223,6 +233,7 @@ const en = {
title: 'Appearance',
display: 'Display',
language: 'Language',
languageDesc: 'Select the language for the interface',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar',
webTitle: 'Web Title',
@@ -246,7 +257,17 @@ const en = {
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi',
setBtn: 'Config'
apMode: 'AP mode is enabled, connect to Wi-Fi by scanning QR code',
connect: 'Join Wi-Fi',
connectDesc1: 'Please enter the network ssid and password',
connectDesc2: 'Please enter the password to join this network',
disconnect: 'Are you sure to disconnect the network?',
failed: 'Connection failed, please try again.',
ssid: 'Name',
password: 'Password',
joinBtn: 'Join',
confirmBtn: 'Ok',
cancelBtn: 'Cancel'
},
ssh: {
description: 'Enable SSH remote access',
@@ -277,9 +298,21 @@ const en = {
hdmi: {
description: 'Enable HDMI/monitor output'
},
autostart: {
title: 'Autostart Scripts Settings',
description: 'Manage scripts that run automatically on system startup',
new: 'New',
deleteConfirm: 'Are you sure you want to delete this file?',
yes: 'Yes',
no: 'No',
scriptName: 'Autostart Script Name',
scriptContent: 'Autostart Script Content',
settings: 'Settings'
},
hidOnly: 'HID-Only Mode',
hidOnlyDesc: 'Stop emulating virtual devices, retaining only basic HID control',
disk: 'Virtual Disk',
diskDesc: 'Mount virtual U-disk on the remote host',
diskDesc: 'Mount SD card on the remote host',
network: 'Virtual Network',
networkDesc: 'Mount virtual network card on the remote host',
reboot: 'Reboot',
@@ -342,7 +375,14 @@ const en = {
preview: 'Preview Updates',
previewDesc: 'Get early access to new features and improvements',
previewTip:
'Please be aware that preview releases may contain bugs or incomplete functionality!'
'Please be aware that preview releases may contain bugs or incomplete functionality!',
offline: {
title: 'Offline Updates',
desc: 'Update through local installation package',
upload: 'Upload',
invalidName: 'Invalid filename format. Please download from GitHub releases.',
updateFailed: 'Update failed. Please retry.'
}
},
account: {
title: 'Account',

View File

@@ -1,4 +1,4 @@
const en = {
const es = {
translation: {
head: {
desktop: 'Escritorio remoto',
@@ -9,51 +9,49 @@ const en = {
},
auth: {
login: 'Iniciar sesión',
placeholderUsername: 'Introduce tu usuario',
placeholderUsername: 'Introduce tu nombre de usuario',
placeholderPassword: 'Introduce tu contraseña',
placeholderPassword2: 'Introduce tu contraseña de nuevo',
noEmptyUsername: 'El usuario no puede estar vacío',
noEmptyUsername: 'El nombre de usuario no puede estar vacío',
noEmptyPassword: 'La contraseña no puede estar vacía',
noAccount:
'No se ha encontrado la cuenta. Por favor, recarga la página o recupera tu contraseña.',
invalidUser: 'Usuario o contraseña incorrectos',
noAccount: 'No se ha encontrado la cuenta. Por favor, recarga la página o recupera tu contraseña.',
invalidUser: 'Nombre de usuario o contraseña incorrectos',
error: 'Error inesperado',
changePassword: 'Cambiar contraseña',
changePasswordDesc: 'Para la seguridad de su dispositivo, por favor, modifique la contraseña de inicio de sesión en la web.',
differentPassword: 'Las contraseñas no coinciden',
changePasswordDesc:
'Para la seguridad de su dispositivo, por favor modifique la contraseña de inicio de sesión en la web.',
illegalUsername: 'El usuario contiene caracteres no permitidos',
illegalUsername: 'El nombre de usuario contiene caracteres no permitidos',
illegalPassword: 'La contraseña contiene caracteres no permitidos',
forgetPassword: 'Contraseña olvidada',
ok: 'Aceptar',
cancel: 'Cancelar',
loginButtonText: 'Iniciar sesión',
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: 'Para restablecer las contraseñas, mantén pulsado el botón BOOT del NanoKVM durante 10 segundos.',
reset2: 'Para ver los pasos detallados, consulta este documento:',
reset3: 'Cuenta predeterminada de la interfaz web:',
reset4: 'Cuenta predeterminada de SSH:',
change1: 'Ten en cuenta que esta acción cambiará las siguientes contraseñas:',
change2: 'Contraseña de acceso web',
change3: 'Contraseña root del sistema (contraseña de acceso por SSH)',
change4: 'Para restablecer las contraseñas, mantén pulsado el botón BOOT del 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: 'Configura el Wi-Fi para el NanoKVM',
success: 'Comprueba el estado de red del NanoKVM y accede a la nueva dirección IP.',
failed: 'La operación ha fallado, vuelve a intentarlo.',
confirmBtn: 'Aceptar',
finishBtn: 'Finalizado'
},
screen: {
title: 'Pantalla',
video: 'Modo de vídeo',
videoDirectTips: 'Habilita HTTPS en "Ajustes > Dispositivo" para usar este modo',
resolution: 'Resolución',
auto: 'Automático',
autoTips:
'En determinadas resoluciones pueden producirse rasgado de imagen o desplazamiento del ratón. Prueba a ajustar la resolución del host remoto o desactiva el modo automático.',
autoTips: 'En determinadas resoluciones pueden producirse rasgado de imagen (tearing) o desplazamiento del ratón. Prueba a ajustar la resolución del host remoto o desactiva el modo automático.',
fps: 'FPS',
customizeFps: 'Personalizar',
quality: 'Calidad',
@@ -62,19 +60,26 @@ const en = {
qualityMedium: 'Medio',
qualityLow: 'Bajo',
frameDetect: 'Detectar fotogramas',
frameDetectTip:
'Calcula la diferencia entre fotogramas. Para de transmitir vídeo cuando no se detectan cambios en la pantalla del host remoto.',
resetHdmi: 'Reset HDMI'
frameDetectTip: 'Calcula la diferencia entre fotogramas. Para de transmitir vídeo cuando no se detectan cambios en la pantalla del host remoto.',
resetHdmi: 'Reiniciar HDMI'
},
keyboard: {
title: 'Teclado',
paste: 'Pegar',
tips: 'Solo están soportadas las letras y símbolos estándar del teclado',
tips: 'Sólo están soportadas las letras y símbolos estándar del teclado',
placeholder: 'Por favor, introduce el texto',
submit: 'Enviar',
virtual: 'Teclado virtual',
ctrlaltdel: 'Ctrl+Alt+Del'
ctrlaltdel: 'Ctrl+Alt+Del',
readClipboard: 'Leer del portapapeles',
clipboardPermissionDenied: 'Permiso de portapapeles denegado. Por favor, permite el acceso al portapapeles en tu navegador.',
clipboardReadError: 'Error al leer del portapapeles',
dropdownEnglish: 'Inglés',
dropdownGerman: 'Alemán',
},
mouse: {
title: 'Ratón',
cursor: 'Estilo de cursor',
default: 'Cursor por defecto',
pointer: 'Cursor de puntero',
cell: 'Cursor de celda',
@@ -84,17 +89,35 @@ const en = {
mode: 'Modo de ratón',
absolute: 'Modo absoluto',
relative: 'Modo relativo',
requestPointer:
'Usando modo relativo. Por favor, haz clic en el escritorio para obtener el cursor del ratón.',
resetHid: 'Restablecer HID'
speed: 'Velocidad de la rueda',
fast: 'Rápida',
slow: 'Lenta',
requestPointer: 'Usando modo relativo. Por favor, haz clic en el escritorio para obtener el cursor del ratón.',
resetHid: 'Restablecer HID',
hidOnly: {
title: 'Modo sólo HID',
desc: 'Si tu ratón y teclado dejan de responder y restablecer el HID no ayuda, podría ser un problema de compatibilidad entre el NanoKVM y el dispositivo. Prueba a habilitar el modo sólo HID para mejorar la compatibilidad.',
tip1: 'Habilitar el modo sólo HID desmontará el disco virtual y la red virtual',
tip2: 'En modo sólo HID, el montaje de imágenes está deshabilitado',
tip3: 'El NanoKVM se reiniciará automáticamente después de cambiar de modo',
enable: 'Habilitar modo sólo HID',
disable: 'Desactivar modo sólo HID'
}
},
image: {
title: 'Imágenes',
loading: 'Cargando...',
empty: 'No se ha encontrado nada',
mountMode: 'Modo de montaje',
mountFailed: 'Fallo al montar',
mountDesc:
'En algunos sistemas, es necesario expulsar el disco virtual en el host remoto antes de montar una imagen.',
mountDesc: 'En algunos sistemas, es necesario expulsar el disco virtual del host remoto antes de montar una imagen.',
unmountFailed: 'Fallo al desmontar',
unmountDesc: 'En algunos sistemas, es necesario expulsar manualmente el disco virtual desde el host remoto antes de desmontar la imagen.',
refresh: 'Actualizar la lista de imágenes',
attention: 'Atención',
deleteConfirm: '¿Estás seguro de que deseas eliminar esta imagen?',
okBtn: 'Sí',
cancelBtn: 'No',
tips: {
title: 'Cómo subir imágenes',
usb1: 'Conecta el NanoKVM a tu computadora mediante USB.',
@@ -103,12 +126,12 @@ const en = {
scp1: 'Asegúrate de que el NanoKVM y tu computadora estén en la misma red local.',
scp2: 'Abre una terminal en tu computadora y usa el comando SCP para subir el archivo de imagen al directorio /data en el NanoKVM.',
scp3: 'Ejemplo: scp tu-ruta-de-imagen root@tu-ip-del-nanokvm:/data',
tfCard: 'Tarjeta TF',
tfCard: 'Tarjeta SD',
tf1: 'Este método es compatible con el sistema Linux',
tf2: 'Obtén la tarjeta TF del NanoKVM (para la versión FULL, desmonta la carcasa primero).',
tf3: 'Inserta la tarjeta TF en un lector de tarjetas y conéctalo a tu computadora.',
tf4: 'Copia el archivo de imagen en el directorio /data de la tarjeta TF.',
tf5: 'Inserta la tarjeta TF en el NanoKVM.'
tf2: 'Obtén la tarjeta SD del NanoKVM (para la versión FULL, desmonta la carcasa primero).',
tf3: 'Inserta la tarjeta SD en un lector de tarjetas y conéctalo a tu computadora.',
tf4: 'Copia el archivo de imagen en el directorio /data de la tarjeta SD.',
tf5: 'Inserta la tarjeta SD en el NanoKVM.'
}
},
script: {
@@ -118,7 +141,7 @@ const en = {
runBackground: 'Ejecutar en segundo plano',
runFailed: 'Ejecución fallida',
attention: 'Atención',
delDesc: '¿Estás seguro que deseas eliminar este archivo?',
delDesc: '¿Estás seguro de que deseas eliminar este archivo?',
confirm: 'Sí',
cancel: 'No',
delete: 'Eliminar',
@@ -131,6 +154,16 @@ const en = {
serialPort: 'Puerto Serie',
serialPortPlaceholder: 'Por favor, introduce el puerto serie',
baudrate: 'Tasa de baudios',
parity: 'Paridad',
parityNone: 'Ninguna',
parityEven: 'Par',
parityOdd: 'Impar',
flowControl: 'Control de flujo',
flowControlNone: 'Ninguno',
flowControlSoft: 'Software',
flowControlHard: 'Hardware',
dataBits: 'Bits de datos',
stopBits: 'Bits de parada',
confirm: 'Confirmar'
},
wol: {
@@ -138,17 +171,29 @@ const en = {
sending: 'Enviando comando...',
sent: 'Comando enviado',
input: 'Por favor, introduce la dirección MAC',
ok: 'Vale'
ok: 'Aceptar'
},
download: {
title: 'Descargador de imágenes',
input: 'Por favor, introduce la URL de una imagen remota',
ok: 'Aceptar',
disabled: 'La partición /data es de sólo lectura, no se puede descargar la imagen'
},
power: {
title: 'Encender / Apagar',
showConfirm: 'Confirmación',
showConfirmTip: 'Las operaciones de encendido requieren confirmación adicional',
reset: 'Reiniciar',
power: 'Encender / Apagar',
powerShort: 'Encender / Apagar (pulsación corta)',
powerLong: 'Encender / Apagar (pulsación larga)'
powerLong: 'Encendido/Apagado (pulsación larga)',
resetConfirm: '¿Desea proceder con la operación de reinicio?',
powerConfirm: '¿Desea proceder con la operación de encendido?',
okBtn: 'Sí',
cancelBtn: 'No'
},
settings: {
title: 'Settings',
title: 'Ajustes',
about: {
title: 'Sobre NanoKVM',
information: 'Información',
@@ -159,65 +204,115 @@ const en = {
image: 'Versión de la imagen',
imageTip: 'NanoKVM system image version',
deviceKey: 'Clave del dispositivo',
community: 'Comunidad'
community: 'Comunidad',
hostname: 'Nombre del host',
hostnameUpdated: 'Nombre del host actualizado. Reinicia para aplicar.',
ipType: {
Wired: 'Cableada',
Wireless: 'Inalámbrica',
Other: 'Otra'
}
},
appearance: {
title: 'Appearance',
display: 'Display',
language: 'Language',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar'
title: 'Apariencia',
display: 'Pantalla',
language: 'Idioma',
menuBar: 'Barra de menú',
menuBarDesc: 'Mostrar iconos en la barra de menú',
webTitle: 'Título web',
webTitleDesc: 'Personaliza el título de la página web'
},
device: {
title: 'Device',
title: 'Dispositivo',
oled: {
title: 'OLED',
description: 'OLED screen automatically sleep',
0: 'Never',
15: '15 sec',
30: '30 sec',
description: 'La pantalla OLED entra en reposo automáticamente',
0: 'Nunca',
15: '15 s',
30: '30 s',
60: '1 min',
180: '3 min',
300: '5 min',
600: '10 min',
1800: '30 min',
3600: '1 hour'
3600: '1 hora'
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi',
setBtn: 'Config'
description: 'Configura el Wi-Fi',
setBtn: 'Configurar'
},
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: 'Habilitar acceso remoto SSH',
tip: 'Establece una contraseña segura antes de habilitar (Cuenta - Cambiar contraseña)'
},
tls: {
description: 'Habilitar protocolo HTTPS',
tip: 'Aviso: Usar HTTPS puede aumentar la latencia, especialmente en modo de vídeo MJPEG.'
},
advanced: 'Ajustes avanzados',
swap: {
title: 'Memoria Swap',
disable: 'Desactivar',
description: 'Establece el tamaño del archivo swap',
tip: 'Habilitar esta función podría acortar la vida útil de tu tarjeta SD.'
},
mouseJiggler: {
title: 'Mouse Jiggler',
description: 'Evitar que el host remoto entre en reposo',
disable: 'Desactivar',
absolute: 'Modo absoluto',
relative: 'Modo relativo'
},
mdns: {
description: 'Habilitar servicio de descubrimiento mDNS',
tip: 'Desactívalo si no es necesario'
},
hdmi: {
description: 'Habilitar salida HDMI/monitor'
},
hidOnly: 'Modo sólo HID',
disk: 'Disco Virtual',
diskDesc: 'Montar disco virtual en el host remoto',
media: 'Imagen virtual',
mediaDesc: 'Adjuntar dispositivo de imagen virtual al host remoto',
network: 'Red Virtual',
networkDesc: 'Montar tarjeta de red virtual en el host remoto',
reboot: 'Reiniciar',
rebootDesc: '¿Estás seguro de que deseas reiniciar el NanoKVM?',
usb: 'Interfaz USB',
usbDesc: 'Habilitar interfaz USB hacia el host',
okBtn: 'Sí',
cancelBtn: 'No'
},
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: 'Optimización de memoria',
tip: 'Cuando el uso de memoria supera el límite, la recolección de basura se ejecuta de forma más agresiva para intentar liberar memoria. Es necesario reiniciar Tailscale para que el cambio surta efecto.'
},
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.',
swap: {
title: 'Memoria Swap',
tip: 'Si los problemas persisten después de habilitar la optimización de memoria, prueba a activar la memoria swap. Esto establece el tamaño del archivo swap en 256 MB por defecto, que se puede ajustar en "Ajustes > Dispositivo".'
},
restart: '¿Seguro que deseas reiniciar Tailscale?',
stop: '¿Seguro que deseas detener Tailscale?',
stopDesc: 'Cerrar sesión en Tailscale y desactivar su inicio automático al arrancar.',
loading: 'Cargando...',
notInstall: '¡Tailscale no encontrado! Por favor, instálalo.',
install: 'Instalar',
installing: 'Instalando',
failed: 'La instalación falló',
retry:
'Por favor, actualiza la página e inténtalo de nuevo. O intenta instalarlo manualmente',
retry: 'Por favor, actualiza la página e inténtalo de nuevo. O intenta instalarlo manualmente',
download: 'Descargar el',
package: 'paquete de instalación',
unzip: 'y descomprimirlo',
upTailscale: 'Sube tailscale al directorio /usr/bin/ del NanoKVM',
upTailscaled: 'Sube tailscaled al directorio /usr/sbin/ del NanoKVM',
refresh: 'Actualizar la página actual',
notLogin:
'El dispositivo aún no ha sido vinculado. Por favor, inicia sesión y vincula este dispositivo a tu cuenta.',
notRunning: 'Tailscale no se está ejecutando. Por favor, inícialo para continuar.',
run: 'Iniciar',
notLogin: 'El dispositivo aún no ha sido vinculado. Por favor, inicia sesión y vincula este dispositivo a tu cuenta.',
urlPeriod: 'Esta URL es válida por 10 minutos',
login: 'Iniciar sesión',
loginSuccess: 'Inicio de sesión exitoso',
@@ -226,8 +321,10 @@ const en = {
deviceIP: 'IP del dispositivo',
account: 'Cuenta',
logout: 'Cerrar sesión',
logout2: '¿Seguro que quieres cerrar sesión?',
okBtn: 'Yes',
logoutDesc: '¿Estás seguro de que deseas cerrar sesión?',
uninstall: 'Desinstalar Tailscale',
uninstallDesc: '¿Estás seguro de que deseas desinstalar Tailscale?',
okBtn: 'Sí',
cancelBtn: 'No'
},
update: {
@@ -238,17 +335,34 @@ const en = {
available: 'Hay una actualización disponible. ¿Estás seguro de que quieres actualizar?',
updating: 'Actualización iniciada. Por favor, espera...',
confirm: 'Confirmar',
cancel: 'Cancelar'
cancel: 'Cancelar',
preview: 'Vista previa de actualizaciones',
previewDesc: 'Accede anticipadamente a nuevas funciones y mejoras',
previewTip: 'Ten en cuenta que las versiones de vista previa pueden contener errores o funcionalidades incompletas'
},
account: {
title: 'Account',
webAccount: 'Web Account Name',
password: 'Password',
updateBtn: 'Update',
logoutBtn: 'Logout'
title: 'Cuenta',
webAccount: 'Nombre de la cuenta web',
password: 'Contraseña',
updateBtn: 'Actualizar',
logoutBtn: 'Cerrar sesión',
logoutDesc: '¿Estás seguro de que deseas cerrar sesión?',
okBtn: 'Sí',
cancelBtn: 'No'
}
},
error: {
title: 'Hemos encontrado un problema',
refresh: 'Actualizar'
},
fullscreen: {
toggle: 'Activar/Desactivar pantalla completa'
},
menu: {
collapse: 'Colapsar menú',
expand: 'Expandir menú'
}
}
};
export default en;
export default es;

View File

@@ -106,11 +106,18 @@ const ko = {
title: '이미지',
loading: '불러오는 중...',
empty: '아무것도 없습니다.',
cdrom: 'CD-ROM 모드로 이미지 마운트',
mountFailed: '이미지 마운트 실패',
mountMode: '마운트 모드',
mountFailed: '마운트 실패',
mountDesc:
'일부 시스템에서는 이미지를 마운트하기 전에 원격 호스트에서 가상 디스크를 제거해야 합니다.',
unmountFailed: '언마운트 실패',
unmountDesc:
'일부 시스템에서는 이미지를 언마운트하기 전에 원격 호스트에서 수동으로 제거하여야 합니다.',
refresh: '이미지 목록 새로고침',
attention: '주의',
deleteConfirm: '이 이미지를 제거하시겠습니까?',
okBtn: '네',
cancelBtn: '아니오',
tips: {
title: '업로드 방법',
usb1: 'USB를 통해 NanoKVM을 컴퓨터에 연결하세요.',
@@ -278,8 +285,11 @@ const ko = {
title: 'Tailscale',
memory: {
title: '메모리 최적화',
tip: '메모리 사용량이 제한을 초과하면 가비지 컬렉션이 더 적극적으로 실행되어 메모리를 확보하려고 시도합니다. Tailscale을 사용할 경우 50MB로 설정하는 것이 좋습니다. 변경 사항을 적용하려면 Tailscale을 다시 시작해야 합니다.',
disable: '비활성화'
tip: '메모리 사용량이 제한을 초과하면 가비지 컬렉션이 더 적극적으로 실행되어 메모리를 확보하려고 시도합니다. Tailscale을 사용할 경우 50MB로 설정하는 것이 좋습니다. 변경 사항을 적용하려면 Tailscale을 다시 시작해야 합니다.'
},
swap: {
title: '스왑 메모리',
tip: '메모리 최적화를 활성화한 후에도 문제가 지속되면 스왑 메모리를 활성화해 보세요. 이 설정은 스왑 파일 크기를 기본값으로 256MB로 설정하며, "설정 > 기기"에서 조정할 수 있습니다.'
},
restart: '정말로 Tailscale을 다시 시작하시겠습니까?',
stop: '정말로 Tailscale을 중지하시겠습니까?',
@@ -293,9 +303,11 @@ const ko = {
download: '다운로드 중 :',
package: '패키지 설치',
unzip: '압축 해제',
upTailscale: 'tailscale을 NanoKVM 의 다음 경로에 업로드 했습니다. : /usr/bin/',
upTailscaled: 'tailscaled을 NanoKVM 의 다음 경로에 업로드 했습니다. : /usr/sbin/',
upTailscale: 'tailscale을 NanoKVM의 /usr/bin/ 경로에 업로드 했습니다.',
upTailscaled: 'tailscaled을 NanoKVM의 /usr/sbin/ 경로에 업로드 했습니다.',
refresh: '현재 페이지 새로고침',
notRunning: 'Tailscale이 실행되고 있지 않습니다. 계속하려면 시작해 주세요.',
run: '시작',
notLogin:
'이 기기는 현재 연동 되지 않았습니다. 로그인해서 계정에 이 장치를 연동하세요.',
urlPeriod: '이 주소는 10분간 유효합니다.',
@@ -307,8 +319,9 @@ const ko = {
account: '계정',
logout: '로그아웃',
logoutDesc: '정말로 로그아웃 하시겠습니까?',
logout2: '정말로 로그아웃 합니까?',
logout2: '정말로 로그아웃 할까요?',
uninstall: 'Tailscale 제거',
uninstallDesc: '정말로 Tailscale을 제거할까요?',
okBtn: '네',
cancelBtn: '아니오'
},

View File

@@ -73,7 +73,10 @@ const ru = {
placeholder: 'Текст для ввода',
submit: 'Вставить',
virtual: 'Клавиатура',
ctrlaltdel: 'Ctrl+Alt+Del'
ctrlaltdel: 'Ctrl+Alt+Del',
dropdownEnglish: 'Английский',
dropdownGerman: 'Немецкий',
dropdownRussian: 'Русский'
},
mouse: {
title: 'Мышь',

View File

@@ -73,7 +73,12 @@ const se = {
placeholder: 'Ange text',
submit: 'Skicka',
virtual: 'Tangentbord',
ctrlaltdel: 'Ctrl+Alt+Del'
ctrlaltdel: 'Ctrl+Alt+Del',
readClipboard: 'Läs från Urklipp',
clipboardPermissionDenied: 'Behörighet till Urklipp nekad. Vänligen tillåt åtkomst till Urklipp i din webbläsare.',
clipboardReadError: 'Misslyckades med att läsa Urklipp',
dropdownEnglish: 'Engelska',
dropdownGerman: 'Tyska'
},
mouse: {
title: 'Mus',
@@ -106,11 +111,18 @@ const se = {
title: 'Avbildningar',
loading: 'Laddar...',
empty: 'Inget hittades',
mountMode: 'Monteringsläge',
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',
unmountFailed: 'Avmontering misslyckades',
unmountDesc: 'I vissa system måste du manuellt mata ut från fjärrvärden innan du avmonterar avbildningen.',
attention: 'Observera',
deleteConfirm: 'Är du säker på att du vill ta bort denna avbildning?',
okBtn: 'Ja',
cancelBtn: 'Nej',
tips: {
title: 'Hur man laddar upp',
usb1: 'Anslut NanoKVM till din dator via USB.',

View File

@@ -40,12 +40,14 @@ const zh = {
wifi: {
title: 'Wi-Fi',
description: '配置 NanoKVM Wi-Fi 信息',
success: '请检查 NanoKVM 的网络状态,并访问新的 IP 地址。',
success: '请前往设备检查 NanoKVM 的网络状态。',
failed: '操作失败,请重试。',
invalidMode: '当前模式不支持配置网络。请先前往设备启用 Wi-Fi 配置模式。',
confirmBtn: '确定',
finishBtn: '完成'
},
screen: {
scale: '缩放',
title: '屏幕',
video: '视频模式',
videoDirectTips: '该模式需启用 HTTPS,请前往「设置 - 设备」中开启',
@@ -85,6 +87,9 @@ const zh = {
mode: '鼠标模式',
absolute: '绝对模式',
relative: '相对模式',
direction: '滚轮方向',
scrollUp: '向上',
scrollDown: '向下',
speed: '滚轮速度',
fast: '快',
slow: '慢',
@@ -198,6 +203,7 @@ const zh = {
title: '外观',
display: '显示',
language: '语言',
languageDesc: '选择界面语言',
menuBar: '菜单栏',
menuBarDesc: '是否在菜单栏中显示图标',
webTitle: '网站标题',
@@ -221,7 +227,17 @@ const zh = {
wifi: {
title: 'Wi-Fi',
description: '配置 Wi-Fi 信息',
setBtn: '设置'
apMode: 'AP 模式已启用,该模式下仅支持扫描二维码配网',
connect: '连接 Wi-Fi',
connectDesc1: '请输入网络名称和密码',
connectDesc2: '请输入密码以连接此网络',
disconnect: '是否要断开该网络连接?',
failed: '连接失败,请重试',
ssid: '名称',
password: '密码',
joinBtn: '加入',
confirmBtn: '确定',
cancelBtn: '取消'
},
ssh: {
description: '启用 SSH 远程访问',
@@ -251,7 +267,19 @@ const zh = {
hdmi: {
description: '启用 HDMI/显示器 输出功能'
},
autostart: {
title: '自动启动脚本设置',
description: '管理能够在 NanoKVM 启动时自动运行的脚本文件',
new: '创建新脚本',
deleteConfirm: '确定要删除该文件吗?',
yes: '是',
no: '否',
scriptName: '自动启动脚本名称',
scriptContent: '自动启动脚本内容',
settings: '设置'
},
hidOnly: 'HID-Only 模式',
hidOnlyDesc: '该模式下不再挂载虚拟设备,仅保留基础的 HID 控制功能。',
disk: '虚拟U盘',
diskDesc: '在远程主机中挂载虚拟U盘',
network: '虚拟网卡',
@@ -314,7 +342,14 @@ const zh = {
cancel: '取消',
preview: '预览更新',
previewDesc: '率先体验即将推出的新功能和优化',
previewTip: '预览版更新可能包含一些不稳定因素或未完善的功能!'
previewTip: '预览版更新可能包含一些不稳定因素或未完善的功能!',
offline: {
title: '离线更新',
desc: '通过本地安装包进行更新',
upload: '上传',
invalidName: '文件名格式错误,请前往 GitHub 发布页下载安装包。',
updateFailed: '更新失败,请重试'
}
},
account: {
title: '帐号',

View File

@@ -6,8 +6,8 @@ export const mouseStyleAtom = atom('cursor-default');
// mouse mode: absolute or relative
export const mouseModeAtom = atom('absolute');
// mouse scroll direction: -1 or 1
export const scrollDirectionAtom = atom(-1);
// 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

@@ -10,5 +10,7 @@ export const isHdmiEnabledAtom = atom(true);
// mjpeg: stream JPEG over HTTP
export const videoModeAtom = atom('');
export const videoScaleAtom = atom<number>(1.0);
// browser screen resolution
export const resolutionAtom = atom<Resolution | null>(null);

View File

@@ -2,6 +2,7 @@ import { Resolution } from '@/types';
const LANGUAGE_KEY = 'nano-kvm-language';
const VIDEO_MODE_KEY = 'nano-kvm-vide-mode';
const VIDEO_SCALE_KEY = 'nano-kvm-video-scale';
const WEB_RESOLUTION_KEY = 'nano-kvm-web-resolution';
const FPS_KEY = 'nano-kvm-fps';
const QUALITY_KEY = 'nano-kvm-quality';
@@ -9,7 +10,8 @@ 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 MOUSE_SCROLL_DIRECTION_KEY = 'nano-kvm-mouse-scroll-direction';
const MOUSE_SCROLL_INTERVAL_KEY = 'nano-kvm-mouse-scroll-interval';
const SKIP_UPDATE_KEY = 'nano-kvm-check-update';
const KEYBOARD_SYSTEM_KEY = 'nano-kvm-keyboard-system';
const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language';
@@ -65,6 +67,18 @@ export function setVideoMode(mode: string) {
localStorage.setItem(VIDEO_MODE_KEY, mode);
}
export function getVideoScale(): number | null {
const scale = localStorage.getItem(VIDEO_SCALE_KEY);
if (scale && Number(scale)) {
return Number(scale);
}
return null;
}
export function setVideoScale(scale: number): void {
localStorage.setItem(VIDEO_SCALE_KEY, String(scale));
}
export function getResolution(): Resolution | null {
const resolution = localStorage.getItem(WEB_RESOLUTION_KEY);
if (resolution) {
@@ -131,6 +145,18 @@ export function setMouseMode(mouse: string) {
localStorage.setItem(MOUSE_MODE_KEY, mouse);
}
export function getMouseScrollDirection(): number | null {
const direction = localStorage.getItem(MOUSE_SCROLL_DIRECTION_KEY);
if (direction && Number(direction)) {
return Number(direction);
}
return null;
}
export function setMouseScrollDirection(direction: number): void {
localStorage.setItem(MOUSE_SCROLL_DIRECTION_KEY, String(direction));
}
export function getMouseScrollInterval() {
const interval = localStorage.getItem(MOUSE_SCROLL_INTERVAL_KEY);
return interval ? Number(interval) : null;

View File

@@ -4,8 +4,15 @@ export function getHostname(): string {
}
export function getPort(): string {
const port = import.meta.env.VITE_SERVER_PORT as string;
return port ? port : window.location.port;
if (import.meta.env.VITE_SERVER_PORT) {
return import.meta.env.VITE_SERVER_PORT;
}
if (window.location.port) {
return window.location.port;
}
return window.location.protocol === 'https:' ? '443' : '80';
}
export function getBaseUrl(type: 'http' | 'ws'): string {
@@ -16,6 +23,11 @@ export function getBaseUrl(type: 'http' | 'ws'): string {
const hostname = getHostname();
const port = getPort();
const baseUrl = `${protocol}//${hostname}`;
return `${protocol}//${hostname}:${port}`;
const isDefaultPort =
((protocol === 'https:' || protocol === 'wss:') && port === '443') ||
((protocol === 'http:' || protocol === 'ws:') && port === '80');
return isDefaultPort ? baseUrl : `${baseUrl}:${port}`;
}

View File

@@ -21,6 +21,8 @@ export const DownloadImage = () => {
const [popoverKey, setPopoverKey] = useState(0);
const inputRef = useRef<InputRef>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const intervalId = useRef<NodeJS.Timeout | undefined>(undefined);
@@ -110,6 +112,58 @@ export const DownloadImage = () => {
});
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] ?? null;
if (!file || !file.name.toLowerCase().endsWith(".iso")) {
setStatus('failed');
setLog(t('download.NoISO'));
return;
}
setStatus('idle');
setLog('');
setSelectedFile(file);
clearInterval(intervalId.current);
intervalId.current = undefined;
}
function upload(file: File | null) {
if (!file) return;
if (!file || !file.name.toLowerCase().endsWith(".iso")) {
setStatus('failed');
setLog(t('download.NoISO'));
return;
}
setStatus('in_progress');
setLog('Downloading: ' + file.name);
const formData = new FormData();
formData.append("file", file);
fetch("/api/download/file", {
method: "POST",
body: formData,
}).catch(() => {
clearInterval(intervalId.current); // Clear the interval when the download is complete or fails
setStatus('failed');
setLog('Failed');
}).then(() => {
clearInterval(intervalId.current); // Clear the interval when the download is complete or fails
setStatus('idle');
setLog('');
});
// Start the interval to check the download status
if (!intervalId.current) {
getDownloadStatus();
setTimeout(() => {
intervalId.current = setInterval(getDownloadStatus, 2500);
}, 2500);
}
}
const content = (
<div key={popoverKey} className="min-w-[300px]">
<div className="flex items-center justify-between px-1">
@@ -122,21 +176,83 @@ export const DownloadImage = () => {
<div className="text-red-500">{t('download.disabled')}</div>
) : (
<>
<div className="pb-1 text-neutral-500">{t('download.input')}</div>
<div className="flex items-center space-x-1">
<Input
ref={inputRef}
value={input}
onChange={handleChange}
disabled={status === 'in_progress'}
/>
<Button
type="primary"
onClick={() => download(input)}
disabled={status === 'in_progress'}
>
{t('download.ok')}
</Button>
<div>
<div className="pb-1 text-neutral-500">{t('download.input')}</div>
<div className="flex items-center space-x-1">
<Input
ref={inputRef}
value={input}
onChange={handleChange}
disabled={status === 'in_progress'}
/>
<Button
type="primary"
onClick={() => download(input)}
disabled={status === 'in_progress'}
>
{t('download.ok')}
</Button>
</div>
</div>
<div>
<div className="pb-1 text-neutral-500">{t('download.inputfile')}</div>
<div className="flex items-center space-x-1">
<div
className={clsx(
"flex flex-col items-center justify-center w-full h-10 border-2 border-solid rounded-xl transition css-9118ya ant-input-outlined",
isDragging ? "bg-neutral-500 border-blue-500" : "",
status === "in_progress" ? "opacity-50 cursor-not-allowed pointer-events-none border-neutral-600 bg-neutral-700" : "cursor-pointer hover:bg-neutral-500"
)}
onDrop={(e) => {
if (status === "in_progress") return; // deaktiviert
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0] ?? null;
if (!file || !file.name.toLowerCase().endsWith(".iso")) {
setStatus('failed');
setLog(t('download.NoISO'));
return;
}
setStatus('idle');
setLog('');
setSelectedFile(file);
}}
onDragOver={(e) => {
if (status === "in_progress") return; // deaktiviert
e.preventDefault();
setIsDragging(true); // Datei wird über den Bereich gezogen
}}
onDragLeave={(e) => {
if (status === "in_progress") return; // deaktiviert
e.preventDefault();
setIsDragging(false); // Maus verlässt Bereich
}}
onClick={() => {
if (status === "in_progress") return; // deaktiviert
document.getElementById("file-upload")?.click()
}}
>
<span className="text-neutral-100 text-sm p-1">
{selectedFile ? selectedFile.name : t('download.uploadbox')}
</span>
<Input
id="file-upload"
type="file"
onChange={handleFileChange}
disabled={status === 'in_progress'}
className="hidden"
/>
</div>
<Button
type="primary"
className="h-10 border-2"
onClick={() => upload(selectedFile)}
disabled={status === 'in_progress' || !selectedFile}
>
{t('download.ok')}
</Button>
</div>
</div>
</>
)}

View File

@@ -8,9 +8,10 @@ export const Fullscreen = () => {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
function onFullscreenChange() {
const onFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
}
};
onFullscreenChange();
document.addEventListener('fullscreenchange', onFullscreenChange);
@@ -24,8 +25,14 @@ export const Fullscreen = () => {
if (!document.fullscreenElement) {
const element = document.documentElement;
element.requestFullscreen();
// @ts-expect-error - https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/lock
navigator.keyboard?.lock();
} else {
document.exitFullscreen();
// @ts-expect-error - https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/unlock
navigator.keyboard?.unlock();
}
}

View File

@@ -28,7 +28,8 @@ export const Paste = () => {
const languages = [
{ value: 'en', label: t('keyboard.dropdownEnglish') },
{ value: 'de', label: t('keyboard.dropdownGerman') }
{ value: 'de', label: t('keyboard.dropdownGerman') },
{ value: 'ru', label: t('keyboard.dropdownRussian') }
];
useEffect(() => {
@@ -37,7 +38,7 @@ export const Paste = () => {
function onChange(e: ChangeEvent<HTMLTextAreaElement>) {
const value = e.target.value;
setStatus(isASCII(value) ? '' : 'error');
setStatus(isValidForLanguage(value, langue) ? '' : 'error');
setInputValue(value);
}
@@ -61,12 +62,50 @@ export const Paste = () => {
setIsReadingClipboard(false);
}
}
// Extended RU → EN translation including punctuation
function translateRuToEnWithPunctuation(value: string): string {
const letterMap: Record<string, string> = {
'ё': '`',
'й': 'q', 'ц': 'w', 'у': 'e', 'к': 'r', 'е': 't', 'н': 'y', 'г': 'u', 'ш': 'i',
'щ': 'o', 'з': 'p', 'х': '[', 'ъ': ']',
'ф': 'a', 'ы': 's', 'в': 'd', 'а': 'f', 'п': 'g', 'р': 'h', 'о': 'j', 'л': 'k',
'д': 'l', 'ж': ';', 'э': '\'',
'я': 'z', 'ч': 'x', 'с': 'c', 'м': 'v', 'и': 'b', 'т': 'n', 'ь': 'm', 'б': ',', 'ю': '.',
};
const punctuationMap: Record<string, string> = {
'"': '@',
'№': '#',
';': '$',
':': '^',
'?': '&',
'Ё': '~',
'/': '|',
'.': '/',
',': '?',
};
return Array.from(value).map((ch) => {
const lower = ch.toLowerCase();
if (letterMap[lower]) {
const translated = letterMap[lower];
return ch === lower ? translated : translated.toUpperCase();
}
if (punctuationMap[ch]) {
return punctuationMap[ch];
}
return ch;
}).join('');
}
function submit() {
if (isLoading || !inputValue) return;
setIsLoading(true);
paste(inputValue, langue)
const textToSend = langue === 'ru' ? translateRuToEnWithPunctuation(inputValue) : inputValue;
paste(textToSend, langue)
.then((rsp) => {
if (rsp.code !== 0) {
setErrMsg(rsp.msg);
@@ -91,9 +130,28 @@ export const Paste = () => {
setIsKeyboardEnable(!open);
}
function isASCII(value: string) {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 127) {
function isValidForLanguage(value: string, selectedLangue: string) {
const isRussian = selectedLangue === 'ru';
for (const ch of value) {
const code = ch.codePointAt(0) ?? 0;
if (isRussian) {
// For Russian language, allow Cyrillic letters and special characters
// that can be typed on Russian keyboard (but not English letters)
if (
(code >= 0x0410 && code <= 0x042F) || // (А-Я)
(code >= 0x0430 && code <= 0x044F) || // (а-я)
code === 0x0401 || // (Ё)
code === 0x0451 || // (ё)
(code >= 0x20 && code <= 0x7E && !(code >= 0x41 && code <= 0x5A) && !(code >= 0x61 && code <= 0x7A)) // Special chars, digits, space, but not English letters
) {
continue;
}
return false;
} else {
// For English/German, only allow ASCII
if (code <= 0x7F) continue;
return false;
}
}

View File

@@ -0,0 +1,51 @@
import { Popover } from 'antd';
import { useAtom } from 'jotai';
import { ArrowDownUpIcon, CheckIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as storage from '@/lib/localstorage.ts';
import { scrollDirectionAtom } from '@/jotai/mouse.ts';
export const Direction = () => {
const { t } = useTranslation();
const [scrollDirection, setScrollDirection] = useAtom(scrollDirectionAtom);
const directions = [
{ name: t('mouse.scrollUp'), value: -1 },
{ name: t('mouse.scrollDown'), value: 1 }
];
function update(direction: number): void {
const value = Number(direction);
setScrollDirection(value);
storage.setMouseScrollDirection(value);
}
const content = (
<>
{directions.map((direction) => (
<div
key={direction.value}
className="flex cursor-pointer items-center space-x-1 rounded py-1.5 pl-2 pr-5 hover:bg-neutral-700/70"
onClick={() => update(direction.value)}
>
<div className="flex h-[16px] w-[16px] items-end text-blue-500">
{direction.value === scrollDirection && <CheckIcon strokeWidth={3} size={16} />}
</div>
<span>{direction.name}</span>
</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">
<ArrowDownUpIcon size={18} />
<span>{t('mouse.direction')}</span>
</div>
</Popover>
);
};

View File

@@ -1,19 +1,17 @@
import { useEffect, useState } from 'react';
import { Button, Divider, Modal, Typography } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { PenIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/hid.ts';
import { hidModeAtom } from '@/jotai/mouse.ts';
const { Paragraph } = Typography;
export const HidMode = () => {
const { t } = useTranslation();
const [hidMode, setHidMode] = useAtom(hidModeAtom);
const [hidMode, setHidMode] = useState<'normal' | 'hid-only'>('normal');
const [isLoading, setIsLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [errMsg, setErrMsg] = useState('');

View File

@@ -5,10 +5,16 @@ import { MouseIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as ls from '@/lib/localstorage';
import { mouseModeAtom, mouseStyleAtom, scrollIntervalAtom } from '@/jotai/mouse';
import {
mouseModeAtom,
mouseStyleAtom,
scrollDirectionAtom,
scrollIntervalAtom
} from '@/jotai/mouse';
import { MenuItem } from '@/components/menu-item.tsx';
import { Cursor } from './cursor.tsx';
import { Direction } from './direction.tsx';
import { HidMode } from './hid-mode.tsx';
import { MouseMode } from './mouse-mode.tsx';
import { ResetHid } from './reset-hid.tsx';
@@ -19,6 +25,7 @@ export const Mouse = () => {
const setMouseStyle = useSetAtom(mouseStyleAtom);
const setMouseMode = useSetAtom(mouseModeAtom);
const setScrollDirection = useSetAtom(scrollDirectionAtom);
const setScrollInterval = useSetAtom(scrollIntervalAtom);
useEffect(() => {
@@ -32,6 +39,11 @@ export const Mouse = () => {
setMouseMode(mouseMode);
}
const direction = ls.getMouseScrollDirection();
if (direction) {
setScrollDirection(direction > 0 ? 1 : -1);
}
const interval = ls.getMouseScrollInterval();
if (interval) {
setScrollInterval(interval);
@@ -42,6 +54,7 @@ export const Mouse = () => {
<div className="flex flex-col space-y-1">
<Cursor />
<MouseMode />
<Direction />
<Speed />
<Divider style={{ margin: '10px 0' }} />

View File

@@ -48,7 +48,7 @@ export const Power = () => {
}
const icon = (
<div className={clsx('h-[18px] w-[18px]', isPowerOn ? 'text-green-600' : 'text-neutral-500')}>
<div className={clsx('h-[18px] w-[18px]', isPowerOn ? 'text-green-600' : 'text-neutral-300')}>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : (

View File

@@ -15,6 +15,7 @@ import { Gop } from './gop.tsx';
import { Quality } from './quality';
import { Reset } from './reset.tsx';
import { Resolution } from './resolution';
import { Scale } from './scale';
import { VideoMode } from './video-mode.tsx';
export const Screen = () => {
@@ -73,6 +74,7 @@ export const Screen = () => {
<Resolution />
<Quality quality={quality} setQuality={setQuality} />
<Fps fps={fps} setFps={setFps} />
<Scale />
{videoMode !== 'mjpeg' && <Gop gop={gop} setGop={setGop} />}
{videoMode === 'mjpeg' && <FrameDetect />}
<Reset />

View File

@@ -0,0 +1,65 @@
import { ReactElement, useEffect } from 'react';
import { Popover } from 'antd';
import { useAtom } from 'jotai';
import { CheckIcon, PercentIcon, ScalingIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as storage from '@/lib/localstorage.ts';
import { videoScaleAtom } from '@/jotai/screen.ts';
const ScaleList = [
{ label: '200', value: 2 },
{ label: '150', value: 1.5 },
{ label: '100', value: 1 },
{ label: '75', value: 0.75 },
{ label: '50', value: 0.5 }
];
export const Scale = (): ReactElement => {
const { t } = useTranslation();
const [videoScale, setVideoScale] = useAtom(videoScaleAtom);
useEffect(() => {
const scale = storage.getVideoScale();
if (scale) {
setVideoScale(scale);
}
}, [setVideoScale]);
async function update(scale: number): Promise<void> {
setVideoScale(scale);
storage.setVideoScale(scale);
}
const content = (
<>
{ScaleList.map((scale) => (
<div
key={scale.value}
className="flex h-[30px] cursor-pointer select-none items-center rounded pl-1 pr-5 hover:bg-neutral-700/70"
onClick={() => update(scale.value)}
>
<div className="flex h-[14px] w-[20px] items-end text-blue-500">
{scale.value === videoScale && <CheckIcon size={14} />}
</div>
<div className="flex items-center space-x-0.5">
<span>{scale.label}</span>
<PercentIcon size={12} />
</div>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [13, 0] }}>
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">
<div className="flex h-[14px] w-[20px] items-end">
<ScalingIcon size={16} />
</div>
<span>{t('screen.scale')}</span>
</div>
</Popover>
);
};

View File

@@ -41,9 +41,9 @@ export const VideoMode = () => {
<>
{!isDirectSupported && (
<Tooltip
placement="right"
title={t('screen.videoDirectTips')}
overlayStyle={{ maxWidth: '270px' }}
placement="right"
styles={{ root: { maxWidth: '270px' } }}
>
<div className="flex cursor-not-allowed select-none items-center rounded py-1.5 pl-1 pr-5 text-neutral-500 hover:bg-neutral-700/70">
<div className="flex h-[14px] w-[20px] items-end text-blue-500"></div>

View File

@@ -31,9 +31,9 @@ export const Community = () => {
return (
<>
<div className="pb-5 text-neutral-400">{t('settings.about.community')}</div>
<div className="text-neutral-400">{t('settings.about.community')}</div>
<div className="my-3 flex space-x-3">
<div className="mt-5 flex flex-wrap gap-3">
{communities.map((community) => (
<a
key={community.name}

View File

@@ -65,8 +65,8 @@ export const Hostname = () => {
}
return (
<>
<div className="flex w-full items-center justify-between pt-4">
<div className="space-y-1">
<div className="flex w-full items-center justify-between">
<span>{t('settings.about.hostname')}</span>
{editState === 'editing' ? (
@@ -94,10 +94,10 @@ export const Hostname = () => {
</div>
{editState === 'edited' && (
<div className="flex w-full justify-end pt-1 text-xs text-green-500">
<div className="flex w-full justify-end text-xs text-green-500">
{t('settings.about.hostnameUpdated')}
</div>
)}
</>
</div>
);
};

View File

@@ -2,7 +2,6 @@ import { Divider } from 'antd';
import { useTranslation } from 'react-i18next';
import { Community } from './community.tsx';
import { Hostname } from './hostname.tsx';
import { Information } from './information.tsx';
export const About = () => {
@@ -10,12 +9,11 @@ export const About = () => {
return (
<>
<div className="text-base font-bold">{t('settings.about.title')}</div>
<Divider />
<div className="text-base">{t('settings.about.title')}</div>
<Divider className="opacity-50" />
<Information />
<Hostname />
<Divider />
<Divider className="opacity-50" />
<Community />
</>

View File

@@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
import { Hostname } from './hostname.tsx';
type IP = {
name: string;
addr: string;
@@ -38,9 +40,9 @@ export const Information = () => {
return (
<>
<div className="pb-5 text-neutral-400">{t('settings.about.information')}</div>
<div className="text-neutral-400">{t('settings.about.information')}</div>
<div className="flex w-full flex-col space-y-4">
<div className="mt-5 flex w-full flex-col space-y-5">
{/* IP list */}
<div className="flex w-full items-start justify-between">
<span>{t('settings.about.ip')}</span>
@@ -103,6 +105,8 @@ export const Information = () => {
<span>{information ? information.application : '-'}</span>
</div>
<Hostname />
</div>
</>
);

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Divider } from 'antd';
import { Button, Divider } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -27,10 +27,10 @@ export const Account = () => {
return (
<>
<div className="text-base font-bold">{t('settings.account.title')}</div>
<Divider />
<div className="text-base">{t('settings.account.title')}</div>
<Divider className="opacity-50" />
<div className="flex flex-col space-y-5">
<div className="flex flex-col space-y-8">
<div className="flex items-center justify-between">
<span>{t('settings.account.webAccount')}</span>
<span>{username ? username : '-'}</span>
@@ -38,15 +38,13 @@ export const Account = () => {
<div className="flex items-center justify-between">
<span>{t('settings.account.password')}</span>
<span
className="cursor-pointer text-blue-500 hover:text-blue-500/80"
onClick={changePassword}
>
<Button type="primary" onClick={changePassword}>
{t('settings.account.updateBtn')}
</span>
</Button>
</div>
</div>
<Divider />
<Divider className="opacity-50" />
<Logout />
</>

View File

@@ -10,14 +10,14 @@ export const Appearance = () => {
return (
<>
<div className="text-base font-bold">{t('settings.appearance.title')}</div>
<Divider />
<div className="text-base">{t('settings.appearance.title')}</div>
<Divider className="opacity-50" />
<div className="flex flex-col space-y-6">
<Language />
<WebTitle />
</div>
<Divider />
<div className="text-neutral-400">{t('settings.appearance.display')}</div>
<Language />
<WebTitle />
<Divider className="opacity-50" style={{ margin: '32px 0' }} />
<MenuBar />
</>

View File

@@ -1,5 +1,4 @@
import { Select } from 'antd';
import { LanguagesIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import languages from '@/i18n/languages.ts';
@@ -21,18 +20,20 @@ export const Language = () => {
}
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-1">
<LanguagesIcon size={16} />
<div className="mt-5 flex items-center justify-between space-x-5">
<div className="flex flex-col space-y-1">
<span>{t('settings.appearance.language')}</span>
<span className="text-xs text-neutral-500">{t('settings.appearance.languageDesc')}</span>
</div>
<Select
defaultValue={i18n.language}
style={{ width: 180 }}
options={options}
onSelect={changeLanguage}
/>
<div>
<Select
defaultValue={i18n.language}
style={{ width: 180 }}
options={options}
onSelect={changeLanguage}
/>
</div>
</div>
);
};

View File

@@ -19,12 +19,12 @@ export const MenuBar = () => {
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
const items = [
{ key: 'image', icon: <DiscIcon size={16} /> },
{ key: 'download', icon: <DownloadIcon size={16} /> },
{ key: 'script', icon: <FileJsonIcon size={15} /> },
{ key: 'terminal', icon: <TerminalSquareIcon size={16} /> },
{ key: 'wol', icon: <NetworkIcon size={16} /> },
{ key: 'power', icon: <PowerIcon size={16} /> }
{ key: 'image', icon: <DiscIcon size={16} className="text-neutral-400" /> },
{ key: 'download', icon: <DownloadIcon size={16} className="text-neutral-400" /> },
{ key: 'script', icon: <FileJsonIcon size={16} className="text-neutral-400" /> },
{ key: 'terminal', icon: <TerminalSquareIcon size={16} className="text-neutral-400" /> },
{ key: 'wol', icon: <NetworkIcon size={16} className="text-neutral-400" /> },
{ key: 'power', icon: <PowerIcon size={16} className="text-neutral-400" /> }
];
function updateItems(key: string) {
@@ -45,10 +45,10 @@ export const MenuBar = () => {
<span className="text-xs text-neutral-500">{t('settings.appearance.menuBarDesc')}</span>
</div>
<div className="flex flex-col space-y-4 pt-5">
<div className="mt-8 flex flex-col space-y-5">
{items.map((item) => (
<div key={item.key} className="flex items-center justify-between">
<div className="flex items-center space-x-1">
<div className="flex items-center space-x-2">
{item.icon}
<span>{t(`${item.key}.title`)}</span>
</div>

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { Input } from 'antd';
import { useAtom } from 'jotai';
import { GlobeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
@@ -46,26 +45,23 @@ export const WebTitle = () => {
}
return (
<div className="flex items-center justify-between">
<div className="mt-8 flex items-center justify-between space-x-5">
<div className="flex flex-col">
<div className="flex items-center space-x-1">
<div className="flex size-[16px] items-center justify-center">
<GlobeIcon size={14} />
</div>
<span>{t('settings.appearance.webTitle')}</span>
</div>
<span>{t('settings.appearance.webTitle')}</span>
<span className="text-xs text-neutral-500">{t('settings.appearance.webTitleDesc')}</span>
</div>
<Input
disabled={isLoading}
style={{ width: 180 }}
value={webTitle}
onChange={(e) => setWebTitle(e.target.value)}
onPressEnter={submit}
onBlur={submit}
placeholder="NanoKVM"
/>
<div>
<Input
disabled={isLoading}
style={{ width: 180 }}
value={webTitle}
onChange={(e) => setWebTitle(e.target.value)}
onPressEnter={submit}
onBlur={submit}
placeholder="NanoKVM"
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,185 @@
import { useEffect, useState } from 'react';
import { DeleteOutlined, EditOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons';
import { Button, Input, Modal, Popconfirm, Space, Table } from 'antd';
import TextArea from 'antd/es/input/TextArea';
import type { TableProps } from 'antd/es/table';
import { useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/autostart.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
export const Autostart = () => {
interface AutostartItem {
name: string;
}
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isEditAutostartOpen, setIsEditAutostartOpen] = useState(false);
const [isManageAutostartOpen, setIsManageAutostartOpen] = useState(false);
const [isAutostartNameEditable, setIsAutostartNameEditable] = useState(true);
const [autostartItems, setAutostartItems] = useState<AutostartItem[]>([]);
const [autostartName, setAutostartName] = useState('');
const [autostartContent, setAutostartContent] = useState('');
const autostartColumns: TableProps<AutostartItem>['columns'] = [
{
title: 'Name',
dataIndex: 'name',
key: 'name'
},
{
title: 'Action',
key: 'action',
render: (_, record) => (
<>
<Button type="text" icon={<EditOutlined />} onClick={() => editAutostart(record.name)} />
<Popconfirm
title={t('settings.device.autostart.deleteConfirm')}
onConfirm={() => deleteAutostart(record.name)}
okText={t('settings.device.autostart.yes')}
cancelText={t('settings.device.autostart.no')}
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</>
)
}
];
useEffect(() => {
setIsKeyboardEnable(false);
getAutostart();
return () => {
setIsKeyboardEnable(true);
};
}, []);
function getAutostart() {
api.getAutostart().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setAutostartItems([]);
if (rsp.data?.files?.length > 0) {
rsp.data.files.forEach((item: string) => {
setAutostartItems((prevItems) => [...prevItems, { name: item }]);
});
}
});
}
function uploadAutostart() {
api.uploadAutostart(autostartName, autostartContent).then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
getAutostart();
setAutostartContent('');
setAutostartName('');
setIsEditAutostartOpen(false);
});
}
function editAutostart(name: string) {
api.getAutostartContent(name).then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setAutostartName(name);
setAutostartContent(rsp.data);
setIsEditAutostartOpen(true);
setIsAutostartNameEditable(false);
});
}
function closeEditAutostart() {
setAutostartContent('');
setAutostartName('');
setIsAutostartNameEditable(true);
setIsEditAutostartOpen(false);
}
function deleteAutostart(name: string) {
api.deleteAutostart(name).then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
getAutostart();
});
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<span>{t('settings.device.autostart.title')}</span>
<span className="text-xs text-neutral-500">
{t('settings.device.autostart.description')}
</span>
</div>
<div>
<Button
type="text"
onClick={() => {
setIsManageAutostartOpen(true);
}}
icon={<SettingOutlined />}
/>
</div>
</div>
<Modal
title={t('settings.device.autostart.title')}
open={isEditAutostartOpen}
onOk={uploadAutostart}
onCancel={closeEditAutostart}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Input
placeholder={t('settings.device.autostart.scriptName')}
value={autostartName}
disabled={!isAutostartNameEditable}
onChange={(e) => setAutostartName(e.target.value)}
/>
<TextArea
placeholder={t('settings.device.autostart.scriptContent')}
value={autostartContent}
onChange={(e) => setAutostartContent(e.target.value)}
/>
</Space>
</Modal>
<Modal
open={isManageAutostartOpen}
footer=""
onCancel={() => setIsManageAutostartOpen(false)}
title={t('settings.device.autostart.title')}
>
<div className="flex justify-end">
<Button
type="text"
onClick={() => {
setIsEditAutostartOpen(true);
}}
icon={<PlusOutlined />}
>
{t('settings.device.autostart.new')}
</Button>
</div>
<Table<AutostartItem> columns={autostartColumns} dataSource={autostartItems} />
</Modal>
</div>
);
};

View File

@@ -4,8 +4,9 @@ import { useTranslation } from 'react-i18next';
import { Swap } from './swap.tsx';
const children = (
<div className="py-3">
<div className="space-y-6 py-3">
<Swap />
{/*<Autostart />*/}
</div>
);

View File

@@ -59,17 +59,17 @@ export const Swap = () => {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<span>{t('settings.device.swap.title')}</span>
<Tooltip
title={t('settings.device.swap.tip')}
className="cursor-pointer"
placement="bottom"
overlayStyle={{ maxWidth: '300px' }}
placement="right"
styles={{ root: { maxWidth: '400px' } }}
>
<CircleAlertIcon size={15} />
<CircleAlertIcon className="text-neutral-500" size={14} />
</Tooltip>
</div>
<span className="text-xs text-neutral-500">{t('settings.device.swap.description')}</span>

View File

@@ -61,7 +61,7 @@ export const Hdmi = () => {
<>
{isPcie && (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex flex-col space-y-1">
<span>HDMI</span>
<span className="text-xs text-neutral-500">

View File

@@ -1,16 +0,0 @@
import { Switch } from 'antd';
import { useTranslation } from 'react-i18next';
export const HidMode = () => {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span>{t('settings.device.hidOnly')}</span>
</div>
<Switch checked={true} disabled={true} />
</div>
);
};

View File

@@ -1,14 +1,8 @@
import { useEffect } from 'react';
import { Divider } from 'antd';
import { useAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/hid.ts';
import { hidModeAtom } from '@/jotai/mouse.ts';
import { Advanced } from './advanced';
import { Hdmi } from './hdmi.tsx';
import { HidMode } from './hid-mode.tsx';
import { Mdns } from './mdns.tsx';
import { MouseJiggler } from './mouse-jiggler.tsx';
import { Oled } from './oled.tsx';
@@ -20,40 +14,31 @@ import { Wifi } from './wifi.tsx';
export const Device = () => {
const { t } = useTranslation();
const [hidMode, setHidMode] = useAtom(hidModeAtom);
useEffect(() => {
api.getHidMode().then((rsp) => {
if (rsp.code === 0) {
setHidMode(rsp.data.mode);
}
});
}, []);
return (
<>
<div className="text-base font-bold">{t('settings.device.title')}</div>
<Divider />
<div className="text-base">{t('settings.device.title')}</div>
<Divider className="opacity-50" />
<div className="flex flex-col space-y-6">
<div className="flex flex-col space-y-8">
<Tls />
<Ssh />
<Mdns />
<Hdmi />
<Divider className="opacity-50" />
{hidMode === 'normal' ? <VirtualDevices /> : <HidMode />}
</div>
<Divider />
<VirtualDevices />
<Divider className="opacity-50" />
<div className="flex flex-col space-y-6">
<Oled />
<Wifi />
<MouseJiggler />
</div>
<Divider />
<Divider className="opacity-50" />
<Advanced />
<Divider />
<Advanced />
</div>
<Divider className="opacity-50" />
<Reboot />
</>

View File

@@ -43,17 +43,17 @@ export const Mdns = () => {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<span>mDNS</span>
<Tooltip
title={t('settings.device.mdns.tip')}
className="cursor-pointer"
placement="bottom"
overlayStyle={{ maxWidth: '300px' }}
placement="right"
styles={{ root: { maxWidth: '400px' } }}
>
<CircleAlertIcon size={15} />
<CircleAlertIcon className="text-neutral-500" size={14} />
</Tooltip>
</div>

View File

@@ -84,7 +84,7 @@ export const MouseJiggler = () => {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex flex-col space-y-1">
<span>{t('settings.device.mouseJiggler.title')}</span>
<span className="text-xs text-neutral-500">
{t('settings.device.mouseJiggler.description')}

View File

@@ -54,7 +54,7 @@ export const Oled = () => {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex flex-col space-y-1">
<span>{t('settings.device.oled.title')}</span>
<span className="text-xs text-neutral-500">{t('settings.device.oled.description')}</span>
</div>

View File

@@ -43,17 +43,17 @@ export const Ssh = () => {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<span>SSH</span>
<Tooltip
title={t('settings.device.ssh.tip')}
className="cursor-pointer"
placement="bottom"
overlayStyle={{ maxWidth: '300px' }}
placement="right"
styles={{ root: { maxWidth: '400px' } }}
>
<CircleAlertIcon size={15} />
<CircleAlertIcon className="text-neutral-500" size={14} />
</Tooltip>
</div>
<span className="text-xs text-neutral-500">{t('settings.device.ssh.description')}</span>

View File

@@ -47,17 +47,17 @@ export const Tls = () => {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<span>HTTPS</span>
<Tooltip
title={t('settings.device.tls.tip')}
className="cursor-pointer"
placement="bottom"
overlayStyle={{ maxWidth: '300px' }}
placement="right"
styles={{ root: { maxWidth: '400px' } }}
>
<CircleAlertIcon size={15} />
<CircleAlertIcon className="text-neutral-500" size={14} />
</Tooltip>
</div>
<span className="text-xs text-neutral-500">{t('settings.device.tls.description')}</span>

View File

@@ -2,67 +2,107 @@ import { useEffect, useState } from 'react';
import { Switch } from 'antd';
import { useTranslation } from 'react-i18next';
import { getHidMode } from '@/api/hid.ts';
import * as api from '@/api/virtual-device.ts';
export const VirtualDevices = () => {
const { t } = useTranslation();
const [isNetworkOn, setIsNetworkOn] = useState(false);
const [isDiskOn, setIsDiskOn] = useState(false);
const [loading, setLoading] = useState(''); // '' | 'network' | 'disk'
const [isHidOnlyMode, setIsHidOnlyMode] = useState(false);
const [isDiskEnabled, setIsDiskEnabled] = useState(false);
const [isNetworkEnabled, setIsNetworkEnabled] = useState(false);
const [loading, setLoading] = useState<'' | 'disk' | 'network'>('');
useEffect(() => {
api.getVirtualDevice().then((rsp) => {
getHidOnlyMode();
getVirtualDevice();
}, []);
async function getHidOnlyMode() {
try {
const rsp = await getHidMode();
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsHidOnlyMode(rsp.data.mode === 'hid-only');
} catch (err) {
console.log(err);
}
}
async function getVirtualDevice() {
try {
const rsp = await api.getVirtualDevice();
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsNetworkOn(rsp.data.network);
setIsDiskOn(rsp.data.disk);
});
}, []);
setIsDiskEnabled(rsp.data.disk);
setIsNetworkEnabled(rsp.data.network);
} catch (err) {
console.log(err);
}
}
function update(device: 'network' | 'disk') {
async function update(device: 'disk' | 'network') {
if (loading) return;
setLoading(device);
api
.updateVirtualDevice(device)
.then((rsp) => {
if (rsp.code !== 0) {
return;
}
try {
const rsp = await api.updateVirtualDevice(device);
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (device === 'network') {
setIsNetworkOn(rsp.data.on);
} else {
setIsDiskOn(rsp.data.on);
}
})
.finally(() => {
setLoading('');
});
await getVirtualDevice();
} catch (err) {
console.log(err);
} finally {
setLoading('');
}
}
if (isHidOnlyMode) {
return (
<div className="flex items-center justify-between space-x-10">
<div className="flex flex-col space-y-1">
<span>{t('settings.device.hidOnly')}</span>
<span className="text-xs text-neutral-500">{t('settings.device.hidOnlyDesc')}</span>
</div>
<Switch checked={true} disabled={true} />
</div>
);
}
return (
<>
{/* Virtual Disk */}
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex flex-col space-y-1">
<span>{t('settings.device.disk')}</span>
<span className="text-xs text-neutral-500">{t('settings.device.diskDesc')}</span>
</div>
<Switch checked={isDiskOn} loading={loading === 'disk'} onChange={() => update('disk')} />
<Switch
checked={isDiskEnabled}
loading={loading === 'disk'}
onChange={() => update('disk')}
/>
</div>
{/* Virtual Network */}
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex flex-col space-y-1">
<span>{t('settings.device.network')}</span>
<span className="text-xs text-neutral-500">{t('settings.device.networkDesc')}</span>
</div>
<Switch
checked={isNetworkOn}
checked={isNetworkEnabled}
loading={loading === 'network'}
onChange={() => update('network')}
/>

View File

@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { Button } from 'antd';
import { LockOutlined, WifiOutlined } from '@ant-design/icons';
import { Button, Input, Modal, Switch } from 'antd';
import { WifiIcon, WifiPenIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/network.ts';
@@ -8,38 +10,198 @@ export const Wifi = () => {
const { t } = useTranslation();
const [isSupported, setIsSupported] = useState(false);
const [isAPMode, setIsAPMode] = useState(false);
const [connectedWiFi, setConnectedWifi] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [ssid, setSsid] = useState('');
const [password, setPassword] = useState('');
const [status, setStatus] = useState<'' | 'connecting' | 'disconnecting'>('');
const [message, setMessage] = useState('');
useEffect(() => {
api.getWiFi().then((rsp) => {
getWiFi();
}, []);
async function getWiFi() {
try {
const rsp = await api.getWiFi();
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsSupported(rsp.data?.supported);
});
}, []);
setIsSupported(!!rsp.data?.supported);
setIsAPMode(!!rsp.data?.apMode);
function open() {
window.open('/#/wifi', '_blank');
if (rsp.data?.connected && rsp.data?.ssid) {
setConnectedWifi(rsp.data.ssid);
} else {
setConnectedWifi('');
}
} catch {
/* empty */
}
}
async function connect() {
setMessage('');
if (!ssid || !password) return;
if (status !== '') return;
setStatus('connecting');
try {
const rsp = await api.connectWifi(ssid, password);
if (rsp.code !== 0) {
console.log(rsp.msg);
setMessage(t('settings.device.wifi.failed'));
getWiFi();
return;
}
setConnectedWifi(ssid);
setIsModalOpen(false);
} catch (err) {
console.log(err);
} finally {
setStatus('');
}
}
async function disconnect(enable: boolean) {
if (enable || status !== '') return;
setStatus('disconnecting');
try {
const rsp = await api.disconnectWifi();
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setConnectedWifi('');
} catch (err) {
console.log(err);
} finally {
setStatus('');
}
}
function openModal() {
setSsid('');
setPassword('');
setMessage('');
setIsModalOpen(true);
}
function closeModal() {
if (status !== '') return;
setIsModalOpen(false);
}
if (!isSupported) {
return <></>;
}
if (isAPMode) {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span>{t('settings.device.wifi.title')}</span>
<span className="text-xs text-neutral-500">{t('settings.device.wifi.apMode')}</span>
</div>
<Button shape="round" size="small" disabled>
<div className="flex items-center justify-center px-1.5">
<WifiIcon size={16} />
</div>
</Button>
</div>
);
}
return (
<>
{isSupported && (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span>{t('settings.device.wifi.title')}</span>
<span className="text-xs text-neutral-500">
{t('settings.device.wifi.description')}
</span>
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-1">
<span>{t('settings.device.wifi.title')}</span>
<span className="text-xs text-neutral-500">{t('settings.device.wifi.description')}</span>
</div>
<Button
type={connectedWiFi ? 'primary' : 'default'}
shape="round"
size="small"
onClick={openModal}
>
<div className="flex items-center justify-center px-1.5">
<WifiIcon size={16} />
</div>
</Button>
</div>
<Modal
closable={false}
open={isModalOpen}
centered={true}
onOk={connect}
onCancel={closeModal}
okText={t('settings.device.wifi.joinBtn')}
cancelText={t('settings.device.wifi.cancelBtn')}
confirmLoading={status === 'connecting'}
>
{/* title */}
<div className="flex items-center space-x-5">
<div className="h-[64px] w-[64px]">
<WifiPenIcon size={64} className="text-blue-400" />
</div>
<Button type="primary" size="small" onClick={open}>
{t('settings.device.wifi.setBtn')}
</Button>
{!connectedWiFi ? (
<div className="flex flex-col">
<span className="text-lg font-bold">{t('settings.device.wifi.connect')}</span>
<span className="text-xs text-neutral-400">
{t('settings.device.wifi.connectDesc1')}
</span>
</div>
) : (
<div className="flex justify-center">
<div className="flex w-[300px] justify-between rounded-lg bg-neutral-800">
<div className="flex w-full justify-between p-3">
<span>{connectedWiFi}</span>
<Switch
value={!!connectedWiFi}
loading={status === 'disconnecting'}
onChange={disconnect}
/>
</div>
</div>
</div>
)}
</div>
)}
{/* form */}
<div className="flex flex-col items-center space-y-3 py-6">
<Input
value={ssid}
style={{ width: '300px' }}
prefix={<WifiOutlined />}
placeholder={t('settings.device.wifi.ssid')}
onChange={(e) => setSsid(e.target.value)}
/>
<Input.Password
value={password}
style={{ width: '300px' }}
prefix={<LockOutlined />}
placeholder={t('settings.device.wifi.password')}
onChange={(e) => setPassword(e.target.value)}
/>
{!!message && <span className="text-sm text-red-500">{message}</span>}
</div>
</Modal>
</>
);
};

View File

@@ -17,6 +17,7 @@ import * as api from '@/api/application.ts';
import * as ls from '@/lib/localstorage.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { Tailscale as TailscaleIcon } from '@/components/icons/tailscale';
import { ScrollArea } from '@/components/ui/scroll-area';
import { About } from './about';
import { Account } from './account';
@@ -64,6 +65,9 @@ export const Settings = () => {
if (rsp.code !== 0) {
return;
}
if (!rsp.data?.current || !rsp.data?.latest) {
return;
}
if (semver.gt(rsp.data.latest, rsp.data.current)) {
setIsUpdateAvailable(true);
@@ -119,23 +123,21 @@ export const Settings = () => {
width={'80%'}
centered={true}
footer={null}
destroyOnClose={true}
destroyOnHidden={true}
onCancel={closeModal}
style={{ maxWidth: '1080px' }}
styles={{ content: { padding: 0 } }}
>
<div className="flex h-[80vh] max-h-[700px] rounded-lg outline outline-1 outline-neutral-700">
<div className="flex h-full max-w-[240px] flex-col space-y-0.5 rounded-l-lg bg-neutral-800 px-1 sm:w-1/5 md:px-2">
<div className="flex h-full max-w-[260px] flex-col space-y-0.5 rounded-l-lg bg-neutral-800/90 px-1 sm:w-1/5 md:w-1/4 md:px-2">
<div className="hidden px-3 pt-10 text-xl sm:block">{t('settings.title')}</div>
<div className="h-10 sm:h-5" />
{tabs.map((tab) => (
<div
key={tab.id}
className={clsx(
'flex cursor-pointer select-none items-center space-x-2 rounded-lg p-2 sm:px-3',
currentTab === tab.id ? 'bg-neutral-700/70' : 'hover:bg-neutral-700'
currentTab === tab.id ? 'bg-neutral-700/50' : 'hover:bg-neutral-700/50'
)}
onClick={() => changeTab(tab.id)}
>
@@ -156,11 +158,13 @@ export const Settings = () => {
))}
</div>
<div className="flex h-full w-full flex-col items-center overflow-y-auto rounded-r-lg bg-neutral-900 px-3">
<div className="w-full max-w-[600px] pb-10 pt-14">
<>{tabs.find((tab) => tab.id === currentTab)?.component}</>
<ScrollArea className="h-full w-full rounded-r-lg bg-neutral-900/50 px-3">
<div className="flex h-full w-full justify-center">
<div className="w-full max-w-[600px] pb-10 pt-14">
<>{tabs.find((tab) => tab.id === currentTab)?.component}</>
</div>
</div>
</div>
</ScrollArea>
</div>
</Modal>
</>

View File

@@ -44,7 +44,7 @@ export const Header = ({ state, onSuccess }: HeaderProps) => {
return (
<div className="flex items-center justify-between">
<span className="text-base font-bold">{t('settings.tailscale.title')}</span>
<span className="text-base">{t('settings.tailscale.title')}</span>
<div className="flex items-center space-x-2">
{state && ['notLogin', 'stopped', 'running'].includes(state) && (
@@ -101,7 +101,7 @@ export const Header = ({ state, onSuccess }: HeaderProps) => {
placement="bottom"
trigger="click"
>
<div className="flex cursor-pointer rounded p-1 text-white hover:bg-neutral-600">
<div className="flex cursor-pointer rounded p-1 text-white hover:bg-neutral-700/50">
<EllipsisIcon size={18} />
</div>
</Popover>

View File

@@ -49,10 +49,10 @@ export const Tailscale = ({ setIsLocked }: TailscaleProps) => {
return (
<>
<Header state={status?.state} onSuccess={getStatus} />
<Divider />
<Divider className="opacity-50" />
{isLoading ? (
<div className="flex items-center justify-center space-x-2 pt-5 text-neutral-500">
<div className="flex w-full items-center justify-center space-x-2 pt-5 text-neutral-500">
<LoaderCircleIcon className="animate-spin" size={18} />
<span>{t('settings.tailscale.loading')}</span>
</div>

View File

@@ -52,7 +52,7 @@ export const Memory = () => {
title={t('settings.tailscale.memory.tip')}
className="cursor-pointer text-neutral-500"
placement="top"
overlayStyle={{ maxWidth: '400px' }}
styles={{ root: { maxWidth: '400px' } }}
>
<CircleHelpIcon size={15} />
</Tooltip>

View File

@@ -59,7 +59,7 @@ export const Swap = () => {
title={t('settings.tailscale.swap.tip')}
className="cursor-pointer text-neutral-500"
placement="top"
overlayStyle={{ maxWidth: '400px' }}
styles={{ root: { maxWidth: '400px' } }}
>
<CircleHelpIcon size={15} />
</Tooltip>

View File

@@ -6,18 +6,19 @@ import semver from 'semver';
import * as api from '@/api/application.ts';
import { Offline } from './offline.tsx';
import { Preview } from './preview.tsx';
type UpdateProps = {
setIsLocked: (isClosable: boolean) => void;
};
type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'failed';
// type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'failed';
export const Update = ({ setIsLocked }: UpdateProps) => {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>('');
const [status, setStatus] = useState('');
const [currentVersion, setCurrentVersion] = useState('');
const [latestVersion, setLatestVersion] = useState('');
const [errMsg, setErrMsg] = useState('');
@@ -40,10 +41,14 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
}
setCurrentVersion(rsp.data.current);
setLatestVersion(rsp.data.latest);
const isLatest = semver.gte(rsp.data.current, rsp.data.latest);
setStatus(isLatest ? 'latest' : 'outdated');
if (rsp.data?.latest) {
setLatestVersion(rsp.data.latest);
const isLatest = semver.gte(rsp.data.current, rsp.data.latest);
setStatus(isLatest ? 'latest' : 'outdated');
} else {
setStatus('latest');
}
})
.catch(() => {
setStatus('failed');
@@ -77,14 +82,19 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
return (
<>
<div className="text-base font-bold">{t('settings.update.title')}</div>
<Divider />
<div className="text-base">{t('settings.update.title')}</div>
<Divider className="opacity-50" />
<Preview />
<Offline
status={status}
setStatus={setStatus}
setIsLocked={setIsLocked}
setErrMsg={setErrMsg}
/>
<Divider className="opacity-50" />
<div className="my-[40px] h-px bg-neutral-500/10" />
<div className="flex min-h-[400px] flex-col justify-between">
<div className="flex min-h-[320px] flex-col justify-between">
{status === 'loading' && (
<div className="flex justify-center pt-24">
<Spin indicator={<LoadingOutlined spin />} size="large" />
@@ -94,7 +104,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
{status === 'updating' && (
<div className="flex flex-col items-center justify-center space-y-10 pb-10 pt-24">
<Spin size="large" />
<span className="text-blue-600">{t('settings.update.updating')}</span>
<span className="text-neutral-500">{t('settings.update.updating')}</span>
</div>
)}

View File

@@ -0,0 +1,120 @@
import { useRef } from 'react';
import { Button } from 'antd';
import { ExternalLinkIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/application.ts';
interface UpdateProps {
status: string;
setStatus: (status: string) => void;
setIsLocked: (isClosable: boolean) => void;
setErrMsg: (msg: string) => void;
}
export const Offline = ({ status, setStatus, setIsLocked, setErrMsg }: UpdateProps) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement | null>(null);
function handleClick() {
inputRef.current?.click();
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) {
return;
}
if (!validateFilename(file.name)) {
setStatus('failed');
setErrMsg(t('settings.update.offline.invalidName'));
return;
}
upload(file);
}
function upload(file: File | null) {
if (!file) return;
if (!validateFilename(file.name)) {
setStatus('failed');
setErrMsg(t('settings.update.offline.invalidName'));
return;
}
if (status === 'loading' || status === 'updating') {
return;
}
setIsLocked(true);
setStatus('updating');
setErrMsg('');
const formData = new FormData();
formData.append('file', file);
api
.offlineUpdate(formData)
.then((rsp: Response) => {
// Prüfen ob HTTP OK
if (!rsp.ok) throw new Error(`HTTP error ${rsp.status}`);
return rsp.json(); // JSON-Payload parsen
})
.then((rspj: any) => {
// Jetzt rspj ist das tatsächliche JSON
if (rspj.code !== 0 || !rspj.data) {
setStatus('failed');
setErrMsg(rspj.msg || t('settings.update.offline.updateFailed'));
console.log(rspj);
return;
}
})
.finally(() => {
setTimeout(() => {
setIsLocked(false);
setErrMsg('');
window.location.reload();
}, 12000);
});
}
function validateFilename(filename: string) {
const regex: RegExp = /^nanokvm_\d+\.\d+\.\d+\.tar\.gz$/;
return regex.test(filename);
}
return (
<>
<div className="mt-8 flex items-center justify-between">
<div className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<span>{t('settings.update.offline.title')}</span>
<a
className="flex items-center text-neutral-500 hover:text-blue-500"
href="https://github.com/sipeed/NanoKVM/releases"
target="_blank"
>
<ExternalLinkIcon size={15} />
</a>
</div>
<span className="text-xs text-neutral-500">{t('settings.update.offline.desc')}</span>
</div>
<input
id="file-upload"
ref={inputRef}
type="file"
onChange={handleFileChange}
className="hidden"
/>
<Button onClick={handleClick}>{t('settings.update.offline.upload')}</Button>
</div>
</>
);
};

View File

@@ -56,17 +56,17 @@ export const Preview = () => {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<span>{t('settings.update.preview')}</span>
<Tooltip
title={t('settings.update.previewTip')}
className="cursor-pointer"
placement="bottom"
overlayStyle={{ maxWidth: '350px' }}
placement="top"
styles={{ root: { maxWidth: '400px' } }}
>
<CircleAlertIcon size={15} />
<CircleAlertIcon className="text-neutral-500" size={15} />
</Tooltip>
</div>

View File

@@ -2,13 +2,14 @@ import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { client } from '@/lib/websocket.ts';
import { scrollIntervalAtom } from '@/jotai/mouse.ts';
import { scrollDirectionAtom, scrollIntervalAtom } from '@/jotai/mouse.ts';
import { resolutionAtom } from '@/jotai/screen.ts';
import { MouseButton, MouseEvent } from './constants';
export const Absolute = () => {
const resolution = useAtomValue(resolutionAtom);
const scrollDirection = useAtomValue(scrollDirectionAtom);
const scrollInterval = useAtomValue(scrollIntervalAtom);
const lastScrollTimeRef = useRef(0);
@@ -62,8 +63,7 @@ export const Absolute = () => {
function handleWheel(event: any) {
disableEvent(event);
const delta = Math.floor(event.deltaY);
if (delta === 0) return;
if (Math.floor(event.deltaY) === 0) return;
const currentTime = Date.now();
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
@@ -71,7 +71,8 @@ export const Absolute = () => {
}
lastScrollTimeRef.current = currentTime;
const data = [2, MouseEvent.Scroll, 0, 0, delta];
const deltaY = (event.deltaY > 0 ? 1 : -1) * scrollDirection;
const data = [2, MouseEvent.Scroll, 0, 0, deltaY];
client.send(data);
}
@@ -130,7 +131,7 @@ export const Absolute = () => {
canvas.removeEventListener('click', disableEvent);
canvas.removeEventListener('contextmenu', disableEvent);
};
}, [resolution, scrollInterval]);
}, [resolution, scrollDirection, scrollInterval]);
// disable default events
function disableEvent(event: any) {

View File

@@ -4,7 +4,7 @@ import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { client } from '@/lib/websocket.ts';
import { scrollIntervalAtom } from '@/jotai/mouse.ts';
import { scrollDirectionAtom, scrollIntervalAtom } from '@/jotai/mouse.ts';
import { resolutionAtom } from '@/jotai/screen.ts';
import { MouseButton, MouseEvent } from './constants';
@@ -14,6 +14,7 @@ export const Relative = () => {
const [messageApi, contextHolder] = message.useMessage();
const resolution = useAtomValue(resolutionAtom);
const scrollDirection = useAtomValue(scrollDirectionAtom);
const scrollInterval = useAtomValue(scrollIntervalAtom);
const isLockedRef = useRef(false);
@@ -112,8 +113,7 @@ export const Relative = () => {
function handleWheel(event: any) {
disableEvent(event);
const delta = Math.floor(event.deltaY);
if (delta === 0) return;
if (Math.floor(event.deltaY) === 0) return;
const currentTime = Date.now();
if (currentTime - lastScrollTimeRef.current < scrollInterval) {
@@ -121,7 +121,8 @@ export const Relative = () => {
}
lastScrollTimeRef.current = currentTime;
const data = [2, MouseEvent.Scroll, 0, 0, delta];
const deltaY = (event.deltaY > 0 ? 1 : -1) * scrollDirection;
const data = [2, MouseEvent.Scroll, 0, 0, deltaY];
client.send(data);
}
@@ -135,7 +136,7 @@ export const Relative = () => {
canvas.removeEventListener('wheel', handleWheel);
canvas.removeEventListener('contextmenu', disableEvent);
};
}, [resolution, scrollInterval]);
}, [resolution, scrollDirection, scrollInterval]);
// disable default events
function disableEvent(event: any) {

View File

@@ -1,21 +1,30 @@
import { useEffect, useRef } from 'react';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { w3cwebsocket as W3cWebSocket } from 'websocket';
import * as storage from '@/lib/localstorage.ts';
import { getBaseUrl } from '@/lib/service.ts';
import { mouseStyleAtom } from '@/jotai/mouse';
import { resolutionAtom } from '@/jotai/screen.ts';
import { resolutionAtom, videoScaleAtom } from '@/jotai/screen.ts';
import DirectWorker from './direct.worker.ts?worker';
export const H264Direct = () => {
const resolution = useAtomValue(resolutionAtom);
const mouseStyle = useAtomValue(mouseStyleAtom);
const [videoScale, setVideoScale] = useAtom(videoScaleAtom);
const canvasRef = useRef<HTMLCanvasElement>(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
const scale = storage.getVideoScale()
if (scale) {
setVideoScale(scale)
}
}, [setVideoScale])
useEffect(() => {
if (!window.VideoDecoder) {
console.log('Error: WebCodecs API not supported.');
@@ -65,11 +74,13 @@ export const H264Direct = () => {
id="screen"
ref={canvasRef}
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
style={
resolution?.width
style={{
transform: `scale(${videoScale})`,
transformOrigin: 'center',
...(resolution?.width
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
: { maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }
}
: { maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }),
}}
></canvas>
</div>
);

View File

@@ -1,16 +1,18 @@
import { useEffect, useRef, useState } from 'react';
import { Spin } from 'antd';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { w3cwebsocket as W3cWebSocket } from 'websocket';
import { getBaseUrl } from '@/lib/service.ts';
import { mouseStyleAtom } from '@/jotai/mouse.ts';
import { resolutionAtom } from '@/jotai/screen.ts';
import { resolutionAtom, videoScaleAtom } from '@/jotai/screen.ts';
import * as storage from '@/lib/localstorage.ts'
export const H264Webrtc = () => {
const resolution = useAtomValue(resolutionAtom);
const mouseStyle = useAtomValue(mouseStyleAtom);
const [videoScale, setVideoScale] = useAtom(videoScaleAtom);
const [isLoading, setIsLoading] = useState(true);
@@ -170,6 +172,13 @@ export const H264Webrtc = () => {
};
}, []);
useEffect(() => {
const scale = storage.getVideoScale()
if (scale) {
setVideoScale(scale)
}
}, [setVideoScale])
return (
<Spin size="large" tip="Loading" spinning={isLoading}>
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
@@ -177,11 +186,13 @@ export const H264Webrtc = () => {
id="screen"
ref={videoRef}
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
style={
resolution?.width
style={{
transform: `scale(${videoScale})`,
transformOrigin: 'center',
...(resolution?.width
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
: { maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }
}
: { maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' })
}}
muted
autoPlay
playsInline

View File

@@ -1,18 +1,21 @@
import { useEffect } from 'react';
import { Image } from 'antd';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { useAtomValue, useAtom } from 'jotai';
import MonitorXIcon from '@/assets/images/monitor-x.svg';
import { stopFrameDetect } from '@/api/stream.ts';
import { getFrameDetect } from '@/lib/localstorage.ts';
import { getBaseUrl } from '@/lib/service.ts';
import { mouseStyleAtom } from '@/jotai/mouse.ts';
import { resolutionAtom } from '@/jotai/screen.ts';
import { resolutionAtom, videoScaleAtom } from '@/jotai/screen.ts';
import * as storage from '@/lib/localstorage.ts'
export const Mjpeg = () => {
const resolution = useAtomValue(resolutionAtom);
const mouseStyle = useAtomValue(mouseStyleAtom);
const [videoScale, setVideoScale] = useAtom(videoScaleAtom);
useEffect(() => {
// stop frame detect for a while
@@ -22,8 +25,19 @@ export const Mjpeg = () => {
}
}, [resolution]);
useEffect(() => {
const scale = storage.getVideoScale()
if (scale) {
setVideoScale(scale)
}
}, [setVideoScale])
return (
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
<div className="flex h-screen w-screen items-start justify-center xl:items-center"
style={{
transform: `scale(${videoScale})`,
transformOrigin: 'center'
}}>
<Image
id="screen"
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}

View File

@@ -29,6 +29,8 @@ export const Terminal = () => {
const url = `${getBaseUrl('ws')}/api/vm/terminal`;
const ws = new WebSocket(url);
let isPicocomRunning = false;
ws.onopen = () => {
const attachAddon = new AttachAddon(ws);
terminal.loadAddon(attachAddon);
@@ -63,22 +65,44 @@ export const Terminal = () => {
ws.send(
`picocom ${port} --baud ${baud} --parity ${parity} --flow ${flowControl} --databits ${dataBits} --stopbits ${stopBits}\r`
);
isPicocomRunning = true;
};
const exitPicocom = () => {
if (ws.readyState === WebSocket.OPEN && isPicocomRunning) {
ws.send('\x01\x18');
isPicocomRunning = false;
}
};
const resizeScreen = () => {
fitAddon.fit();
sendSize();
};
const cleanupConnection = () => {
exitPicocom();
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
}, 100);
};
const handleBeforeUnload = () => {
cleanupConnection();
};
window.addEventListener('resize', resizeScreen, false);
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
terminal.dispose();
if (ws.readyState === 1) {
ws.close();
}
cleanupConnection();
window.removeEventListener('resize', resizeScreen, false);
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, []);

View File

@@ -3,32 +3,42 @@ import { CheckOutlined, LockOutlined, WifiOutlined } from '@ant-design/icons';
import { Button, Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { connectWifi } from '@/api/network.ts';
import * as api from '@/api/network.ts';
import { Head } from '@/components/head.tsx';
type State = '' | 'loading' | 'success' | 'failed';
type State = '' | 'loading' | 'success' | 'failed' | 'denied';
export const Wifi = () => {
const { t } = useTranslation();
const [state, setState] = useState<State>('');
function connect(values: any) {
if (state === 'loading' || state === 'success') return;
const ssid = values.ssid;
const password = values.password;
if (!ssid || !password) return;
async function connect(values: any) {
if (!values.ssid || !values.password) return;
if (state === 'loading') return;
setState('loading');
connectWifi(ssid, password)
.then((rsp) => {
setState(rsp.code === 0 ? 'success' : 'failed');
})
.catch(() => {
setState('failed');
});
try {
const rsp = await api.connectWifiNoAuth(values.ssid, values.password);
switch (rsp?.code) {
case 0:
setState('success');
return;
case -1:
setState('denied');
return;
case -2:
case -3:
setState('failed');
return;
}
} catch (err) {
console.log(err);
}
setState('success');
}
return (
@@ -74,12 +84,17 @@ export const Wifi = () => {
</Form.Item>
</Form>
<div className="flex h-6 justify-center px-10">
<div className="flex max-w-[500px] justify-center px-5 pt-3 md:px-10">
{state === 'success' && (
<span className="text-sm text-green-500">{t('wifi.success')}</span>
)}
{state === 'failed' && <span className="text-sm text-red-500">{t('wifi.failed')} </span>}
{state === 'denied' && (
<div className="flex flex-col items-center space-y-5">
<span className="text-sm text-red-500">{t('wifi.invalidMode')} </span>
</div>
)}
</div>
</div>
</>