mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 18:09:19 -06:00
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fcc4c3747 | ||
|
|
0d2c52adc3 | ||
|
|
af256e7f92 | ||
|
|
84830421ee | ||
|
|
9b278cdb0e | ||
|
|
9b40a5cf86 | ||
|
|
b8d980be33 | ||
|
|
195037af87 | ||
|
|
c98000069a | ||
|
|
2bd277b57d | ||
|
|
4852ed813b | ||
|
|
fb08df9e4e | ||
|
|
64f3b49e21 | ||
|
|
329c0a0001 | ||
|
|
c28273d1f0 | ||
|
|
1d22d75889 | ||
|
|
6ef8bb74e3 | ||
|
|
0e00760474 | ||
|
|
d969bd68ec | ||
|
|
1e5f4cb24b | ||
|
|
4d3b1c835e | ||
|
|
e3558933b1 | ||
|
|
7762706200 | ||
|
|
b66707c959 | ||
|
|
a3440cf867 | ||
|
|
1b7021d64b | ||
|
|
687dd3bfc4 | ||
|
|
cc072ab158 | ||
|
|
b0a01a9837 | ||
|
|
2d575b916c | ||
|
|
1f908ef181 | ||
|
|
fb2adf13ee | ||
|
|
ad9eb44ad8 | ||
|
|
b4962e7a58 | ||
|
|
14614e5e35 | ||
|
|
9a93ab24f5 | ||
|
|
aaad552633 | ||
|
|
79cc1cc2bf | ||
|
|
01bcda6886 | ||
|
|
ec49bffe06 | ||
|
|
2db5d86a3c | ||
|
|
da6d4840f8 | ||
|
|
9afa21a0b0 | ||
|
|
6168069301 | ||
|
|
ffe71ae6b1 | ||
|
|
a6a97df4e7 | ||
|
|
f05467f45c | ||
|
|
969e3a5e6a | ||
|
|
f0e7dc069c | ||
|
|
f5f70bd015 | ||
|
|
aa80e1d7cc | ||
|
|
d6b03a345b | ||
|
|
c0cfcfa31a | ||
|
|
afdbb179ad | ||
|
|
ed9b7c5126 | ||
|
|
551329a41d | ||
|
|
b3c6b6c53d | ||
|
|
5a9cd18f94 | ||
|
|
63c8fc8eb5 | ||
|
|
3305c69e2d | ||
|
|
56a0468a53 | ||
|
|
52416dfdd7 | ||
|
|
bcd0b717ee | ||
|
|
d9c9eae4f9 | ||
|
|
c1a21c2d69 | ||
|
|
8d822948cf | ||
|
|
7404a205a5 | ||
|
|
d774bf8c58 | ||
|
|
0e1fae8751 | ||
|
|
3ff64483df | ||
|
|
fed92254d2 | ||
|
|
dec1621850 | ||
|
|
116bb50364 | ||
|
|
957d86d64c | ||
|
|
1fa6f48d4e | ||
|
|
0610f6fd1a | ||
|
|
ade9ac863f | ||
|
|
61ff3c5b23 | ||
|
|
19076b2508 | ||
|
|
e175147674 | ||
|
|
2cc64d2208 | ||
|
|
346315b4bb | ||
|
|
648c9f866e | ||
|
|
b5866088fc | ||
|
|
cd954b6f24 | ||
|
|
f27102d344 | ||
|
|
ba8e8b7a5c | ||
|
|
cb3834bb71 | ||
|
|
8ec134c182 | ||
|
|
631d944d70 | ||
|
|
f539576a49 | ||
|
|
fa11347f1b | ||
|
|
34c66dbd8b | ||
|
|
3d5c14405c | ||
|
|
9e4408409f | ||
|
|
9b629c8dc0 | ||
|
|
8da7a70bfc | ||
|
|
ea6ac3c903 | ||
|
|
6b783677e5 | ||
|
|
1cc31c30a3 | ||
|
|
92d5f63679 | ||
|
|
9fd3728aee | ||
|
|
916803d0f0 | ||
|
|
8de298d245 | ||
|
|
75fc8d09ce | ||
|
|
b157b9fe28 | ||
|
|
53a800d252 | ||
|
|
124868e093 | ||
|
|
e684936d15 | ||
|
|
5958fd5c6f | ||
|
|
4d7ee39ccf | ||
|
|
1bb91518b6 | ||
|
|
2d0123cb18 | ||
|
|
455bc19fab | ||
|
|
0003be478e | ||
|
|
d448f11861 | ||
|
|
53924dc8ef | ||
|
|
0d1dca3e0a | ||
|
|
dda98e2a61 | ||
|
|
e8016c1d51 | ||
|
|
c1e2bed94e | ||
|
|
393328eaf6 | ||
|
|
7771d1f326 | ||
|
|
2a34eb55e6 | ||
|
|
9a0c77bb8b | ||
|
|
a1df6376ec |
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
|
||||
68
.github/workflows/issue-tracker.yml
vendored
Normal file
68
.github/workflows/issue-tracker.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Issue Tracker
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened, closed]
|
||||
|
||||
jobs:
|
||||
track-issue:
|
||||
name: Track Issue Updates
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Send Issue Data to API
|
||||
env:
|
||||
RAW_TITLE: ${{ github.event.issue.title }}
|
||||
RAW_BODY: ${{ github.event.issue.body }}
|
||||
RAW_LABELS: ${{ toJSON(github.event.issue.labels) }}
|
||||
RAW_ASSIGNEES: ${{ toJSON(github.event.issue.assignees) }}
|
||||
API_URL: ${{ secrets.ISSUE_TRACKER_API_URL }}
|
||||
VERIFY_TOKEN: ${{ secrets.VERIFY_TOKEN }}
|
||||
run: |
|
||||
ISSUE_DATA=$(jq -n \
|
||||
--arg action "${{ github.event.action }}" \
|
||||
--arg id "${{ github.event.issue.id }}" \
|
||||
--arg num "${{ github.event.issue.number }}" \
|
||||
--arg title "$RAW_TITLE" \
|
||||
--arg body "$RAW_BODY" \
|
||||
--arg state "${{ github.event.issue.state }}" \
|
||||
--arg created_at "${{ github.event.issue.created_at }}" \
|
||||
--arg updated_at "${{ github.event.issue.updated_at }}" \
|
||||
--arg closed_at "${{ github.event.issue.closed_at }}" \
|
||||
--arg url "${{ github.event.issue.html_url }}" \
|
||||
--arg user_login "${{ github.event.issue.user.login }}" \
|
||||
--arg repository "${{ github.repository }}" \
|
||||
--argjson labels "$RAW_LABELS" \
|
||||
--argjson assignees "$RAW_ASSIGNEES" \
|
||||
'{
|
||||
action: $action,
|
||||
issue: {
|
||||
id: ($id | tonumber),
|
||||
number: ($num | tonumber),
|
||||
title: $title,
|
||||
body: $body,
|
||||
state: $state,
|
||||
created_at: $created_at,
|
||||
updated_at: $updated_at,
|
||||
closed_at: $closed_at,
|
||||
url: $url,
|
||||
user: { login: $user_login },
|
||||
labels: $labels,
|
||||
assignees: $assignees
|
||||
},
|
||||
repository: { name: $repository },
|
||||
}')
|
||||
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$API_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Token: $VERIFY_TOKEN" \
|
||||
-d "$ISSUE_DATA")
|
||||
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
|
||||
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
|
||||
echo "Successfully sent issue data to API"
|
||||
else
|
||||
echo "Failed to send issue data to API"
|
||||
exit 1
|
||||
fi
|
||||
71
CHANGELOG.md
71
CHANGELOG.md
@@ -1,3 +1,74 @@
|
||||
## 2.3.3 (2026-01-16)
|
||||
|
||||
### Features
|
||||
|
||||
* Added support for setting the display mode of the menu bar
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed a keyboard issue where IME composition events were not handled correctly
|
||||
* Resolved an issue where the `AltGr` key was not recognized on the Windows system
|
||||
* Fixed a bug where `Command` key combinations were not fully released on the macOS system
|
||||
* Fixed input issues with the `IntlBackslash` key on the German virtual keyboard
|
||||
|
||||
### Localization
|
||||
|
||||
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
|
||||
|
||||
### Chores
|
||||
|
||||
* Bump react-router-dom from 6.27.0 to 6.30.3
|
||||
* Updated eslint
|
||||
|
||||
## 2.3.2 (2026-01-08)
|
||||
|
||||
### Features
|
||||
|
||||
* Added support for custom keyboard shortcuts
|
||||
* Added support for mouse Forward and Back buttons (reboot required)
|
||||
* Added support for touchscreen mouse operation
|
||||
* The menu bar now supports auto-hide and drag
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed an issue where the previous IP address was not released after configuring a static IP
|
||||
|
||||
### Performance
|
||||
|
||||
* Refactored the keyboard to support simultaneous keystrokes and a wider range of international layouts (reboot required)
|
||||
* Refactored the mouse to improve response latency
|
||||
* Refactored the WebSocket module to transmit keyboard and mouse data using the standard HID format
|
||||
|
||||
## 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
97
Makefile
Normal 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."
|
||||
84
README.md
84
README.md
@@ -1,55 +1,47 @@
|
||||
NanoKVM
|
||||
======
|
||||
# NanoKVM
|
||||
|
||||
<div align="center">
|
||||
|
||||

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

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

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

|
||||
|
||||
<h3>
|
||||
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html"> クイックスタート </a>
|
||||
|
|
||||
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM"> ハードウェア </a>
|
||||
</h3>
|
||||
|
||||
[English](./README.md) | [中文](./README_ZH.md) | 日本語 | [한국어](./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 (Lite) | NanoKVM (Full) | PiKVM V4 |
|
||||
|----------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |
|
||||
| 計算ユニット | LicheeRV Nano(RISCV) | LicheeRV Nano(RISCV) | CM4 (ARM) |
|
||||
| 解像度 | 1080P @ 60fps | 1080P @ 60fps | 1080P @ 60fps |
|
||||
| ビデオエンコーディング | MJPEG, H264 | MJPEG, H264 | MJPEG, H264 |
|
||||
| ビデオ遅延 | 90~230ms | 90~230ms | 100~230ms |
|
||||
| UEFI/BIOS | ✓ | ✓ | ✓ |
|
||||
| 仮想HID | ✓ | ✓ | ✓ |
|
||||
| 仮想CD-ROM | ✓ | ✓ | ✓ |
|
||||
| IPMI | ✓ | ✓ | ✓ |
|
||||
| Wake-on-LAN | ✓ | ✓ | ✓ |
|
||||
| ETH | 100M/10M | 100M/10M | 1000M/100M/10M |
|
||||
| ATX電源制御 | なし、ユーザーが自分で接続 | USBインターフェースIO制御ボード | RJ-45インターフェースIO制御ボード |
|
||||
| OLED | なし、ユーザーが自分で接続 | 128x64 0.96" 白 | 128x32 0.91" 白 |
|
||||
| UART | 2 | 2 | 1 |
|
||||
| TFカード | なし | ✓ | ✓ |
|
||||
| 拡張 | なし | PoE | WiFi/LTE |
|
||||
| 消費電力 | 0.2A@5V | 0.2A@5V | ピーク時 2.6A@5V |
|
||||
| 電源入力 | PC USBで給電可能 | PC USBまたは補助電源 | DC 5V 3A電源が必要 |
|
||||
| 冷却 | 静音ファンレス | 静音ファンレス | ファン冷却 |
|
||||
| サイズ | 23x37x15mm ~1/30 PiKVM V4サイズ | 40x36x36mm ~1/7 PiKVM V4サイズ | 120x68x44mm |
|
||||
|
||||

|
||||
|
||||
## NanoKVM ハードウェアプラットフォーム
|
||||
|
||||
NanoKVMはSipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html)に基づいており、仕様、回路図、寸法図は[こちら](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano)で確認できます。
|
||||
|
||||
NanoKVM LiteはLicheeRV NanoとHDMItoCSIボードで構成され、NanoKVM FULLはNanoKVM LiteにNanoKVM-A/Bボードとシェルを追加しています。HDMItoCSIボードはHDMI信号を変換するために使用されます。NanoKVM-AにはOLED、ATX制御出力(USB Type-Cインターフェース)、補助電源供給およびATX電源オン/オフおよびリセットボタンが含まれています。NanoKVM-Bは一端がAボードに接続され、他端がコンピュータのATXピンに接続され、コンピュータの電源をリモートで制御します。
|
||||
|
||||
NanoKVMイメージはLicheeRV Nano SDKおよびMaixCDKに基づいて構築されており、LicheeRV Nanoを使用する資料と互換性があります。逆に、KVMソフトウェアはLicheeRV Nanoや他のSG2002製品では使用できません。LicheeRV NanoやMaixCamでHDMI入力アプリケーションを構築したい場合は、技術サポートを受けるためにお問い合わせください。
|
||||
|
||||
注:SG2002の256MBメモリのうち、現在158MBがマルチメディアサブシステムに割り当てられており、NanoKVMはビデオ画像の取得と処理にこのメモリを使用します。
|
||||
|
||||
+ [NanoKVM-A 回路図](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
|
||||
+ [NanoKVM-B 回路図](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_HDMI_MIPI_31011.pdf)
|
||||
+ [NanoKVM イメージダウンロード](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
|
||||
|
||||
## ロードマップ
|
||||
|
||||
### Q1 2025
|
||||
|
||||
**改善:**
|
||||
|
||||
- [x] HDMI モジュールをリファクタリングし、libmaixcam_lib.so の依存関係を削除
|
||||
- [x] SSH をデフォルトで無効にし、Web UI に有効/無効の切り替えを追加
|
||||
- [x] Tailscale をデフォルトで無効にし、Web UI に有効/無効の切り替えを追加
|
||||
- [x] 設定ファイルに JWT 設定オプションを追加
|
||||
- [x] bcrypt を利用したパスワードストレージのセキュリティ保護
|
||||
|
||||
**配布:**
|
||||
|
||||
- [x] GitHub 経由でのアプリケーション配布
|
||||
- [x] オンラインでの更新の際に整合性チェックを実施
|
||||
- [ ] オフラインでの更新をサポート
|
||||
|
||||
**バグ修正:**
|
||||
|
||||
- [x] DNS の問題を修正
|
||||
- [x] CSRF の脆弱性を修正
|
||||
|
||||
**機能**
|
||||
|
||||
- [ ] [79](https://github.com/sipeed/NanoKVM/issues/79) Zerotier をサポート
|
||||
- [ ] [99](https://github.com/sipeed/NanoKVM/issues/99) WireGuard をサポート
|
||||
- [ ] [249](https://github.com/sipeed/NanoKVM/issues/249) Mouse Jiggler を追加
|
||||
|
||||
## 購入方法
|
||||
|
||||
* [Aliexpress(グローバル、米国とロシアを除く)](https://www.aliexpress.com/item/1005007369816019.html)
|
||||
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
|
||||
* [予約注文 (淘宝とアリエクスプレスがサポートされていない国)](https://sipeed.com/nanokvm)
|
||||
|
||||
## コミュニティ
|
||||
|
||||
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
|
||||
* QQグループ: 703230713
|
||||
139
README_KO.md
139
README_KO.md
@@ -1,139 +0,0 @@
|
||||
NanoKVM
|
||||
======
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
<h3>
|
||||
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 빠른 시작</a>
|
||||
|
|
||||
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ 하드웨어 정보</a>
|
||||
|
|
||||
<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">
|
||||
|
||||

|
||||
|
||||
</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)
|
||||
|
||||

|
||||
|
||||
## 🤝 기여
|
||||
|
||||
여러분의 기여를 환영합니다! 도움을 줄 수 있는 방법은 다음과 같습니다:
|
||||
|
||||
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 파일을 참조하세요.
|
||||
139
README_ZH.md
139
README_ZH.md
@@ -1,139 +0,0 @@
|
||||
NanoKVM
|
||||
======
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
<h3>
|
||||
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 快速开始</a>
|
||||
|
|
||||
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ 硬件信息</a>
|
||||
|
|
||||
<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 现已开启预售。
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</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)
|
||||
|
||||

|
||||
|
||||
## 🤝 贡献代码
|
||||
|
||||
我们十分欢迎任何人来贡献代码!您可以通过以下方式提供帮助:
|
||||
|
||||
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
79
docker/Dockerfile
Normal 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
22
docker/entrypoint
Normal 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 "$@"
|
||||
@@ -60,7 +60,7 @@ start_usb_dev(){
|
||||
fi
|
||||
echo 1 > functions/hid.GS0/protocol
|
||||
echo 8 > functions/hid.GS0/report_length
|
||||
echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\x65\\x05\\x07\\x19\\x00\\x29\\x65\\x81\\x00\\xc0 > functions/hid.GS0/report_desc
|
||||
echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\xE7\\x05\\x07\\x19\\x00\\x29\\xE7\\x81\\x00\\xc0 > functions/hid.GS0/report_desc
|
||||
ln -s functions/hid.GS0 configs/c.1
|
||||
|
||||
# mouse
|
||||
@@ -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
|
||||
@@ -90,7 +90,7 @@ start_usb_dev(){
|
||||
fi
|
||||
echo 2 > functions/hid.GS2/protocol
|
||||
echo 6 > functions/hid.GS2/report_length
|
||||
echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x03\\x15\\x00\\x25\\x01\\x95\\x03\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x05\\x81\\x01\\x05\\x01\\x09\\x30\\x09\\x31\\x15\\x00\\x26\\xff\\x7f\\x35\\x00\\x46\\xff\\x7f\\x75\\x10\\x95\\x02\\x81\\x02\\x05\\x01\\x09\\x38\\x15\\x81\\x25\\x7f\\x35\\x00\\x45\\x00\\x75\\x08\\x95\\x01\\x81\\x06\\xc0\\xc0 > functions/hid.GS2/report_desc
|
||||
echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x05\\x15\\x00\\x25\\x01\\x95\\x05\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x03\\x81\\x01\\x05\\x01\\x09\\x30\\x09\\x31\\x15\\x00\\x26\\xff\\x7f\\x35\\x00\\x46\\xff\\x7f\\x75\\x10\\x95\\x02\\x81\\x02\\x05\\x01\\x09\\x38\\x15\\x81\\x25\\x7f\\x35\\x00\\x45\\x00\\x75\\x08\\x95\\x01\\x81\\x06\\xc0\\xc0 > functions/hid.GS2/report_desc
|
||||
ln -s functions/hid.GS2 configs/c.1
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ EOF
|
||||
}
|
||||
|
||||
start() {
|
||||
ip addr flush dev eth0
|
||||
if [ -e /boot/eth.nodhcp ]; then
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
line=$(clean_line "$line")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -7,3 +7,25 @@ type GetHidModeRsp struct {
|
||||
type SetHidModeReq struct {
|
||||
Mode string `validate:"required"` // normal or hid-only
|
||||
}
|
||||
|
||||
type ShortcutKey struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type Shortcut struct {
|
||||
ID string `json:"id"`
|
||||
Keys []ShortcutKey `json:"keys"`
|
||||
}
|
||||
|
||||
type GetShortcutsRsp struct {
|
||||
Shortcuts []Shortcut `json:"shortcuts"`
|
||||
}
|
||||
|
||||
type AddShortcutReq struct {
|
||||
Keys []ShortcutKey `validate:"required"`
|
||||
}
|
||||
|
||||
type DeleteShortcutReq struct {
|
||||
ID string `validate:"required"`
|
||||
}
|
||||
|
||||
@@ -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
22
server/proto/tailscale.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ func hidRouter(r *gin.Engine) {
|
||||
|
||||
api.POST("/hid/paste", service.Paste) // paste
|
||||
|
||||
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
|
||||
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
|
||||
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
|
||||
|
||||
api.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
api.POST("/hid/mode", service.SetHidMode) // set hid mode
|
||||
api.POST("/hid/reset", service.ResetHid) // reset hid
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
76
server/service/application/install.go
Normal file
76
server/service/application/install.go
Normal 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
|
||||
}
|
||||
84
server/service/application/progress_writer.go
Normal file
84
server/service/application/progress_writer.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
176
server/service/application/update_offline.go
Normal file
176
server/service/application/update_offline.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,9 +18,9 @@ type Hid struct {
|
||||
}
|
||||
|
||||
const (
|
||||
HID0 = "/dev/hidg0"
|
||||
HID1 = "/dev/hidg1"
|
||||
HID2 = "/dev/hidg2"
|
||||
HID0 = "/dev/hidg0" // Keyboard
|
||||
HID1 = "/dev/hidg1" // Mouse (Relative Mode)
|
||||
HID2 = "/dev/hidg2" // Touchpad (Absolute Mode)
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -95,16 +95,22 @@ func (h *Hid) Close() {
|
||||
}
|
||||
|
||||
func (h *Hid) WriteHid0(data []byte) {
|
||||
deadline := time.Now().Add(8 * time.Millisecond)
|
||||
|
||||
h.kbMutex.Lock()
|
||||
_ = h.g0.SetWriteDeadline(deadline)
|
||||
_, err := h.g0.Write(data)
|
||||
h.kbMutex.Unlock()
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrClosed) {
|
||||
switch {
|
||||
case errors.Is(err, os.ErrClosed):
|
||||
log.Errorf("hid already closed, reopen it...")
|
||||
h.OpenNoLock()
|
||||
} else {
|
||||
log.Debugf("write to %s failed: %s", HID0, err)
|
||||
case errors.Is(err, os.ErrDeadlineExceeded):
|
||||
log.Debugf("write to %s timeout", HID0)
|
||||
default:
|
||||
log.Errorf("write to %s failed: %s", HID0, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package hid
|
||||
|
||||
func (h *Hid) Keyboard(queue <-chan []int) {
|
||||
for event := range queue {
|
||||
code := byte(event[0])
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var modifier byte = 0x00
|
||||
if code > 0 {
|
||||
modifier = byte(event[1]) | byte(event[2]) | byte(event[3]) | byte(event[4])
|
||||
func (h *Hid) Keyboard(queue <-chan []byte) {
|
||||
for event := range queue {
|
||||
if len(event) != 8 {
|
||||
log.Debugf("invalid keyboard event: %v", event)
|
||||
continue
|
||||
}
|
||||
|
||||
data := []byte{modifier, 0x00, code, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
h.WriteHid0(data)
|
||||
h.WriteHid0(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +1,18 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
MouseUp = iota
|
||||
MouseDown
|
||||
MouseMoveAbsolute
|
||||
MouseMoveRelative
|
||||
MouseScroll
|
||||
)
|
||||
|
||||
var mouseButtonMap = map[byte]bool{
|
||||
0x01: true,
|
||||
0x02: true,
|
||||
0x04: true,
|
||||
}
|
||||
|
||||
func (h *Hid) Mouse(queue <-chan []int) {
|
||||
func (h *Hid) Mouse(queue <-chan []byte) {
|
||||
for event := range queue {
|
||||
|
||||
switch event[0] {
|
||||
case MouseDown:
|
||||
h.mouseDown(event)
|
||||
case MouseUp:
|
||||
h.mouseUp()
|
||||
case MouseMoveAbsolute:
|
||||
h.mouseMoveAbsolute(event)
|
||||
case MouseMoveRelative:
|
||||
h.mouseMoveRelative(event)
|
||||
case MouseScroll:
|
||||
h.mouseScroll(event)
|
||||
switch len(event) {
|
||||
case 4:
|
||||
h.WriteHid1(event)
|
||||
case 6:
|
||||
h.WriteHid2(event)
|
||||
default:
|
||||
log.Debugf("invalid mouse event: %v", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) mouseDown(event []int) {
|
||||
button := byte(event[1])
|
||||
|
||||
if _, ok := mouseButtonMap[button]; !ok {
|
||||
log.Errorf("invalid mouse button: %v", event)
|
||||
return
|
||||
}
|
||||
|
||||
data := []byte{button, 0, 0, 0}
|
||||
h.WriteHid1(data)
|
||||
}
|
||||
|
||||
func (h *Hid) mouseUp() {
|
||||
data := []byte{0, 0, 0, 0}
|
||||
h.WriteHid1(data)
|
||||
}
|
||||
|
||||
func (h *Hid) mouseScroll(event []int) {
|
||||
direction := 0x01
|
||||
if event[3] > 0 {
|
||||
direction = -0x1
|
||||
}
|
||||
|
||||
data := []byte{0, 0, 0, byte(direction)}
|
||||
h.WriteHid1(data)
|
||||
}
|
||||
func (h *Hid) mouseMoveAbsolute(event []int) {
|
||||
x := make([]byte, 2)
|
||||
y := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(x, uint16(event[2]))
|
||||
binary.LittleEndian.PutUint16(y, uint16(event[3]))
|
||||
|
||||
data := []byte{0, x[0], x[1], y[0], y[1], 0}
|
||||
h.WriteHid2(data)
|
||||
}
|
||||
|
||||
func (h *Hid) mouseMoveRelative(event []int) {
|
||||
data := []byte{byte(event[1]), byte(event[2]), byte(event[3]), 0}
|
||||
h.WriteHid1(data)
|
||||
}
|
||||
|
||||
@@ -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} // §
|
||||
|
||||
172
server/service/hid/shortcut.go
Normal file
172
server/service/hid/shortcut.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
shortcutFile = "/etc/kvm/shortcuts.json"
|
||||
shortcutMutex = sync.RWMutex{}
|
||||
)
|
||||
|
||||
type ShortcutStore struct {
|
||||
Shortcuts []proto.Shortcut `json:"shortcuts"`
|
||||
}
|
||||
|
||||
func (s *Service) GetShortcuts(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
shortcuts, err := listShortcuts()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get shortcuts: %v", err)
|
||||
rsp.ErrRsp(c, -1, "get shortcuts failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetShortcutsRsp{
|
||||
Shortcuts: shortcuts,
|
||||
})
|
||||
log.Debugf("get shortcuts success, total: %d", len(shortcuts))
|
||||
}
|
||||
|
||||
func (s *Service) AddShortcut(c *gin.Context) {
|
||||
var req proto.AddShortcutReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
shortcut, err := addShortcut(req.Keys)
|
||||
if err != nil {
|
||||
log.Errorf("failed to add shortcut: %v", err)
|
||||
rsp.ErrRsp(c, -2, "add shortcut failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("add shortcut %s", shortcut.ID)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteShortcut(c *gin.Context) {
|
||||
var req proto.DeleteShortcutReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
err := deleteShortcut(req.ID)
|
||||
if err != nil {
|
||||
log.Errorf("failed to delete shortcut: %v", err)
|
||||
rsp.ErrRsp(c, -2, "delete shortcut failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("delete shortcut %s", req.ID)
|
||||
}
|
||||
|
||||
func loadShortcuts() (*ShortcutStore, error) {
|
||||
if _, err := os.Stat(shortcutFile); os.IsNotExist(err) {
|
||||
return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(shortcutFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil
|
||||
}
|
||||
|
||||
var store ShortcutStore
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &store, nil
|
||||
}
|
||||
|
||||
func saveShortcuts(store *ShortcutStore) error {
|
||||
data, err := json.Marshal(store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(shortcutFile, data, 0644)
|
||||
}
|
||||
|
||||
func listShortcuts() ([]proto.Shortcut, error) {
|
||||
shortcutMutex.RLock()
|
||||
defer shortcutMutex.RUnlock()
|
||||
|
||||
store, err := loadShortcuts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return store.Shortcuts, nil
|
||||
}
|
||||
|
||||
func addShortcut(keys []proto.ShortcutKey) (*proto.Shortcut, error) {
|
||||
shortcutMutex.Lock()
|
||||
defer shortcutMutex.Unlock()
|
||||
|
||||
store, err := loadShortcuts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shortcut := proto.Shortcut{
|
||||
ID: uuid.New().String(),
|
||||
Keys: keys,
|
||||
}
|
||||
|
||||
store.Shortcuts = append(store.Shortcuts, shortcut)
|
||||
|
||||
if err := saveShortcuts(store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &shortcut, nil
|
||||
}
|
||||
|
||||
func deleteShortcut(id string) error {
|
||||
shortcutMutex.Lock()
|
||||
defer shortcutMutex.Unlock()
|
||||
|
||||
store, err := loadShortcuts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
newShortcuts := make([]proto.Shortcut, 0, len(store.Shortcuts))
|
||||
for _, shortcut := range store.Shortcuts {
|
||||
if shortcut.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
newShortcuts = append(newShortcuts, shortcut)
|
||||
}
|
||||
|
||||
if !found {
|
||||
return errors.New("shortcut not found")
|
||||
}
|
||||
|
||||
store.Shortcuts = newShortcuts
|
||||
return saveShortcuts(store)
|
||||
}
|
||||
@@ -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", "")
|
||||
}
|
||||
|
||||
104
server/service/vm/autostart.go
Normal file
104
server/service/vm/autostart.go
Normal 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)
|
||||
}
|
||||
@@ -50,6 +50,7 @@ func (s *Service) Terminal(c *gin.Context) {
|
||||
defer func() {
|
||||
_ = ptmx.Close()
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
|
||||
go wsWrite(ws, ptmx)
|
||||
|
||||
109
server/service/ws/client.go
Normal file
109
server/service/ws/client.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/service/vm/jiggler"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
Heartbeat = iota
|
||||
KeyboardEvent
|
||||
MouseEvent
|
||||
)
|
||||
|
||||
func NewClient(ws *websocket.Conn) *Client {
|
||||
client := &Client{
|
||||
ws: ws,
|
||||
hid: hid.GetHid(),
|
||||
keyboard: make(chan []byte, 200),
|
||||
mouse: make(chan []byte, 200),
|
||||
lastHeartbeat: time.Time{},
|
||||
}
|
||||
|
||||
client.hid.Open()
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *Client) Start() {
|
||||
defer c.Close()
|
||||
|
||||
go c.hid.Keyboard(c.keyboard)
|
||||
go c.hid.Mouse(c.mouse)
|
||||
|
||||
_ = c.Read()
|
||||
}
|
||||
|
||||
func (c *Client) Read() error {
|
||||
var zeroTime time.Time
|
||||
_ = c.ws.SetReadDeadline(zeroTime)
|
||||
|
||||
for {
|
||||
messageType, data, err := c.ws.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("received message %d: %v", messageType, data)
|
||||
|
||||
switch data[0] {
|
||||
case Heartbeat:
|
||||
c.UpdateHeartbeat()
|
||||
case KeyboardEvent:
|
||||
writeQueue(c.keyboard, data[1:])
|
||||
case MouseEvent:
|
||||
writeQueue(c.mouse, data[1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Write(event string, data string) error {
|
||||
message := &Message{
|
||||
Type: event,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
messageByte, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
log.Errorf("failed to marshal message: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
_ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
return c.ws.WriteMessage(websocket.TextMessage, messageByte)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateHeartbeat() {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
c.lastHeartbeat = time.Now()
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
_ = c.ws.Close()
|
||||
|
||||
closeQueue(c.keyboard)
|
||||
closeQueue(c.mouse)
|
||||
|
||||
log.Debug("websocket disconnected")
|
||||
}
|
||||
|
||||
func writeQueue(queue chan []byte, data []byte) {
|
||||
queue <- data
|
||||
jiggler.GetJiggler().Update()
|
||||
}
|
||||
|
||||
func closeQueue(queue chan []byte) {
|
||||
for range queue {
|
||||
}
|
||||
close(queue)
|
||||
}
|
||||
46
server/service/ws/manager.go
Normal file
46
server/service/ws/manager.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var (
|
||||
globalManager *Manager
|
||||
managerOnce sync.Once
|
||||
)
|
||||
|
||||
func GetManager() *Manager {
|
||||
managerOnce.Do(func() {
|
||||
globalManager = &Manager{
|
||||
clients: make(map[*websocket.Conn]*Client),
|
||||
mutex: sync.RWMutex{},
|
||||
}
|
||||
})
|
||||
return globalManager
|
||||
}
|
||||
|
||||
func (m *Manager) AddClient(ws *websocket.Conn, client *Client) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.clients[ws] = client
|
||||
}
|
||||
|
||||
func (m *Manager) RemoveClient(ws *websocket.Conn) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
delete(m.clients, ws)
|
||||
}
|
||||
|
||||
func (m *Manager) GetClients() []*Client {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
clients := make([]*Client, 0, len(m.clients))
|
||||
for _, c := range m.clients {
|
||||
clients = append(clients, c)
|
||||
}
|
||||
|
||||
return clients
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package ws
|
||||
|
||||
type Stream struct {
|
||||
Type string `json:"type"`
|
||||
State int `json:"state"`
|
||||
}
|
||||
@@ -1,7 +1,41 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) Connect(c *gin.Context) {
|
||||
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Errorf("create websocket failed: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("websocket connected")
|
||||
|
||||
client := NewClient(ws)
|
||||
|
||||
manager := GetManager()
|
||||
manager.AddClient(ws, client)
|
||||
defer manager.RemoveClient(ws)
|
||||
|
||||
client.Start()
|
||||
}
|
||||
|
||||
29
server/service/ws/types.go
Normal file
29
server/service/ws/types.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"NanoKVM-Server/service/hid"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
clients map[*websocket.Conn]*Client
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
ws *websocket.Conn
|
||||
hid *hid.Hid
|
||||
keyboard chan []byte
|
||||
mouse chan []byte
|
||||
lastHeartbeat time.Time
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/service/vm/jiggler"
|
||||
)
|
||||
|
||||
const (
|
||||
KeyboardEvent int = 1
|
||||
MouseEvent int = 2
|
||||
)
|
||||
|
||||
type WsClient struct {
|
||||
conn *websocket.Conn
|
||||
hid *hid.Hid
|
||||
keyboard chan []int
|
||||
mouse chan []int
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
func (s *Service) Connect(c *gin.Context) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Errorf("create websocket failed: %s", err)
|
||||
return
|
||||
}
|
||||
log.Debug("websocket connected")
|
||||
|
||||
client := &WsClient{
|
||||
hid: hid.GetHid(),
|
||||
conn: conn,
|
||||
keyboard: make(chan []int, 200),
|
||||
mouse: make(chan []int, 200),
|
||||
}
|
||||
|
||||
go client.Start()
|
||||
}
|
||||
|
||||
func (c *WsClient) Start() {
|
||||
defer c.Clean()
|
||||
|
||||
c.hid.Open()
|
||||
|
||||
go c.hid.Keyboard(c.keyboard)
|
||||
go c.hid.Mouse(c.mouse)
|
||||
|
||||
_ = c.Read()
|
||||
}
|
||||
|
||||
func (c *WsClient) Read() error {
|
||||
var zeroTime time.Time
|
||||
_ = c.conn.SetReadDeadline(zeroTime)
|
||||
|
||||
for {
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("received message: %s", message)
|
||||
|
||||
var event []int
|
||||
err = json.Unmarshal(message, &event)
|
||||
if err != nil {
|
||||
log.Debugf("received invalid message: %s", message)
|
||||
continue
|
||||
}
|
||||
|
||||
if event[0] == KeyboardEvent {
|
||||
c.keyboard <- event[1:]
|
||||
} else if event[0] == MouseEvent {
|
||||
c.mouse <- event[1:]
|
||||
}
|
||||
|
||||
// update latest HID operation time
|
||||
jiggler.GetJiggler().Update()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WsClient) Write(message []byte) error {
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
return c.conn.WriteMessage(websocket.TextMessage, message)
|
||||
}
|
||||
|
||||
func (c *WsClient) Clean() {
|
||||
_ = c.conn.Close()
|
||||
|
||||
go clearQueue(c.keyboard)
|
||||
close(c.keyboard)
|
||||
|
||||
go clearQueue(c.mouse)
|
||||
close(c.mouse)
|
||||
|
||||
c.hid.Close()
|
||||
|
||||
log.Debug("websocket disconnected")
|
||||
}
|
||||
|
||||
func clearQueue(queue chan []int) {
|
||||
for range queue {
|
||||
}
|
||||
}
|
||||
@@ -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_
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'prettier',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
32
web/eslint.config.js
Normal file
32
web/eslint.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'public/mockServiceWorker.js'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier
|
||||
)
|
||||
@@ -7,11 +7,12 @@
|
||||
"dev": "vite",
|
||||
"mocked": "vite --mode mocked",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "eslint . --report-unused-disable-directives",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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",
|
||||
@@ -30,7 +31,7 @@
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^14.1.3",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-simple-keyboard": "^3.8.19",
|
||||
"semver": "^7.6.3",
|
||||
"vaul": "^0.9.9",
|
||||
@@ -38,6 +39,7 @@
|
||||
"yocto-queue": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -54,12 +56,14 @@
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^17.0.0",
|
||||
"msw": "^2.6.0",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"vite": "7.1.11",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
},
|
||||
@@ -68,4 +72,4 @@
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
440
web/pnpm-lock.yaml
generated
440
web/pnpm-lock.yaml
generated
@@ -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
|
||||
@@ -66,8 +69,8 @@ importers:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(react@18.3.1)
|
||||
react-router-dom:
|
||||
specifier: ^6.27.0
|
||||
version: 6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
specifier: ^6.30.3
|
||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-simple-keyboard:
|
||||
specifier: ^3.8.19
|
||||
version: 3.8.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -84,6 +87,9 @@ importers:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2
|
||||
'@ianvs/prettier-plugin-sort-imports':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(prettier@3.4.2)
|
||||
@@ -132,6 +138,9 @@ importers:
|
||||
eslint-plugin-react-refresh:
|
||||
specifier: ^0.4.16
|
||||
version: 0.4.16(eslint@9.39.1(jiti@1.21.7))
|
||||
globals:
|
||||
specifier: ^17.0.0
|
||||
version: 17.0.0
|
||||
msw:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0(@types/node@22.9.0)(typescript@5.6.3)
|
||||
@@ -150,6 +159,9 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.6.3
|
||||
typescript-eslint:
|
||||
specifier: ^8.53.0
|
||||
version: 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
|
||||
vite:
|
||||
specifier: 7.1.11
|
||||
version: 7.1.11(@types/node@22.9.0)(jiti@1.21.7)(yaml@2.6.0)
|
||||
@@ -495,10 +507,20 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1':
|
||||
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
|
||||
'@eslint-community/regexpp@4.12.1':
|
||||
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
|
||||
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||
|
||||
'@eslint-community/regexpp@4.12.2':
|
||||
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
|
||||
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||
|
||||
'@eslint/config-array@0.21.1':
|
||||
resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -519,6 +541,10 @@ packages:
|
||||
resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/js@9.39.2':
|
||||
resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/object-schema@2.1.7':
|
||||
resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -640,9 +666,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 +684,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 +702,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 +724,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 +803,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 +829,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 +864,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 +882,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 +918,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'}
|
||||
@@ -857,8 +982,8 @@ packages:
|
||||
react: '>=16.9.0'
|
||||
react-dom: '>=16.9.0'
|
||||
|
||||
'@remix-run/router@1.20.0':
|
||||
resolution: {integrity: sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==}
|
||||
'@remix-run/router@1.23.2':
|
||||
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||
@@ -1036,6 +1161,14 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.53.0':
|
||||
resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.53.0
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/parser@8.48.0':
|
||||
resolution: {integrity: sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1043,22 +1176,45 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/parser@8.53.0':
|
||||
resolution: {integrity: sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.48.0':
|
||||
resolution: {integrity: sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.53.0':
|
||||
resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@8.48.0':
|
||||
resolution: {integrity: sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/scope-manager@8.53.0':
|
||||
resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.48.0':
|
||||
resolution: {integrity: sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.53.0':
|
||||
resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.48.0':
|
||||
resolution: {integrity: sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1066,16 +1222,33 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.53.0':
|
||||
resolution: {integrity: sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/types@8.48.0':
|
||||
resolution: {integrity: sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/types@8.53.0':
|
||||
resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.48.0':
|
||||
resolution: {integrity: sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.53.0':
|
||||
resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@8.48.0':
|
||||
resolution: {integrity: sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1083,10 +1256,21 @@ packages:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@8.53.0':
|
||||
resolution: {integrity: sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.48.0':
|
||||
resolution: {integrity: sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.53.0':
|
||||
resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0':
|
||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
@@ -1685,6 +1869,10 @@ packages:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
globals@17.0.0:
|
||||
resolution: {integrity: sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
globalthis@1.0.4:
|
||||
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1982,8 +2170,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
|
||||
|
||||
@@ -2603,15 +2791,15 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
react-router-dom@6.27.0:
|
||||
resolution: {integrity: sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==}
|
||||
react-router-dom@6.30.3:
|
||||
resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
react-router@6.27.0:
|
||||
resolution: {integrity: sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==}
|
||||
react-router@6.30.3:
|
||||
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
@@ -2712,6 +2900,11 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.7.3:
|
||||
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2839,6 +3032,12 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
ts-api-utils@2.4.0:
|
||||
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
|
||||
engines: {node: '>=18.12'}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
@@ -2889,6 +3088,13 @@ packages:
|
||||
typedarray-to-buffer@3.1.5:
|
||||
resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==}
|
||||
|
||||
typescript-eslint@8.53.0:
|
||||
resolution: {integrity: sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
typescript@5.6.3:
|
||||
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -3396,8 +3602,15 @@ snapshots:
|
||||
eslint: 9.39.1(jiti@1.21.7)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.1(jiti@1.21.7))':
|
||||
dependencies:
|
||||
eslint: 9.39.1(jiti@1.21.7)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/regexpp@4.12.1': {}
|
||||
|
||||
'@eslint-community/regexpp@4.12.2': {}
|
||||
|
||||
'@eslint/config-array@0.21.1':
|
||||
dependencies:
|
||||
'@eslint/object-schema': 2.1.7
|
||||
@@ -3430,6 +3643,8 @@ snapshots:
|
||||
|
||||
'@eslint/js@9.39.1': {}
|
||||
|
||||
'@eslint/js@9.39.2': {}
|
||||
|
||||
'@eslint/object-schema@2.1.7': {}
|
||||
|
||||
'@eslint/plugin-kit@0.4.1':
|
||||
@@ -3551,20 +3766,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 +3818,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 +3881,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 +3900,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 +3933,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 +3972,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
|
||||
@@ -3754,7 +4046,7 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@remix-run/router@1.20.0': {}
|
||||
'@remix-run/router@1.23.2': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
|
||||
@@ -3899,6 +4191,22 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
|
||||
'@typescript-eslint/scope-manager': 8.53.0
|
||||
'@typescript-eslint/type-utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
|
||||
'@typescript-eslint/utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
|
||||
'@typescript-eslint/visitor-keys': 8.53.0
|
||||
eslint: 9.39.1(jiti@1.21.7)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.4.0(typescript@5.6.3)
|
||||
typescript: 5.6.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.48.0
|
||||
@@ -3911,6 +4219,18 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.53.0
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
|
||||
'@typescript-eslint/visitor-keys': 8.53.0
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.1(jiti@1.21.7)
|
||||
typescript: 5.6.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.48.0(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.6.3)
|
||||
@@ -3920,15 +4240,33 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.53.0(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.6.3)
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
debug: 4.4.3
|
||||
typescript: 5.6.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.48.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.48.0
|
||||
'@typescript-eslint/visitor-keys': 8.48.0
|
||||
|
||||
'@typescript-eslint/scope-manager@8.53.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
'@typescript-eslint/visitor-keys': 8.53.0
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.48.0(typescript@5.6.3)':
|
||||
dependencies:
|
||||
typescript: 5.6.3
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.6.3)':
|
||||
dependencies:
|
||||
typescript: 5.6.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.48.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.48.0
|
||||
@@ -3941,8 +4279,22 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/type-utils@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
|
||||
'@typescript-eslint/utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.1(jiti@1.21.7)
|
||||
ts-api-utils: 2.4.0(typescript@5.6.3)
|
||||
typescript: 5.6.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/types@8.48.0': {}
|
||||
|
||||
'@typescript-eslint/types@8.53.0': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.48.0(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.48.0(typescript@5.6.3)
|
||||
@@ -3958,6 +4310,21 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.53.0(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.53.0(typescript@5.6.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.6.3)
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
'@typescript-eslint/visitor-keys': 8.53.0
|
||||
debug: 4.4.3
|
||||
minimatch: 9.0.5
|
||||
semver: 7.7.3
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.4.0(typescript@5.6.3)
|
||||
typescript: 5.6.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.48.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
|
||||
@@ -3969,11 +4336,27 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@1.21.7))
|
||||
'@typescript-eslint/scope-manager': 8.53.0
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
|
||||
eslint: 9.39.1(jiti@1.21.7)
|
||||
typescript: 5.6.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.48.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.48.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.53.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@vitejs/plugin-react@4.7.0(vite@7.1.11(@types/node@22.9.0)(jiti@1.21.7)(yaml@2.6.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -4773,6 +5156,8 @@ snapshots:
|
||||
|
||||
globals@14.0.0: {}
|
||||
|
||||
globals@17.0.0: {}
|
||||
|
||||
globalthis@1.0.4:
|
||||
dependencies:
|
||||
define-properties: 1.2.1
|
||||
@@ -5026,7 +5411,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
|
||||
|
||||
@@ -5654,16 +6039,16 @@ snapshots:
|
||||
react: 18.3.1
|
||||
shallow-equal: 3.1.0
|
||||
|
||||
react-router-dom@6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.20.0
|
||||
'@remix-run/router': 1.23.2
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-router: 6.27.0(react@18.3.1)
|
||||
react-router: 6.30.3(react@18.3.1)
|
||||
|
||||
react-router@6.27.0(react@18.3.1):
|
||||
react-router@6.30.3(react@18.3.1):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.20.0
|
||||
'@remix-run/router': 1.23.2
|
||||
react: 18.3.1
|
||||
|
||||
react-simple-keyboard@3.8.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
@@ -5790,6 +6175,8 @@ snapshots:
|
||||
|
||||
semver@7.6.3: {}
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -5962,6 +6349,10 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.6.3
|
||||
|
||||
ts-api-utils@2.4.0(typescript@5.6.3):
|
||||
dependencies:
|
||||
typescript: 5.6.3
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
tsconfck@3.1.4(typescript@5.6.3):
|
||||
@@ -6016,6 +6407,17 @@ snapshots:
|
||||
dependencies:
|
||||
is-typedarray: 1.0.0
|
||||
|
||||
typescript-eslint@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
|
||||
'@typescript-eslint/parser': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
|
||||
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.6.3)
|
||||
'@typescript-eslint/utils': 8.53.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.6.3)
|
||||
eslint: 9.39.1(jiti@1.21.7)
|
||||
typescript: 5.6.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
typescript@5.6.3: {}
|
||||
|
||||
unbox-primitive@1.0.2:
|
||||
|
||||
@@ -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
20
web/src/api/autostart.ts
Normal 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);
|
||||
}
|
||||
@@ -22,3 +22,24 @@ export function setHidMode(mode: string) {
|
||||
};
|
||||
return http.post('/api/hid/mode', data);
|
||||
}
|
||||
|
||||
// get shortcuts
|
||||
export function getShortcuts() {
|
||||
return http.get('/api/hid/shortcuts');
|
||||
}
|
||||
|
||||
// add shortcut
|
||||
export function addShortcut(keys: any[]) {
|
||||
const data = {
|
||||
keys
|
||||
};
|
||||
return http.post('/api/hid/shortcut', data);
|
||||
}
|
||||
|
||||
// delete shortcut
|
||||
export function deleteShortcut(id: string) {
|
||||
const data = {
|
||||
id
|
||||
};
|
||||
return http.delete('/api/hid/shortcut', data);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,10 @@ export function getWolMacs() {
|
||||
}
|
||||
|
||||
export function deleteWolMac(mac: string) {
|
||||
return http.request({
|
||||
method: 'delete',
|
||||
url: '/api/network/wol/mac',
|
||||
data: { mac }
|
||||
});
|
||||
const data = {
|
||||
mac
|
||||
};
|
||||
return http.delete('/api/network/wol/mac', data);
|
||||
}
|
||||
|
||||
// set Mac name
|
||||
@@ -31,11 +30,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');
|
||||
}
|
||||
|
||||
@@ -20,9 +20,8 @@ export function getScripts() {
|
||||
}
|
||||
|
||||
export function deleteScript(name: string) {
|
||||
return http.request({
|
||||
url: '/api/vm/script',
|
||||
method: 'delete',
|
||||
data: { name }
|
||||
});
|
||||
const data = {
|
||||
name
|
||||
};
|
||||
return http.delete('/api/vm/script', data);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { submenuOpenCountAtom } from '@/jotai/settings.ts';
|
||||
|
||||
type MenuItemProps = {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
@@ -20,6 +23,7 @@ export const MenuItem = ({
|
||||
onOpenChange
|
||||
}: MenuItemProps) => {
|
||||
const isBigScreen = useMediaQuery({ minWidth: 640 });
|
||||
const setSubmenuOpenCount = useSetAtom(submenuOpenCountAtom);
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
@@ -28,6 +32,9 @@ export const MenuItem = ({
|
||||
setIsTooltipOpen(false);
|
||||
setIsPopoverOpen(open);
|
||||
|
||||
// Update global submenu count
|
||||
setSubmenuOpenCount((count) => (open ? count + 1 : Math.max(0, count - 1)));
|
||||
|
||||
if (onOpenChange) {
|
||||
onOpenChange(open);
|
||||
}
|
||||
|
||||
28
web/src/components/ui/kbd.tsx
Normal file
28
web/src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={clsx(
|
||||
'pointer-events-none inline-flex h-5 w-fit min-w-9 select-none items-center justify-center gap-1 rounded border-b border-b-neutral-600 bg-neutral-700/70 px-1 font-sans text-xs font-medium text-neutral-300',
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
'[[data-slot=tooltip-content]_&]:text-background [[data-slot=tooltip-content]_&]:bg-background/10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={clsx('inline-flex items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup };
|
||||
53
web/src/components/ui/scroll-area.tsx
Normal file
53
web/src/components/ui/scroll-area.tsx
Normal 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 };
|
||||
55
web/src/hooks/useMenuBounds.ts
Normal file
55
web/src/hooks/useMenuBounds.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { RefObject, useCallback, useEffect, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
|
||||
|
||||
export interface MenuBounds {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function useMenuBounds(
|
||||
nodeRef: RefObject<HTMLDivElement | null>,
|
||||
isMenuExpanded: boolean
|
||||
): MenuBounds {
|
||||
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
|
||||
const [menuBounds, setMenuBounds] = useState<MenuBounds>({
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0
|
||||
});
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (!nodeRef.current) return;
|
||||
|
||||
const elementRect = nodeRef.current.getBoundingClientRect();
|
||||
const width = (window.innerWidth - elementRect.width) / 2;
|
||||
|
||||
setMenuBounds({
|
||||
left: -width,
|
||||
top: -10,
|
||||
right: width,
|
||||
bottom: window.innerHeight - elementRect.height - 10
|
||||
});
|
||||
}, [nodeRef]);
|
||||
|
||||
// Update bounds on mount and resize
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
// Update bounds when menu items or expansion state change
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
}, [menuDisabledItems, isMenuExpanded, handleResize]);
|
||||
|
||||
return menuBounds;
|
||||
}
|
||||
129
web/src/hooks/useMenuVisibility.ts
Normal file
129
web/src/hooks/useMenuVisibility.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
|
||||
import { getMenuDisabledItems, getMenuDisplayMode } from '@/lib/localstorage.ts';
|
||||
import {
|
||||
menuDisabledItemsAtom,
|
||||
menuDisplayModeAtom,
|
||||
submenuOpenCountAtom
|
||||
} from '@/jotai/settings.ts';
|
||||
|
||||
const HIDE_TIMEOUT = 5000;
|
||||
|
||||
export interface MenuVisibilityState {
|
||||
isInitialized: boolean;
|
||||
isMenuExpanded: boolean;
|
||||
isMenuHidden: boolean;
|
||||
isMenuMoved: boolean;
|
||||
isInvisible: boolean;
|
||||
handleHovered: (hovered: boolean) => void;
|
||||
handleMoved: () => void;
|
||||
setIsMenuExpanded: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export function useMenuVisibility(): MenuVisibilityState {
|
||||
const [menuDisplayMode, setMenuDisplayMode] = useAtom(menuDisplayModeAtom);
|
||||
const setMenuDisabledItems = useSetAtom(menuDisabledItemsAtom);
|
||||
const submenuOpenCount = useAtomValue(submenuOpenCountAtom);
|
||||
|
||||
const [isMenuExpanded, setIsMenuExpanded] = useState(true);
|
||||
const [isMenuMoved, setIsMenuMoved] = useState(false);
|
||||
const [isMenuHovered, setIsMenuHovered] = useState(false);
|
||||
const [isMenuHidden, setIsMenuHidden] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const startCountdown = useCallback(() => {
|
||||
if (menuDisplayMode !== 'auto' || submenuOpenCount > 0 || !isMenuExpanded || isMenuMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
setIsMenuHidden(true);
|
||||
}, HIDE_TIMEOUT);
|
||||
}, [menuDisplayMode, submenuOpenCount, isMenuExpanded, isMenuMoved]);
|
||||
|
||||
const stopCountdown = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize menu settings
|
||||
useEffect(() => {
|
||||
const displayMode = getMenuDisplayMode();
|
||||
setMenuDisplayMode(displayMode);
|
||||
|
||||
const items = getMenuDisabledItems();
|
||||
setMenuDisabledItems(items);
|
||||
|
||||
if (displayMode === 'off') {
|
||||
setIsMenuExpanded(false);
|
||||
}
|
||||
|
||||
setIsInitialized(true);
|
||||
|
||||
return () => {
|
||||
stopCountdown();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle display mode changes
|
||||
useEffect(() => {
|
||||
if (menuDisplayMode === 'off') {
|
||||
setIsMenuExpanded(false);
|
||||
setIsMenuHidden(false);
|
||||
stopCountdown();
|
||||
} else if (menuDisplayMode === 'always') {
|
||||
setIsMenuExpanded(true);
|
||||
setIsMenuHidden(false);
|
||||
stopCountdown();
|
||||
} else if (menuDisplayMode === 'auto') {
|
||||
setIsMenuExpanded(true);
|
||||
setIsMenuHidden(false);
|
||||
startCountdown();
|
||||
}
|
||||
}, [menuDisplayMode, startCountdown, stopCountdown]);
|
||||
|
||||
// Handle hover and submenu state
|
||||
useEffect(() => {
|
||||
if (submenuOpenCount === 0 && !isMenuHovered) {
|
||||
startCountdown();
|
||||
} else {
|
||||
stopCountdown();
|
||||
}
|
||||
}, [submenuOpenCount, isMenuHovered, startCountdown, stopCountdown]);
|
||||
|
||||
// Handle hover state
|
||||
const handleHovered = useCallback((hovered: boolean) => {
|
||||
setIsMenuHovered(hovered);
|
||||
if (hovered) {
|
||||
setIsMenuHidden(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMoved = useCallback(() => {
|
||||
if (!isMenuMoved) {
|
||||
setIsMenuMoved(true);
|
||||
}
|
||||
}, [isMenuMoved]);
|
||||
|
||||
const isInvisible = menuDisplayMode === 'off' && !isMenuExpanded;
|
||||
|
||||
return {
|
||||
isMenuExpanded,
|
||||
isMenuHidden,
|
||||
isMenuMoved,
|
||||
isInvisible,
|
||||
isInitialized,
|
||||
handleHovered,
|
||||
handleMoved,
|
||||
setIsMenuExpanded
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
@@ -73,13 +76,23 @@ const en = {
|
||||
placeholder: 'Please input',
|
||||
submit: 'Submit',
|
||||
virtual: 'Keyboard',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del',
|
||||
readClipboard: 'Read from Clipboard',
|
||||
clipboardPermissionDenied:
|
||||
'Clipboard permission denied. Please allow clipboard access in your browser.',
|
||||
clipboardReadError: 'Failed to read clipboard',
|
||||
dropdownEnglish: 'English',
|
||||
dropdownGerman: 'German'
|
||||
dropdownGerman: 'German',
|
||||
dropdownRussian: 'Russian',
|
||||
shortcut: {
|
||||
title: 'Shortcuts',
|
||||
custom: 'Custom',
|
||||
capture: 'Click here to capture shortcut',
|
||||
clear: 'Clear',
|
||||
save: 'Save',
|
||||
captureTips:
|
||||
'Capturing system-level keys (such as the Windows key) requires full-screen permission.',
|
||||
enterFullScreen: 'Toggle full-screen mode.'
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: 'Mouse',
|
||||
@@ -93,6 +106,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 +199,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,10 +242,19 @@ const en = {
|
||||
title: 'Appearance',
|
||||
display: 'Display',
|
||||
language: 'Language',
|
||||
menuBar: 'Menu Bar',
|
||||
menuBarDesc: 'Display icons in the menu bar',
|
||||
languageDesc: 'Select the language for the interface',
|
||||
webTitle: 'Web Title',
|
||||
webTitleDesc: 'Customize the web page title'
|
||||
webTitleDesc: 'Customize the web page title',
|
||||
menuBar: {
|
||||
title: 'Menu Bar',
|
||||
mode: 'Display Mode',
|
||||
modeDesc: 'Display menu bar on the screen',
|
||||
modeOff: 'Off',
|
||||
modeAuto: 'Auto hide',
|
||||
modeAlways: 'Always visible',
|
||||
icons: 'Submenu Icons',
|
||||
iconsDesc: 'Display submenu icons in the menu bar'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: 'Device',
|
||||
@@ -246,7 +274,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 +315,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 +392,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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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을 컴퓨터에 연결하세요.',
|
||||
@@ -210,6 +217,7 @@ const ko = {
|
||||
title: '디자인',
|
||||
display: '표시',
|
||||
language: '언어',
|
||||
languageDesc: '인터페이스 언어를 선택하세요',
|
||||
menuBar: '메뉴 바',
|
||||
menuBarDesc: '메뉴 바에 아이콘을 표시',
|
||||
webTitle: '웹 제목',
|
||||
@@ -278,8 +286,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 +304,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 +320,9 @@ const ko = {
|
||||
account: '계정',
|
||||
logout: '로그아웃',
|
||||
logoutDesc: '정말로 로그아웃 하시겠습니까?',
|
||||
logout2: '정말로 로그아웃 합니까?',
|
||||
logout2: '정말로 로그아웃 할까요?',
|
||||
uninstall: 'Tailscale 제거',
|
||||
uninstallDesc: '정말로 Tailscale을 제거할까요?',
|
||||
okBtn: '네',
|
||||
cancelBtn: '아니오'
|
||||
},
|
||||
@@ -323,7 +337,14 @@ const ko = {
|
||||
cancel: '취소',
|
||||
preview: '미리보기 업데이트',
|
||||
previewDesc: '새로운 기능과 개선 사항에 미리 접근하세요',
|
||||
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!'
|
||||
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!',
|
||||
offline: {
|
||||
title: '오프라인 업데이트',
|
||||
desc: '로컬 설치 패키지를 통한 업데이트',
|
||||
upload: '업로드',
|
||||
invalidName: '유효하지 않은 파일 이름 형식입니다. GitHub 릴리즈에서 다운로드하세요.',
|
||||
updateFailed: '업데이트에 실패했습니다. 재시도하세요.'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
title: '계정',
|
||||
|
||||
@@ -73,7 +73,10 @@ const ru = {
|
||||
placeholder: 'Текст для ввода',
|
||||
submit: 'Вставить',
|
||||
virtual: 'Клавиатура',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
ctrlaltdel: 'Ctrl+Alt+Del',
|
||||
dropdownEnglish: 'Английский',
|
||||
dropdownGerman: 'Немецкий',
|
||||
dropdownRussian: 'Русский'
|
||||
},
|
||||
mouse: {
|
||||
title: 'Мышь',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -94,7 +94,7 @@ const tr = {
|
||||
resetHid: 'İnsan Arayüz Cihazı\'nı sıfırlayın',
|
||||
hidOnly: {
|
||||
title: 'Sadece İnsan Arayüz Cihazı modu',
|
||||
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı\'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
|
||||
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
|
||||
tip1: 'Sadece İnsan Arayüz Cihazı modunu aktifleştirmek, sanal U-disk’i ve sanal ağı devre dışı bırakacaktır.',
|
||||
tip2: 'Sadece İnsan Arayüz Cihazı modunda disk imajı bağlama özelliği devre dışı bırakılır',
|
||||
tip3: 'NanoKVM mod değişiminden sonra kendiliğinden yeniden başlatılacaktır',
|
||||
|
||||
@@ -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,请前往「设置 - 设备」中开启',
|
||||
@@ -71,7 +73,15 @@ const zh = {
|
||||
placeholder: '请输入内容',
|
||||
submit: '确定',
|
||||
virtual: '虚拟键盘',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
shortcut: {
|
||||
title: '快捷键',
|
||||
custom: '自定义',
|
||||
capture: '点击此处捕获快捷键',
|
||||
clear: '清空',
|
||||
save: '保存',
|
||||
captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。',
|
||||
enterFullScreen: '切换全屏模式。'
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: '鼠标',
|
||||
@@ -85,6 +95,9 @@ const zh = {
|
||||
mode: '鼠标模式',
|
||||
absolute: '绝对模式',
|
||||
relative: '相对模式',
|
||||
direction: '滚轮方向',
|
||||
scrollUp: '向上',
|
||||
scrollDown: '向下',
|
||||
speed: '滚轮速度',
|
||||
fast: '快',
|
||||
slow: '慢',
|
||||
@@ -198,10 +211,19 @@ const zh = {
|
||||
title: '外观',
|
||||
display: '显示',
|
||||
language: '语言',
|
||||
menuBar: '菜单栏',
|
||||
menuBarDesc: '是否在菜单栏中显示图标',
|
||||
webTitle: '网站标题',
|
||||
webTitleDesc: '自定义网站页面标题'
|
||||
languageDesc: '选择界面语言',
|
||||
webTitle: '网页标题',
|
||||
webTitleDesc: '自定义网站页面标题',
|
||||
menuBar: {
|
||||
title: '菜单栏',
|
||||
mode: '显示方式',
|
||||
modeDesc: '菜单栏在屏幕上的显示方式',
|
||||
modeOff: '关闭',
|
||||
modeAuto: '自动隐藏',
|
||||
modeAlways: '始终显示',
|
||||
icons: '菜单图标',
|
||||
iconsDesc: '是否在菜单栏中显示子菜单图标'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: '设备',
|
||||
@@ -221,7 +243,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 +283,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 +358,14 @@ const zh = {
|
||||
cancel: '取消',
|
||||
preview: '预览更新',
|
||||
previewDesc: '率先体验即将推出的新功能和优化',
|
||||
previewTip: '预览版更新可能包含一些不稳定因素或未完善的功能!'
|
||||
previewTip: '预览版更新可能包含一些不稳定因素或未完善的功能!',
|
||||
offline: {
|
||||
title: '离线更新',
|
||||
desc: '通过本地安装包进行更新',
|
||||
upload: '上传',
|
||||
invalidName: '文件名格式错误,请前往 GitHub 发布页下载安装包。',
|
||||
updateFailed: '更新失败,请重试'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
title: '帐号',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,5 +3,11 @@ import { atom } from 'jotai';
|
||||
// menu bar disabled items
|
||||
export const menuDisabledItemsAtom = atom<string[]>([]);
|
||||
|
||||
// track how many submenus are currently open
|
||||
export const submenuOpenCountAtom = atom(0);
|
||||
|
||||
// web title
|
||||
export const webTitleAtom = atom('');
|
||||
|
||||
// menu display mode: 'off' | 'auto' | 'always'
|
||||
export const menuDisplayModeAtom = atom<string>('auto');
|
||||
|
||||
56
web/src/lib/browser.ts
Normal file
56
web/src/lib/browser.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export interface BrowserInfo {
|
||||
os: string;
|
||||
isChrome: boolean;
|
||||
}
|
||||
|
||||
export function getOperatingSystem(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const platformSource =
|
||||
// @ts-expect-error check userAgentData.platform
|
||||
'userAgentData' in navigator ? navigator.userAgentData.platform : navigator.platform;
|
||||
|
||||
if (!platformSource) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const platform = platformSource.toLowerCase();
|
||||
|
||||
if (platform.startsWith('win')) return 'Windows';
|
||||
if (platform.startsWith('mac')) return 'macOS';
|
||||
if (platform.startsWith('linux')) return 'Linux';
|
||||
|
||||
return platformSource;
|
||||
}
|
||||
|
||||
export function isChromeBrowser(): boolean {
|
||||
if (typeof window === 'undefined' || !window.navigator) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('userAgentData' in navigator) {
|
||||
// @ts-expect-error userAgentData.brands
|
||||
return navigator.userAgentData?.brands?.some(
|
||||
(brand: any) => brand.brand === 'Google Chrome' || brand.brand === 'Chromium'
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
const vendor = navigator.vendor;
|
||||
|
||||
return (
|
||||
/Chrome|Chromium/.test(userAgent) &&
|
||||
/Google Inc/.test(vendor) &&
|
||||
!/Edg/.test(userAgent) &&
|
||||
!/OPR/.test(userAgent)
|
||||
);
|
||||
}
|
||||
|
||||
export function getBrowserInfo(): BrowserInfo {
|
||||
return {
|
||||
os: getOperatingSystem(),
|
||||
isChrome: isChromeBrowser()
|
||||
};
|
||||
}
|
||||
@@ -66,6 +66,14 @@ class Http {
|
||||
});
|
||||
}
|
||||
|
||||
public delete(url: string, data?: any): Promise<Response> {
|
||||
return this.instance.request({
|
||||
method: 'delete',
|
||||
url,
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public request(config: AxiosRequestConfig): Promise<Response> {
|
||||
return this.instance.request(config);
|
||||
}
|
||||
|
||||
65
web/src/lib/keyboard.ts
Normal file
65
web/src/lib/keyboard.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getKeycode, getModifierBit, isModifier } from './keymap';
|
||||
|
||||
const MAX_KEYS = 6;
|
||||
|
||||
export class KeyboardReport {
|
||||
private modifier: number = 0;
|
||||
private pressedKeys: Map<string, number> = new Map();
|
||||
|
||||
keyDown(code: string): Uint8Array {
|
||||
if (isModifier(code)) {
|
||||
this.modifier |= getModifierBit(code);
|
||||
} else {
|
||||
const keycode = getKeycode(code);
|
||||
if (keycode !== undefined && this.pressedKeys.size < MAX_KEYS) {
|
||||
this.pressedKeys.set(code, keycode);
|
||||
}
|
||||
}
|
||||
return this.buildReport();
|
||||
}
|
||||
|
||||
keyUp(code: string): Uint8Array {
|
||||
if (isModifier(code)) {
|
||||
this.modifier &= ~getModifierBit(code);
|
||||
} else {
|
||||
this.pressedKeys.delete(code);
|
||||
}
|
||||
return this.buildReport();
|
||||
}
|
||||
|
||||
reset(): Uint8Array {
|
||||
this.modifier = 0;
|
||||
this.pressedKeys.clear();
|
||||
return this.buildReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the 8-byte HID keyboard report
|
||||
* Byte 0: Modifier keys bitmap
|
||||
* Byte 1: Reserved (0x00)
|
||||
* Bytes 2-7: Up to 6 keycodes
|
||||
*/
|
||||
private buildReport(): Uint8Array {
|
||||
const report = new Uint8Array(8);
|
||||
report[0] = this.modifier;
|
||||
report[1] = 0x00;
|
||||
|
||||
let i = 2;
|
||||
for (const keycode of this.pressedKeys.values()) {
|
||||
if (i >= 8) break;
|
||||
report[i++] = keycode;
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
getModifier(): number {
|
||||
return this.modifier;
|
||||
}
|
||||
|
||||
getPressedKeyCount(): number {
|
||||
return this.pressedKeys.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const keyboard = new KeyboardReport();
|
||||
269
web/src/lib/keymap.ts
Normal file
269
web/src/lib/keymap.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
// Modifier key bit positions
|
||||
export const ModifierBits = {
|
||||
LeftCtrl: 1 << 0,
|
||||
LeftShift: 1 << 1,
|
||||
LeftAlt: 1 << 2,
|
||||
LeftMeta: 1 << 3,
|
||||
RightCtrl: 1 << 4,
|
||||
RightShift: 1 << 5,
|
||||
RightAlt: 1 << 6,
|
||||
RightMeta: 1 << 7
|
||||
} as const;
|
||||
|
||||
// Map event.code to HID modifier bit
|
||||
export const ModifierMap: Record<string, number> = {
|
||||
ControlLeft: ModifierBits.LeftCtrl,
|
||||
ShiftLeft: ModifierBits.LeftShift,
|
||||
AltLeft: ModifierBits.LeftAlt,
|
||||
MetaLeft: ModifierBits.LeftMeta,
|
||||
ControlRight: ModifierBits.RightCtrl,
|
||||
ShiftRight: ModifierBits.RightShift,
|
||||
AltRight: ModifierBits.RightAlt,
|
||||
MetaRight: ModifierBits.RightMeta
|
||||
};
|
||||
|
||||
// Map event.code to HID keycode
|
||||
export const KeycodeMap: Record<string, number> = {
|
||||
// Letters
|
||||
KeyA: 0x04,
|
||||
KeyB: 0x05,
|
||||
KeyC: 0x06,
|
||||
KeyD: 0x07,
|
||||
KeyE: 0x08,
|
||||
KeyF: 0x09,
|
||||
KeyG: 0x0a,
|
||||
KeyH: 0x0b,
|
||||
KeyI: 0x0c,
|
||||
KeyJ: 0x0d,
|
||||
KeyK: 0x0e,
|
||||
KeyL: 0x0f,
|
||||
KeyM: 0x10,
|
||||
KeyN: 0x11,
|
||||
KeyO: 0x12,
|
||||
KeyP: 0x13,
|
||||
KeyQ: 0x14,
|
||||
KeyR: 0x15,
|
||||
KeyS: 0x16,
|
||||
KeyT: 0x17,
|
||||
KeyU: 0x18,
|
||||
KeyV: 0x19,
|
||||
KeyW: 0x1a,
|
||||
KeyX: 0x1b,
|
||||
KeyY: 0x1c,
|
||||
KeyZ: 0x1d,
|
||||
|
||||
// Numbers
|
||||
Digit1: 0x1e,
|
||||
Digit2: 0x1f,
|
||||
Digit3: 0x20,
|
||||
Digit4: 0x21,
|
||||
Digit5: 0x22,
|
||||
Digit6: 0x23,
|
||||
Digit7: 0x24,
|
||||
Digit8: 0x25,
|
||||
Digit9: 0x26,
|
||||
Digit0: 0x27,
|
||||
|
||||
// Special keys
|
||||
Enter: 0x28,
|
||||
Escape: 0x29,
|
||||
Backspace: 0x2a,
|
||||
Tab: 0x2b,
|
||||
Space: 0x2c,
|
||||
Minus: 0x2d,
|
||||
Equal: 0x2e,
|
||||
BracketLeft: 0x2f,
|
||||
BracketRight: 0x30,
|
||||
Backslash: 0x31,
|
||||
Semicolon: 0x33,
|
||||
Quote: 0x34,
|
||||
Backquote: 0x35,
|
||||
Comma: 0x36,
|
||||
Period: 0x37,
|
||||
Slash: 0x38,
|
||||
CapsLock: 0x39,
|
||||
|
||||
// Function keys
|
||||
F1: 0x3a,
|
||||
F2: 0x3b,
|
||||
F3: 0x3c,
|
||||
F4: 0x3d,
|
||||
F5: 0x3e,
|
||||
F6: 0x3f,
|
||||
F7: 0x40,
|
||||
F8: 0x41,
|
||||
F9: 0x42,
|
||||
F10: 0x43,
|
||||
F11: 0x44,
|
||||
F12: 0x45,
|
||||
|
||||
// Control keys
|
||||
PrintScreen: 0x46,
|
||||
ScrollLock: 0x47,
|
||||
Pause: 0x48,
|
||||
Insert: 0x49,
|
||||
Home: 0x4a,
|
||||
PageUp: 0x4b,
|
||||
Delete: 0x4c,
|
||||
End: 0x4d,
|
||||
PageDown: 0x4e,
|
||||
|
||||
// Arrow keys
|
||||
ArrowRight: 0x4f,
|
||||
ArrowLeft: 0x50,
|
||||
ArrowDown: 0x51,
|
||||
ArrowUp: 0x52,
|
||||
|
||||
// Numpad
|
||||
NumLock: 0x53,
|
||||
NumpadDivide: 0x54,
|
||||
NumpadMultiply: 0x55,
|
||||
NumpadSubtract: 0x56,
|
||||
NumpadAdd: 0x57,
|
||||
NumpadEnter: 0x58,
|
||||
Numpad1: 0x59,
|
||||
Numpad2: 0x5a,
|
||||
Numpad3: 0x5b,
|
||||
Numpad4: 0x5c,
|
||||
Numpad5: 0x5d,
|
||||
Numpad6: 0x5e,
|
||||
Numpad7: 0x5f,
|
||||
Numpad8: 0x60,
|
||||
Numpad9: 0x61,
|
||||
Numpad0: 0x62,
|
||||
NumpadDecimal: 0x63,
|
||||
|
||||
// International / Non-US keyboard keys
|
||||
IntlBackslash: 0x64,
|
||||
ContextMenu: 0x65,
|
||||
Power: 0x66,
|
||||
NumpadEqual: 0x67,
|
||||
|
||||
// Extended function keys
|
||||
F13: 0x68,
|
||||
F14: 0x69,
|
||||
F15: 0x6a,
|
||||
F16: 0x6b,
|
||||
F17: 0x6c,
|
||||
F18: 0x6d,
|
||||
F19: 0x6e,
|
||||
F20: 0x6f,
|
||||
F21: 0x70,
|
||||
F22: 0x71,
|
||||
F23: 0x72,
|
||||
F24: 0x73,
|
||||
|
||||
// System / Edit keys
|
||||
Execute: 0x74,
|
||||
Help: 0x75,
|
||||
Props: 0x76,
|
||||
Select: 0x77,
|
||||
Stop: 0x78,
|
||||
Again: 0x79,
|
||||
Undo: 0x7a,
|
||||
Cut: 0x7b,
|
||||
Copy: 0x7c,
|
||||
Paste: 0x7d,
|
||||
Find: 0x7e,
|
||||
|
||||
// Media / Volume keys
|
||||
AudioVolumeMute: 0x7f,
|
||||
AudioVolumeUp: 0x80,
|
||||
AudioVolumeDown: 0x81,
|
||||
VolumeMute: 0x7f, // Alias
|
||||
VolumeUp: 0x80, // Alias
|
||||
VolumeDown: 0x81, // Alias
|
||||
|
||||
// Locking keys (for keyboards with physical lock keys)
|
||||
LockingCapsLock: 0x82,
|
||||
LockingNumLock: 0x83,
|
||||
LockingScrollLock: 0x84,
|
||||
|
||||
// Numpad additional
|
||||
NumpadComma: 0x85,
|
||||
NumpadEqual2: 0x86, // AS/400 keyboard equal key
|
||||
|
||||
// International keys - Japanese
|
||||
IntlRo: 0x87, // Japanese Ro key (ろ)
|
||||
KanaMode: 0x88, // Katakana/Hiragana toggle
|
||||
IntlYen: 0x89, // Japanese Yen (¥)
|
||||
Convert: 0x8a, // Japanese Henkan (変換)
|
||||
NonConvert: 0x8b, // Japanese Muhenkan (無変換)
|
||||
|
||||
// International keys - Additional Japanese
|
||||
International6: 0x8c,
|
||||
International7: 0x8d,
|
||||
International8: 0x8e,
|
||||
International9: 0x8f,
|
||||
|
||||
// Language keys - Korean/Japanese/Chinese
|
||||
Lang1: 0x90, // Korean Hangul/English toggle
|
||||
Lang2: 0x91, // Korean Hanja
|
||||
Lang3: 0x92, // Japanese Katakana
|
||||
Lang4: 0x93, // Japanese Hiragana
|
||||
Lang5: 0x94, // Japanese Zenkaku/Hankaku
|
||||
Lang6: 0x95,
|
||||
Lang7: 0x96,
|
||||
Lang8: 0x97,
|
||||
Lang9: 0x98,
|
||||
|
||||
// ISO keyboard specific
|
||||
IntlHash: 0x32, // Non-US # and ~ (ISO keyboards)
|
||||
|
||||
// Numpad extended
|
||||
NumpadParenLeft: 0xb6,
|
||||
NumpadParenRight: 0xb7,
|
||||
NumpadBackspace: 0xbb,
|
||||
NumpadMemoryStore: 0xd0,
|
||||
NumpadMemoryRecall: 0xd1,
|
||||
NumpadMemoryClear: 0xd2,
|
||||
NumpadMemoryAdd: 0xd3,
|
||||
NumpadMemorySubtract: 0xd4,
|
||||
NumpadClear: 0xd8,
|
||||
NumpadClearEntry: 0xd9,
|
||||
|
||||
// Additional browser/system keys
|
||||
BrowserSearch: 0xf0,
|
||||
BrowserHome: 0xf1,
|
||||
BrowserBack: 0xf2,
|
||||
BrowserForward: 0xf3,
|
||||
BrowserStop: 0xf4,
|
||||
BrowserRefresh: 0xf5,
|
||||
BrowserFavorites: 0xf6,
|
||||
|
||||
// Media keys
|
||||
MediaPlayPause: 0xe8,
|
||||
MediaStop: 0xe9,
|
||||
MediaTrackPrevious: 0xea,
|
||||
MediaTrackNext: 0xeb,
|
||||
Eject: 0xec,
|
||||
MediaSelect: 0xed,
|
||||
|
||||
// Application launch keys
|
||||
LaunchMail: 0xee,
|
||||
LaunchApp1: 0xef,
|
||||
LaunchApp2: 0xf0,
|
||||
|
||||
// Sleep/Wake keys
|
||||
Sleep: 0xf8,
|
||||
Wake: 0xf9,
|
||||
|
||||
// Accessibility keys
|
||||
MediaRewind: 0xfa,
|
||||
MediaFastForward: 0xfb
|
||||
};
|
||||
|
||||
// Check if code is a modifier key
|
||||
export function isModifier(code: string): boolean {
|
||||
return code in ModifierMap;
|
||||
}
|
||||
|
||||
// Get modifier bit for code
|
||||
export function getModifierBit(code: string): number {
|
||||
return ModifierMap[code] ?? 0;
|
||||
}
|
||||
|
||||
// Get keycode for code
|
||||
export function getKeycode(code: string): number | undefined {
|
||||
return KeycodeMap[code];
|
||||
}
|
||||
@@ -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,12 +10,14 @@ 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';
|
||||
const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password';
|
||||
const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items';
|
||||
const MENU_AUTO_HIDE_KEY = 'nano-kvm-menu-auto-hide';
|
||||
const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm';
|
||||
|
||||
type ItemWithExpiry = {
|
||||
@@ -65,6 +68,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 +146,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;
|
||||
@@ -186,6 +213,15 @@ export function getMenuDisabledItems(): string[] {
|
||||
return value ? JSON.parse(value) : [];
|
||||
}
|
||||
|
||||
export function getMenuDisplayMode(): string {
|
||||
const value = localStorage.getItem(MENU_AUTO_HIDE_KEY);
|
||||
return value || 'auto';
|
||||
}
|
||||
|
||||
export function setMenuDisplayMode(mode: string) {
|
||||
localStorage.setItem(MENU_AUTO_HIDE_KEY, mode);
|
||||
}
|
||||
|
||||
export function getPowerConfirm() {
|
||||
const enabled = localStorage.getItem(POWER_CONFIRM_KEY);
|
||||
return enabled === 'true';
|
||||
|
||||
138
web/src/lib/mouse.ts
Normal file
138
web/src/lib/mouse.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// Button bit positions
|
||||
const MouseButtons = {
|
||||
Left: 1 << 0,
|
||||
Right: 1 << 1,
|
||||
Middle: 1 << 2,
|
||||
Back: 1 << 3,
|
||||
Forward: 1 << 4
|
||||
} as const;
|
||||
|
||||
// Map browser button index to HID bit
|
||||
function getMouseButtonBit(button: number): number {
|
||||
switch (button) {
|
||||
case 0:
|
||||
return MouseButtons.Left;
|
||||
case 1:
|
||||
return MouseButtons.Middle;
|
||||
case 2:
|
||||
return MouseButtons.Right;
|
||||
case 3:
|
||||
return MouseButtons.Back;
|
||||
case 4:
|
||||
return MouseButtons.Forward;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative Mouse Report (4 bytes)
|
||||
* Used with /dev/hidg1 (relative mouse)
|
||||
*
|
||||
* Byte 0: Buttons
|
||||
* Byte 1: X movement (-127 to 127)
|
||||
* Byte 2: Y movement (-127 to 127)
|
||||
* Byte 3: Wheel (-127 to 127)
|
||||
*/
|
||||
export class MouseReportRelative {
|
||||
private buttons: number = 0;
|
||||
|
||||
buttonDown(button: number): void {
|
||||
this.buttons |= getMouseButtonBit(button);
|
||||
}
|
||||
|
||||
buttonUp(button: number): void {
|
||||
this.buttons &= ~getMouseButtonBit(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build relative mouse report
|
||||
* @param deltaX X movement (-127 to 127)
|
||||
* @param deltaY Y movement (-127 to 127)
|
||||
* @param wheel Scroll wheel (-127 to 127, negative = down)
|
||||
*/
|
||||
buildReport(deltaX: number, deltaY: number, wheel: number = 0): Uint8Array {
|
||||
const report = new Uint8Array(4);
|
||||
report[0] = this.buttons;
|
||||
report[1] = this.clamp(Math.round(deltaX), -127, 127) & 0xff;
|
||||
report[2] = this.clamp(Math.round(deltaY), -127, 127) & 0xff;
|
||||
report[3] = this.clamp(Math.round(wheel), -127, 127) & 0xff;
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build button-only report (no movement)
|
||||
*/
|
||||
buildButtonReport(): Uint8Array {
|
||||
return this.buildReport(0, 0, 0);
|
||||
}
|
||||
|
||||
reset(): Uint8Array {
|
||||
this.buttons = 0;
|
||||
return this.buildReport(0, 0, 0);
|
||||
}
|
||||
|
||||
private clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute Mouse Report (6 bytes)
|
||||
* Used with /dev/hidg2 (absolute mouse/tablet)
|
||||
*
|
||||
* Byte 0: Buttons
|
||||
* Byte 1-2: X position (0 to 32767, Little Endian)
|
||||
* Byte 3-4: Y position (0 to 32767, Little Endian)
|
||||
* Byte 5: Wheel
|
||||
*/
|
||||
export class MouseReportAbsolute {
|
||||
private buttons: number = 0;
|
||||
|
||||
buttonDown(button: number): void {
|
||||
this.buttons |= getMouseButtonBit(button);
|
||||
}
|
||||
|
||||
buttonUp(button: number): void {
|
||||
this.buttons &= ~getMouseButtonBit(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build absolute mouse report
|
||||
* @param x X position (0.0 to 1.0, normalized)
|
||||
* @param y Y position (0.0 to 1.0, normalized)
|
||||
* @param wheel Scroll wheel (-127 to 127)
|
||||
*/
|
||||
buildReport(x: number, y: number, wheel: number = 0): Uint8Array {
|
||||
const report = new Uint8Array(6);
|
||||
|
||||
report[0] = this.buttons;
|
||||
report[1] = x & 0xff;
|
||||
report[2] = (x >> 8) & 0xff;
|
||||
report[3] = y & 0xff;
|
||||
report[4] = (y >> 8) & 0xff;
|
||||
report[5] = this.clamp(Math.round(wheel), -127, 127) & 0xff;
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build button-only report (keeps last position)
|
||||
*/
|
||||
buildButtonReport(lastX: number, lastY: number): Uint8Array {
|
||||
return this.buildReport(lastX, lastY, 0);
|
||||
}
|
||||
|
||||
reset(): Uint8Array {
|
||||
this.buttons = 0;
|
||||
return this.buildReport(0, 0, 0);
|
||||
}
|
||||
|
||||
private clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instances
|
||||
export const mouseRelative = new MouseReportRelative();
|
||||
export const mouseAbsolute = new MouseReportAbsolute();
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -2,64 +2,188 @@ import { IMessageEvent, w3cwebsocket as W3cWebSocket } from 'websocket';
|
||||
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
|
||||
type Event = (message: IMessageEvent) => void;
|
||||
type MessageHandler = (message: IMessageEvent) => void;
|
||||
type SendData = number[] | ArrayBuffer | Uint8Array;
|
||||
|
||||
const eventMap: Map<string, Event> = new Map<string, Event>();
|
||||
export enum MessageEvent {
|
||||
Heartbeat = 0,
|
||||
Keyboard = 1,
|
||||
Mouse = 2
|
||||
}
|
||||
|
||||
class WsClient {
|
||||
private readonly url: string;
|
||||
private instance: W3cWebSocket;
|
||||
interface WsClientOptions {
|
||||
url?: string;
|
||||
heartbeatInterval?: number;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.url = `${getBaseUrl('ws')}/api/ws`;
|
||||
this.instance = new W3cWebSocket(this.url);
|
||||
this.setEvents();
|
||||
const DEFAULT_OPTIONS: Required<WsClientOptions> = {
|
||||
url: `${getBaseUrl('ws')}/api/ws`,
|
||||
heartbeatInterval: 10 * 1000,
|
||||
reconnectInterval: 3 * 1000,
|
||||
maxReconnectAttempts: 1
|
||||
};
|
||||
|
||||
export class WsClient {
|
||||
private readonly options: Required<WsClientOptions>;
|
||||
private instance: W3cWebSocket | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private shouldReconnect = true;
|
||||
|
||||
private readonly eventHandlers = new Map<string, Set<MessageHandler>>();
|
||||
|
||||
constructor(options: WsClientOptions = {}) {
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
public connect() {
|
||||
this.close();
|
||||
|
||||
this.instance = new W3cWebSocket(this.url);
|
||||
this.setEvents();
|
||||
public connect(): void {
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.createConnection();
|
||||
}
|
||||
|
||||
public send(data: number[]) {
|
||||
if (this.instance.readyState !== W3cWebSocket.OPEN) {
|
||||
public close(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.cleanup();
|
||||
|
||||
if (this.instance && this.instance.readyState === W3cWebSocket.OPEN) {
|
||||
this.instance.close();
|
||||
}
|
||||
|
||||
this.instance = null;
|
||||
}
|
||||
|
||||
public on(type: string, handler: MessageHandler): () => void {
|
||||
if (!this.eventHandlers.has(type)) {
|
||||
this.eventHandlers.set(type, new Set());
|
||||
}
|
||||
|
||||
this.eventHandlers.get(type)!.add(handler);
|
||||
|
||||
return () => {
|
||||
const handlers = this.eventHandlers.get(type);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
this.eventHandlers.delete(type);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public off(type: string, handler?: MessageHandler): void {
|
||||
if (handler) {
|
||||
const handlers = this.eventHandlers.get(type);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
this.eventHandlers.delete(type);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.eventHandlers.delete(type);
|
||||
}
|
||||
}
|
||||
|
||||
public send(data: SendData): boolean {
|
||||
if (!this.instance || !this.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data instanceof ArrayBuffer || (data as unknown) instanceof Uint8Array) {
|
||||
this.instance.send(data);
|
||||
} else {
|
||||
this.instance.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public get isConnected(): boolean {
|
||||
return this.instance?.readyState === W3cWebSocket.OPEN;
|
||||
}
|
||||
|
||||
private createConnection(): void {
|
||||
this.cleanup();
|
||||
|
||||
this.instance = new W3cWebSocket(this.options.url);
|
||||
this.instance.binaryType = 'arraybuffer';
|
||||
|
||||
this.instance.onopen = this.handleOpen.bind(this);
|
||||
this.instance.onclose = this.handleClose.bind(this);
|
||||
this.instance.onerror = this.handleError.bind(this);
|
||||
this.instance.onmessage = this.handleMessage.bind(this);
|
||||
}
|
||||
|
||||
private handleOpen(): void {
|
||||
this.reconnectAttempts = 0;
|
||||
this.startHeartbeat();
|
||||
}
|
||||
|
||||
private handleClose(): void {
|
||||
this.stopHeartbeat();
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private handleError(error: Error): void {
|
||||
console.error('[WebSocket] Error:', error);
|
||||
}
|
||||
|
||||
private handleMessage(message: IMessageEvent): void {
|
||||
try {
|
||||
const data = JSON.parse(message.data as string);
|
||||
const handlers = this.eventHandlers.get(data.type);
|
||||
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => handler(message));
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.send(new Uint8Array([MessageEvent.Heartbeat]));
|
||||
}, this.options.heartbeatInterval);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (!this.shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.stringify(data);
|
||||
this.instance.send(message);
|
||||
}
|
||||
|
||||
public close() {
|
||||
if (this.instance.readyState === W3cWebSocket.OPEN) {
|
||||
this.instance.close();
|
||||
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
||||
console.error('[WebSocket] Max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
console.log(`[WebSocket] Reconnecting... (attempt ${this.reconnectAttempts})`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.createConnection();
|
||||
}, this.options.reconnectInterval);
|
||||
}
|
||||
|
||||
public register(type: string, fn: (message: IMessageEvent) => void) {
|
||||
eventMap.set(type, fn);
|
||||
private cleanup(): void {
|
||||
this.stopHeartbeat();
|
||||
|
||||
this.setEvents();
|
||||
}
|
||||
|
||||
public unregister(type: string) {
|
||||
eventMap.delete(type);
|
||||
|
||||
this.setEvents();
|
||||
}
|
||||
|
||||
private setEvents() {
|
||||
this.instance.onmessage = (message) => {
|
||||
const data = JSON.parse(message.data as string);
|
||||
if (!data) return;
|
||||
|
||||
const fn = eventMap.get(data.type);
|
||||
if (!fn) return;
|
||||
|
||||
fn(message);
|
||||
};
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
import { Head } from '@/components/head.tsx';
|
||||
|
||||
import { Keyboard } from './keyboard';
|
||||
import { VirtualKeyboard } from './keyboard/virtual-keyboard';
|
||||
import { Menu } from './menu';
|
||||
import { Mouse } from './mouse';
|
||||
import { Notification } from './notification.tsx';
|
||||
import { Screen } from './screen';
|
||||
import { VirtualKeyboard } from './virtual-keyboard';
|
||||
|
||||
export const Desktop = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,22 +21,17 @@ export const Desktop = () => {
|
||||
|
||||
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
|
||||
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
|
||||
|
||||
useEffect(() => {
|
||||
client.connect();
|
||||
|
||||
const mode = getVideoMode();
|
||||
setVideoMode(mode);
|
||||
|
||||
const res = storage.getResolution() || { width: 0, height: 0 };
|
||||
setResolution(res);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
client.send([0]);
|
||||
}, 60 * 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
client.unregister('stream');
|
||||
client.close();
|
||||
};
|
||||
}, []);
|
||||
@@ -67,7 +61,7 @@ export const Desktop = () => {
|
||||
<Menu />
|
||||
<Screen />
|
||||
<Mouse />
|
||||
{isKeyboardEnable && <Keyboard />}
|
||||
<Keyboard />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,102 +1,177 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { getOperatingSystem } from '@/lib/browser.ts';
|
||||
import { KeyboardReport } from '@/lib/keyboard.ts';
|
||||
import { isModifier } from '@/lib/keymap';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
import { KeyboardCodes } from './mappings.ts';
|
||||
interface AltGrState {
|
||||
active: boolean;
|
||||
ctrlLeftTimestamp: number;
|
||||
}
|
||||
|
||||
const ALTGR_THRESHOLD_MS = 10;
|
||||
|
||||
export const Keyboard = () => {
|
||||
const pressedKeys = useRef<Set<string>>(new Set());
|
||||
const isKeyboardEnabled = useAtomValue(isKeyboardEnableAtom);
|
||||
|
||||
const keyboardRef = useRef(new KeyboardReport());
|
||||
const pressedKeys = useRef(new Set<string>());
|
||||
const altGrState = useRef<AltGrState | null>(null);
|
||||
const isComposing = useRef(false);
|
||||
|
||||
// listen keyboard events
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
window.addEventListener('blur', releaseAllKeys);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// press button
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
disableEvent(event);
|
||||
|
||||
if (!pressedKeys.current.has(event.code)) {
|
||||
pressedKeys.current.add(event.code);
|
||||
}
|
||||
|
||||
sendKeyDown(event);
|
||||
if (getOperatingSystem() === 'Windows' && !altGrState.current) {
|
||||
altGrState.current = { active: false, ctrlLeftTimestamp: 0 };
|
||||
}
|
||||
|
||||
// release button
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
disableEvent(event);
|
||||
|
||||
if (pressedKeys.current.has(event.code)) {
|
||||
pressedKeys.current.delete(event.code);
|
||||
}
|
||||
|
||||
sendKeyUp();
|
||||
}
|
||||
|
||||
function releaseAllKeys() {
|
||||
if (pressedKeys.current.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendKeyUp();
|
||||
pressedKeys.current.clear();
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
releaseAllKeys();
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
window.removeEventListener('blur', releaseAllKeys);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function sendKeyDown(event: KeyboardEvent) {
|
||||
const code = KeyboardCodes.get(event.code);
|
||||
if (!code) {
|
||||
console.log('unknown code: ', event.code);
|
||||
if (!isKeyboardEnabled) {
|
||||
releaseKeys();
|
||||
return;
|
||||
}
|
||||
|
||||
let ctrl = 0;
|
||||
if (event.ctrlKey) {
|
||||
if (pressedKeys.current.has('ControlLeft')) {
|
||||
ctrl = 1;
|
||||
} else if (pressedKeys.current.has('ControlRight')) {
|
||||
ctrl = 16;
|
||||
} else if (pressedKeys.current.has('AltRight')) {
|
||||
ctrl = 0;
|
||||
} else {
|
||||
ctrl = 1;
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
document.addEventListener('compositionstart', handleCompositionStart);
|
||||
document.addEventListener('compositionend', handleCompositionEnd);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// Key down event
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isKeyboardEnabled) return;
|
||||
|
||||
// Skip during IME composition
|
||||
if (isComposing.current || event.isComposing) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const code = event.code;
|
||||
if (pressedKeys.current.has(code)) return;
|
||||
|
||||
// When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight
|
||||
if (altGrState.current) {
|
||||
if (code === 'ControlLeft') {
|
||||
altGrState.current.ctrlLeftTimestamp = event.timeStamp;
|
||||
} else if (code === 'AltRight') {
|
||||
const timeDiff = event.timeStamp - altGrState.current.ctrlLeftTimestamp;
|
||||
if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) {
|
||||
pressedKeys.current.delete('ControlLeft');
|
||||
handleKeyEvent({ type: 'keyup', code: 'ControlLeft' });
|
||||
altGrState.current.active = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pressedKeys.current.add(code);
|
||||
handleKeyEvent({ type: 'keydown', code });
|
||||
}
|
||||
|
||||
// Key up event
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
if (!isKeyboardEnabled) return;
|
||||
|
||||
if (isComposing.current || event.isComposing) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const code = event.code;
|
||||
|
||||
// Handle AltGr state for Windows
|
||||
if (altGrState.current?.active) {
|
||||
if (code === 'ControlLeft') return;
|
||||
|
||||
if (code === 'AltRight') {
|
||||
altGrState.current.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Compatible with macOS's command key combinations
|
||||
if (code === 'MetaLeft' || code === 'MetaRight') {
|
||||
const keysToRelease: string[] = [];
|
||||
pressedKeys.current.forEach((pressedCode) => {
|
||||
if (!isModifier(pressedCode)) {
|
||||
keysToRelease.push(pressedCode);
|
||||
}
|
||||
});
|
||||
|
||||
for (const key of keysToRelease) {
|
||||
handleKeyEvent({ type: 'keyup', code: key });
|
||||
pressedKeys.current.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
pressedKeys.current.delete(code);
|
||||
handleKeyEvent({ type: 'keyup', code });
|
||||
}
|
||||
|
||||
// Composition start event
|
||||
function handleCompositionStart() {
|
||||
isComposing.current = true;
|
||||
}
|
||||
|
||||
// Composition end event
|
||||
function handleCompositionEnd() {
|
||||
isComposing.current = false;
|
||||
}
|
||||
|
||||
// Release all keys when window loses focus
|
||||
function handleBlur() {
|
||||
releaseKeys();
|
||||
}
|
||||
|
||||
// Release all keys before window closes
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
releaseKeys();
|
||||
}
|
||||
}
|
||||
|
||||
const modifiers = [
|
||||
ctrl,
|
||||
event.shiftKey ? (pressedKeys.current.has('ShiftRight') ? 32 : 2) : 0,
|
||||
event.altKey ? (pressedKeys.current.has('AltRight') ? 64 : 4) : 0,
|
||||
event.metaKey ? (pressedKeys.current.has('MetaRight') ? 128 : 8) : 0
|
||||
];
|
||||
// Release all keys
|
||||
function releaseKeys() {
|
||||
pressedKeys.current.forEach((code) => {
|
||||
handleKeyEvent({ type: 'keyup', code });
|
||||
});
|
||||
|
||||
client.send([1, code, ...modifiers]);
|
||||
pressedKeys.current.clear();
|
||||
|
||||
// Reset AltGr state
|
||||
if (altGrState.current) {
|
||||
altGrState.current.active = false;
|
||||
altGrState.current.ctrlLeftTimestamp = 0;
|
||||
}
|
||||
|
||||
const report = keyboardRef.current.reset();
|
||||
sendReport(report);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
document.removeEventListener('compositionstart', handleCompositionStart);
|
||||
document.removeEventListener('compositionend', handleCompositionEnd);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
releaseKeys();
|
||||
};
|
||||
}, [isKeyboardEnabled]);
|
||||
|
||||
// Keyboard handler
|
||||
function handleKeyEvent(event: { type: 'keydown' | 'keyup'; code: string }) {
|
||||
const kb = keyboardRef.current;
|
||||
const report = event.type === 'keydown' ? kb.keyDown(event.code) : kb.keyUp(event.code);
|
||||
sendReport(report);
|
||||
}
|
||||
|
||||
function sendKeyUp() {
|
||||
client.send([1, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
// disable the default keyboard events
|
||||
function disableEvent(event: KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Send keyboard report
|
||||
function sendReport(report: Uint8Array) {
|
||||
const data = new Uint8Array([MessageEvent.Keyboard, ...report]);
|
||||
client.send(data);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
export const KeyboardCodes: Map<string, number> = new Map([
|
||||
['KeyA', 4],
|
||||
['KeyB', 5],
|
||||
['KeyC', 6],
|
||||
['KeyD', 7],
|
||||
['KeyE', 8],
|
||||
['KeyF', 9],
|
||||
['KeyG', 10],
|
||||
['KeyH', 11],
|
||||
['KeyI', 12],
|
||||
['KeyJ', 13],
|
||||
['KeyK', 14],
|
||||
['KeyL', 15],
|
||||
['KeyM', 16],
|
||||
['KeyN', 17],
|
||||
['KeyO', 18],
|
||||
['KeyP', 19],
|
||||
['KeyQ', 20],
|
||||
['KeyR', 21],
|
||||
['KeyS', 22],
|
||||
['KeyT', 23],
|
||||
['KeyU', 24],
|
||||
['KeyV', 25],
|
||||
['KeyW', 26],
|
||||
['KeyX', 27],
|
||||
['KeyY', 28],
|
||||
['KeyZ', 29],
|
||||
|
||||
['RusA', 4],
|
||||
['RusB', 5],
|
||||
['RusC', 6],
|
||||
['RusD', 7],
|
||||
['RusE', 8],
|
||||
['RusF', 9],
|
||||
['RusG', 10],
|
||||
['RusH', 11],
|
||||
['RusI', 12],
|
||||
['RusJ', 13],
|
||||
['RusK', 14],
|
||||
['RusL', 15],
|
||||
['RusM', 16],
|
||||
['RusN', 17],
|
||||
['RusO', 18],
|
||||
['RusP', 19],
|
||||
['RusQ', 20],
|
||||
['RusR', 21],
|
||||
['RusS', 22],
|
||||
['RusT', 23],
|
||||
['RusU', 24],
|
||||
['RusV', 25],
|
||||
['RusW', 26],
|
||||
['RusX', 27],
|
||||
['RusY', 28],
|
||||
['RusZ', 29],
|
||||
|
||||
['RusBracketLeft', 47],
|
||||
['RusBracketRight', 48],
|
||||
['RusBackslash', 49],
|
||||
['RusSemicolon', 51],
|
||||
['RusQuote', 52],
|
||||
['RusComma', 54],
|
||||
['RusPeriod', 55],
|
||||
['RusSlash', 56],
|
||||
|
||||
['Digit1', 30],
|
||||
['Digit2', 31],
|
||||
['Digit3', 32],
|
||||
['Digit4', 33],
|
||||
['Digit5', 34],
|
||||
['Digit6', 35],
|
||||
['Digit7', 36],
|
||||
['Digit8', 37],
|
||||
['Digit9', 38],
|
||||
['Digit0', 39],
|
||||
|
||||
['Enter', 40],
|
||||
['Escape', 41],
|
||||
['Backspace', 42],
|
||||
['Tab', 43],
|
||||
['Space', 44],
|
||||
['Minus', 45],
|
||||
['Equal', 46],
|
||||
['BracketLeft', 47],
|
||||
['BracketRight', 48],
|
||||
['Backslash', 49],
|
||||
['IntlBackslash', 49],
|
||||
['Backquote_azerty', 100],
|
||||
['IntlBackslash_qwertz', 100],
|
||||
|
||||
['Semicolon', 51],
|
||||
['Quote', 52],
|
||||
['Backquote', 53],
|
||||
['KeyTilde', 53],
|
||||
['Comma', 54],
|
||||
['Period', 55],
|
||||
['KeyDot', 55],
|
||||
['Slash', 56],
|
||||
['CapsLock', 57],
|
||||
|
||||
['F1', 58],
|
||||
['F2', 59],
|
||||
['F3', 60],
|
||||
['F4', 61],
|
||||
['F5', 62],
|
||||
['F6', 63],
|
||||
['F7', 64],
|
||||
['F8', 65],
|
||||
['F9', 66],
|
||||
['F10', 67],
|
||||
['F11', 68],
|
||||
['F12', 69],
|
||||
['F13', 70],
|
||||
|
||||
['PrintScreen', 70],
|
||||
['ScrollLock', 71],
|
||||
['Pause', 72],
|
||||
['Insert', 73],
|
||||
['Home', 74],
|
||||
['PageUp', 75],
|
||||
['Delete', 76],
|
||||
['End', 77],
|
||||
['PageDown', 78],
|
||||
['ArrowRight', 79],
|
||||
['ArrowLeft', 80],
|
||||
['ArrowDown', 81],
|
||||
['ArrowUp', 82],
|
||||
|
||||
['NumLock', 83],
|
||||
['NumpadDivide', 84],
|
||||
['NumpadMultiply', 85],
|
||||
['NumpadSubtract', 86],
|
||||
['NumpadAdd', 87],
|
||||
['NumpadEnter', 88],
|
||||
['Numpad1', 89],
|
||||
['Numpad2', 90],
|
||||
['Numpad3', 91],
|
||||
['Numpad4', 92],
|
||||
['Numpad5', 93],
|
||||
['Numpad6', 94],
|
||||
['Numpad7', 95],
|
||||
['Numpad8', 96],
|
||||
['Numpad9', 97],
|
||||
['Numpad0', 98],
|
||||
['NumpadDecimal', 99],
|
||||
['KeyKpDot', 99],
|
||||
|
||||
['Menu', 118],
|
||||
|
||||
['ControlLeft', 224],
|
||||
['ShiftLeft', 225],
|
||||
['AltLeft', 226],
|
||||
['MetaLeft', 227],
|
||||
['ControlRight', 228],
|
||||
['ShiftRight', 229],
|
||||
['AltRight', 230],
|
||||
['MetaRight', 231]
|
||||
]);
|
||||
|
||||
export const ModifierCodes: Map<string, number> = new Map([
|
||||
['ControlLeft', 1],
|
||||
['ShiftLeft', 2],
|
||||
['AltLeft', 4],
|
||||
['MetaLeft', 8],
|
||||
['ControlRight', 16],
|
||||
['ShiftRight', 32],
|
||||
['AltRight', 64],
|
||||
['MetaRight', 128]
|
||||
]);
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Divider, Modal, Segmented } from 'antd';
|
||||
import { Divider, Modal, Segmented, Tooltip } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { DiscIcon, HardDriveIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/storage.ts';
|
||||
import { submenuOpenCountAtom } from '@/jotai/settings.ts';
|
||||
|
||||
import { Images } from './images.tsx';
|
||||
import { Tips } from './tips.tsx';
|
||||
|
||||
export const Image = () => {
|
||||
const { t } = useTranslation();
|
||||
const setSubmenuOpenCount = useSetAtom(submenuOpenCountAtom);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
@@ -51,19 +54,26 @@ export const Image = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
function toggleModal(open: boolean) {
|
||||
setIsModalOpen(open);
|
||||
setSubmenuOpenCount((count) => (open ? count + 1 : Math.max(0, count - 1)));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700',
|
||||
isMounted ? 'text-blue-500' : 'text-neutral-300 hover:text-white'
|
||||
)}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<DiscIcon size={18} />
|
||||
</div>
|
||||
<Tooltip title={t('image.title')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded hover:bg-neutral-700',
|
||||
isMounted ? 'text-blue-500' : 'text-neutral-300 hover:text-white'
|
||||
)}
|
||||
onClick={() => toggleModal(true)}
|
||||
>
|
||||
<DiscIcon size={18} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Modal open={isModalOpen} footer={null} onCancel={() => setIsModalOpen(false)}>
|
||||
<Modal open={isModalOpen} footer={null} onCancel={() => toggleModal(false)}>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-xl font-bold">{t('image.title')}</span>
|
||||
<Tips />
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Divider, Tooltip } from 'antd';
|
||||
import { useRef } from 'react';
|
||||
import { Divider } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { MenuIcon, XIcon } from 'lucide-react';
|
||||
import Draggable from 'react-draggable';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { GripVerticalIcon } from 'lucide-react';
|
||||
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
|
||||
|
||||
import { getMenuDisabledItems } from '@/lib/localstorage.ts';
|
||||
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
|
||||
import { menuDisabledItemsAtom, menuDisplayModeAtom } from '@/jotai/settings.ts';
|
||||
import { useMenuBounds } from '@/hooks/useMenuBounds.ts';
|
||||
import { useMenuVisibility } from '@/hooks/useMenuVisibility.ts';
|
||||
|
||||
import { DownloadImage } from './download.tsx';
|
||||
import { Fullscreen } from './fullscreen';
|
||||
import { Image } from './image';
|
||||
import { Keyboard } from './keyboard';
|
||||
import { Mouse } from './mouse';
|
||||
import { Collapse, Expand } from './operations';
|
||||
import { Power } from './power';
|
||||
import { Screen } from './screen';
|
||||
import { Script } from './script';
|
||||
@@ -22,79 +23,88 @@ import { Terminal } from './terminal';
|
||||
import { Wol } from './wol';
|
||||
|
||||
export const Menu = () => {
|
||||
const { t } = useTranslation();
|
||||
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
|
||||
const nodeRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(true);
|
||||
const [bounds, setBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
|
||||
const menuDisplayMode = useAtomValue(menuDisplayModeAtom);
|
||||
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
|
||||
|
||||
const nodeRef = useRef<any>(null);
|
||||
const {
|
||||
isInitialized,
|
||||
isMenuExpanded,
|
||||
isMenuHidden,
|
||||
isInvisible,
|
||||
handleHovered,
|
||||
handleMoved,
|
||||
setIsMenuExpanded
|
||||
} = useMenuVisibility();
|
||||
|
||||
useEffect(() => {
|
||||
// disabled menu items
|
||||
const items = getMenuDisabledItems();
|
||||
setMenuDisabledItems(items);
|
||||
const menuBounds = useMenuBounds(nodeRef, isMenuExpanded);
|
||||
|
||||
// react-draggable bounds
|
||||
const handleResize = () => {
|
||||
if (!nodeRef.current) return;
|
||||
function onDragStop(_e: DraggableEvent, data: DraggableData) {
|
||||
if (data.x === 0 && data.y === 0) return;
|
||||
handleMoved();
|
||||
}
|
||||
|
||||
const elementRect = nodeRef.current.getBoundingClientRect();
|
||||
const width = (window.innerWidth - elementRect.width) / 2;
|
||||
|
||||
setBounds({
|
||||
left: -width,
|
||||
top: -10,
|
||||
right: width,
|
||||
bottom: window.innerHeight - elementRect.height - 10
|
||||
});
|
||||
};
|
||||
|
||||
handleResize();
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
function isEnabled(item: string) {
|
||||
return !menuDisabledItems.includes(item);
|
||||
}
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
nodeRef={nodeRef}
|
||||
bounds={bounds}
|
||||
bounds={menuBounds}
|
||||
handle="strong"
|
||||
positionOffset={{ x: '-50%', y: '0%' }}
|
||||
onStop={onDragStop}
|
||||
>
|
||||
<div ref={nodeRef} className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2">
|
||||
<div
|
||||
ref={nodeRef}
|
||||
className={clsx(
|
||||
'fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2 transition-opacity duration-300',
|
||||
isInitialized ? 'opacity-100' : 'opacity-0',
|
||||
isInvisible && 'invisible'
|
||||
)}
|
||||
onMouseEnter={() => handleHovered(true)}
|
||||
onMouseLeave={() => handleHovered(false)}
|
||||
onBlur={() => handleHovered(false)}
|
||||
>
|
||||
{/* Trigger area for auto-show when hidden */}
|
||||
{isMenuExpanded && (
|
||||
<div className="absolute -top-[10px] left-0 right-0 h-[46px] w-full bg-transparent" />
|
||||
)}
|
||||
|
||||
{/* Menubar */}
|
||||
<div className="sticky top-[10px] flex w-full justify-center">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-[36px] items-center rounded bg-neutral-800/80',
|
||||
isMenuOpen ? 'flex' : 'hidden'
|
||||
'h-[36px] items-center rounded bg-neutral-800/80 pl-1 pr-2 transition-all duration-300',
|
||||
isMenuExpanded ? 'flex' : 'hidden',
|
||||
isMenuHidden ? '-translate-y-[110%] opacity-80' : 'translate-y-0 opacity-100'
|
||||
)}
|
||||
>
|
||||
<strong>
|
||||
<div className="hidden h-[30px] cursor-move select-none items-center px-3 sm:flex">
|
||||
<img src="/sipeed.ico" width={18} height={18} draggable={false} alt="sipeed" />
|
||||
<div className="flex h-[30px] cursor-move select-none items-center justify-center pl-1 text-neutral-500">
|
||||
<GripVerticalIcon size={18} />
|
||||
</div>
|
||||
</strong>
|
||||
<Divider type="vertical" />
|
||||
|
||||
<Screen />
|
||||
<Keyboard />
|
||||
<Mouse />
|
||||
<Divider type="vertical" />
|
||||
|
||||
{!menuDisabledItems.includes('image') && <Image />}
|
||||
{!menuDisabledItems.includes('download') && <DownloadImage />}
|
||||
{!menuDisabledItems.includes('script') && <Script />}
|
||||
{!menuDisabledItems.includes('terminal') && <Terminal />}
|
||||
{!menuDisabledItems.includes('wol') && <Wol />}
|
||||
{isEnabled('image') && <Image />}
|
||||
{isEnabled('download') && <DownloadImage />}
|
||||
{isEnabled('script') && <Script />}
|
||||
{isEnabled('terminal') && <Terminal />}
|
||||
{isEnabled('wol') && <Wol />}
|
||||
|
||||
{['image', 'script', 'terminal', 'wol', 'download'].some(
|
||||
(key) => !menuDisabledItems.includes(key)
|
||||
) && <Divider type="vertical" />}
|
||||
{['image', 'download', 'script', 'terminal', 'wol'].some(isEnabled) && (
|
||||
<Divider type="vertical" />
|
||||
)}
|
||||
|
||||
{!menuDisabledItems.includes('power') && (
|
||||
{isEnabled('power') && (
|
||||
<>
|
||||
<Power />
|
||||
<Divider type="vertical" />
|
||||
@@ -102,29 +112,13 @@ export const Menu = () => {
|
||||
)}
|
||||
|
||||
<Settings />
|
||||
<Fullscreen />
|
||||
|
||||
<Tooltip title={t('menu.collapse')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className="mr-1 flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/80 hover:text-white"
|
||||
onClick={() => setIsMenuOpen((o) => !o)}
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
{isEnabled('fullscreen') && <Fullscreen />}
|
||||
{isEnabled('collapse') && <Collapse toggleMenu={setIsMenuExpanded} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMenuOpen && (
|
||||
<Tooltip title={t('menu.expand')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className="flex h-[30px] w-[32px] cursor-pointer items-center justify-center rounded bg-neutral-800/50 text-white/50 hover:bg-neutral-700 hover:text-white"
|
||||
onClick={() => setIsMenuOpen((o) => !o)}
|
||||
>
|
||||
<MenuIcon size={20} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Menubar expand button */}
|
||||
{!isMenuExpanded && menuDisplayMode !== 'off' && <Expand toggleMenu={setIsMenuExpanded} />}
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OctagonMinus } from 'lucide-react';
|
||||
|
||||
import { KeyboardCodes, ModifierCodes } from '@/pages/desktop/keyboard/mappings.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
|
||||
export const CtrlAltDel = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function sendCtrlAltDel() {
|
||||
const ctrl = ModifierCodes.get('ControlLeft')!;
|
||||
const alt = ModifierCodes.get('AltLeft')!;
|
||||
const del = KeyboardCodes.get('Delete')!;
|
||||
|
||||
client.send([1, del, ctrl, 0, alt, 0]);
|
||||
client.send([1, 0, 0, 0, 0, 0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70"
|
||||
)}
|
||||
onClick={sendCtrlAltDel}
|
||||
>
|
||||
<OctagonMinus size={18} />
|
||||
<span>{t('keyboard.ctrlaltdel')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { CtrlAltDel } from './ctrl-alt-del.tsx';
|
||||
import { Paste } from './paste.tsx';
|
||||
import { Shortcuts } from './shortcuts';
|
||||
import { VirtualKeyboard } from './virtual-keyboard.tsx';
|
||||
|
||||
export const Keyboard = () => {
|
||||
@@ -15,11 +15,11 @@ export const Keyboard = () => {
|
||||
title={t('keyboard.title')}
|
||||
icon={<KeyboardIcon size={18} />}
|
||||
content={
|
||||
<>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Paste />
|
||||
<VirtualKeyboard />
|
||||
<CtrlAltDel />
|
||||
</>
|
||||
<Shortcuts />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
140
web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx
Normal file
140
web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Divider, Popover } from 'antd';
|
||||
import { CommandIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/hid.ts';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
import { Recorder } from './recorder.tsx';
|
||||
import { Shortcut } from './shortcut.tsx';
|
||||
import type { Shortcut as ShortcutInterface } from './types.ts';
|
||||
|
||||
export const Shortcuts = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [customShortcuts, setCustomShortcuts] = useState<ShortcutInterface[]>([]);
|
||||
|
||||
const defaultShortcuts: ShortcutInterface[] = [
|
||||
{
|
||||
keys: [
|
||||
{ code: 'MetaLeft', label: 'Win' },
|
||||
{ code: 'Tab', label: 'Tab' }
|
||||
]
|
||||
},
|
||||
{
|
||||
keys: [
|
||||
{ code: 'ControlLeft', label: 'Ctrl' },
|
||||
{ code: 'AltLeft', label: 'Alt' },
|
||||
{ code: 'Delete', label: '⌫' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
getShortcuts();
|
||||
}, [isOpen]);
|
||||
|
||||
async function getShortcuts() {
|
||||
try {
|
||||
const rsp = await api.getShortcuts();
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomShortcuts(rsp.data.shortcuts);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function addShortcut(shortcut: ShortcutInterface) {
|
||||
try {
|
||||
const rsp = await api.addShortcut(shortcut.keys);
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
await getShortcuts();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function delShortcut(shortcut: ShortcutInterface) {
|
||||
try {
|
||||
if (!shortcut.id) return;
|
||||
|
||||
const rsp = await api.deleteShortcut(shortcut.id);
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
await getShortcuts();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
if (isRecording) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<ScrollArea className="max-w-[400px] [&>[data-radix-scroll-area-viewport]]:max-h-[350px]">
|
||||
{/* custom shortcuts */}
|
||||
{customShortcuts.length > 0 && (
|
||||
<>
|
||||
{customShortcuts.map((shortcut) => (
|
||||
<Shortcut key={shortcut.id} shortcut={shortcut}></Shortcut>
|
||||
))}
|
||||
|
||||
<Divider style={{ margin: '5px 0 5px 0' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* default shortcuts */}
|
||||
{defaultShortcuts.map((shortcut, index) => (
|
||||
<Shortcut key={index} shortcut={shortcut}></Shortcut>
|
||||
))}
|
||||
|
||||
<Divider style={{ margin: '5px 0 5px 0' }} />
|
||||
|
||||
<Recorder
|
||||
shortcuts={customShortcuts}
|
||||
addShortcut={addShortcut}
|
||||
delShortcut={delShortcut}
|
||||
setIsRecording={setIsRecording}
|
||||
/>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
trigger="hover"
|
||||
placement="rightTop"
|
||||
align={{ offset: [14, 0] }}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
arrow={false}
|
||||
>
|
||||
<div className="flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70">
|
||||
<CommandIcon size={18} />
|
||||
<span>{t('keyboard.shortcut.title')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
262
web/src/pages/desktop/menu/keyboard/shortcuts/recorder.tsx
Normal file
262
web/src/pages/desktop/menu/keyboard/shortcuts/recorder.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Divider, Input, InputRef, Modal } from 'antd';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { KeyboardIcon, Trash2Icon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isModifier } from '@/lib/keymap.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd.tsx';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx';
|
||||
|
||||
import { KeyInfo, Shortcut } from './types.ts';
|
||||
|
||||
const MAX_KEYS = 6;
|
||||
|
||||
const SpecialKeyMap: Record<string, string> = {
|
||||
Space: 'Space',
|
||||
Backspace: '⌫',
|
||||
Enter: '↵',
|
||||
Tab: 'Tab',
|
||||
CapsLock: 'Caps',
|
||||
Escape: 'Esc',
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
Delete: 'Del',
|
||||
Insert: 'Ins',
|
||||
Home: 'Home',
|
||||
End: 'End',
|
||||
PageUp: 'PgUp',
|
||||
PageDown: 'PgDn'
|
||||
};
|
||||
|
||||
const PunctuationMap: Record<string, string> = {
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Backslash: '\\',
|
||||
Semicolon: ';',
|
||||
Quote: "'",
|
||||
Backquote: '`',
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Slash: '/'
|
||||
};
|
||||
|
||||
interface RecorderProps {
|
||||
shortcuts: Shortcut[];
|
||||
addShortcut: (shortcut: Shortcut) => void;
|
||||
delShortcut: (shortcut: Shortcut) => void;
|
||||
setIsRecording: (isRecording: boolean) => void;
|
||||
}
|
||||
|
||||
export const Recorder = ({
|
||||
shortcuts,
|
||||
addShortcut,
|
||||
delShortcut,
|
||||
setIsRecording
|
||||
}: RecorderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [shortcutLabel, setShortcutLabel] = useState('');
|
||||
|
||||
const inputRef = useRef<InputRef | null>(null);
|
||||
const recordedKeysRef = useRef<KeyInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsKeyboardEnable(!isModalOpen);
|
||||
setIsRecording(isModalOpen);
|
||||
|
||||
if (!isModalOpen) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { key, code } = event;
|
||||
|
||||
const isRecorded = recordedKeysRef.current.some((k) => k.code === code);
|
||||
if (isRecorded) return;
|
||||
|
||||
if (recordedKeysRef.current.length >= MAX_KEYS) return;
|
||||
|
||||
const keyInfo: KeyInfo = {
|
||||
code: code,
|
||||
label: formatKeyDisplay(key, code)
|
||||
};
|
||||
|
||||
setShortcutLabel((prev) => (prev ? `${prev} + ${keyInfo.label}` : keyInfo.label));
|
||||
recordedKeysRef.current.push(keyInfo);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isFocused]);
|
||||
|
||||
const formatKeyDisplay = (key: string, code: string): string => {
|
||||
if (isModifier(code)) {
|
||||
if (code.startsWith('Control')) {
|
||||
return 'Ctrl';
|
||||
}
|
||||
if (code.startsWith('Shift')) {
|
||||
return 'Shift';
|
||||
}
|
||||
if (code.startsWith('Alt')) {
|
||||
return 'Alt';
|
||||
}
|
||||
if (code.startsWith('Meta')) {
|
||||
return 'Win';
|
||||
}
|
||||
}
|
||||
|
||||
if (code.startsWith('Digit')) {
|
||||
return code.replace('Digit', '');
|
||||
}
|
||||
if (code.startsWith('Key')) {
|
||||
return code.replace('Key', '');
|
||||
}
|
||||
if (code.startsWith('Numpad')) {
|
||||
const numpadKey = code.replace('Numpad', '');
|
||||
return `Num${numpadKey}`;
|
||||
}
|
||||
if (code.startsWith('F') && /^F\d+$/.test(code)) {
|
||||
return code; // F1, F2, etc.
|
||||
}
|
||||
|
||||
if (SpecialKeyMap[code]) {
|
||||
return SpecialKeyMap[code];
|
||||
}
|
||||
if (PunctuationMap[code]) {
|
||||
return PunctuationMap[code];
|
||||
}
|
||||
|
||||
return key.toUpperCase();
|
||||
};
|
||||
|
||||
function saveShortcut() {
|
||||
if (recordedKeysRef.current.length > 0) {
|
||||
addShortcut({ keys: recordedKeysRef.current });
|
||||
}
|
||||
|
||||
clearShortcut();
|
||||
}
|
||||
|
||||
function clearShortcut() {
|
||||
setShortcutLabel('');
|
||||
recordedKeysRef.current = [];
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
clearShortcut();
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.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();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/60"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<span>{t('keyboard.shortcut.custom')}</span>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
width={500}
|
||||
title={t('keyboard.shortcut.title')}
|
||||
keyboard={false}
|
||||
footer={null}
|
||||
open={isModalOpen}
|
||||
onCancel={closeModal}
|
||||
>
|
||||
<div>
|
||||
<span className="text-neutral-500">{t('keyboard.shortcut.captureTips')}</span>
|
||||
<a className="px-1 text-blue-500/80" onClick={toggleFullscreen}>
|
||||
{t('keyboard.shortcut.enterFullScreen')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-0.5 py-6">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t('keyboard.shortcut.capture')}
|
||||
prefix={<KeyboardIcon size={16} className="text-neutral-500" />}
|
||||
value={shortcutLabel}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
|
||||
<Button onClick={clearShortcut}>{t('keyboard.shortcut.clear')}</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-center pb-3">
|
||||
<Button type="primary" className="min-w-24" onClick={saveShortcut}>
|
||||
{t('keyboard.shortcut.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{shortcuts.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<ScrollArea className="[&>[data-radix-scroll-area-viewport]]:max-h-[300px]">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.id}
|
||||
className="flex items-center justify-between rounded p-2 hover:bg-neutral-700/50"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
{shortcut.keys.map((key, index) => (
|
||||
<KbdGroup key={index}>
|
||||
<Kbd>{key.label}</Kbd>
|
||||
</KbdGroup>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex size-[20px] cursor-pointer items-center justify-center rounded-sm text-neutral-500 hover:text-red-500"
|
||||
onClick={() => delShortcut(shortcut)}
|
||||
>
|
||||
<Trash2Icon size={16} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
58
web/src/pages/desktop/menu/keyboard/shortcuts/shortcut.tsx
Normal file
58
web/src/pages/desktop/menu/keyboard/shortcuts/shortcut.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { KeyboardReport } from '@/lib/keyboard.ts';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd.tsx';
|
||||
|
||||
import type { Shortcut as ShortcutInterface } from './types.ts';
|
||||
|
||||
type ShortcutProps = {
|
||||
shortcut: ShortcutInterface;
|
||||
};
|
||||
|
||||
export const Shortcut = ({ shortcut }: ShortcutProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
async function sendShortcut() {
|
||||
const keyboard = new KeyboardReport();
|
||||
|
||||
shortcut.keys.forEach((key) => {
|
||||
const report = keyboard.keyDown(key.code);
|
||||
send(report);
|
||||
});
|
||||
|
||||
const report = keyboard.reset();
|
||||
send(report);
|
||||
}
|
||||
|
||||
function send(report: Uint8Array) {
|
||||
const data = new Uint8Array([MessageEvent.Keyboard, ...report]);
|
||||
client.send(data);
|
||||
}
|
||||
|
||||
async function handleClick(): Promise<void> {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await sendShortcut();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[32px] w-full cursor-pointer items-center space-x-1 rounded px-3 hover:bg-neutral-700/30"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{shortcut.keys.map((key, index) => (
|
||||
<KbdGroup key={index}>
|
||||
<Kbd>{key.label}</Kbd>
|
||||
</KbdGroup>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
web/src/pages/desktop/menu/keyboard/shortcuts/types.ts
Normal file
9
web/src/pages/desktop/menu/keyboard/shortcuts/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface KeyInfo {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface Shortcut {
|
||||
id?: string;
|
||||
keys: KeyInfo[];
|
||||
}
|
||||
51
web/src/pages/desktop/menu/mouse/direction.tsx
Normal file
51
web/src/pages/desktop/menu/mouse/direction.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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('');
|
||||
|
||||
@@ -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' }} />
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RefreshCwIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/hid.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
|
||||
export const ResetHid = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -15,7 +15,10 @@ export const ResetHid = () => {
|
||||
if (isResetting) return;
|
||||
setIsResetting(true);
|
||||
|
||||
client.send([1, 0, 0, 0, 0, 0]);
|
||||
// Release keyboard keys
|
||||
const data = new Uint8Array([MessageEvent.Keyboard, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
client.send(data);
|
||||
|
||||
client.close();
|
||||
|
||||
api.reset().finally(() => {
|
||||
|
||||
@@ -39,7 +39,7 @@ export const Speed = () => {
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className="h-[150px] w-[60px] py-3">
|
||||
<div className="h-[150px] min-w-[60px] py-3">
|
||||
<Slider
|
||||
vertical
|
||||
marks={{
|
||||
|
||||
22
web/src/pages/desktop/menu/operations/collapse.tsx
Normal file
22
web/src/pages/desktop/menu/operations/collapse.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CollapseProps {
|
||||
toggleMenu(expanded: boolean): void;
|
||||
}
|
||||
|
||||
export const Collapse = ({ toggleMenu }: CollapseProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip title={t('menu.collapse')} placement="bottom" mouseEnterDelay={0.6}>
|
||||
<div
|
||||
className="flex size-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/80 hover:text-white"
|
||||
onClick={() => toggleMenu(false)}
|
||||
>
|
||||
<XIcon size={18} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user