mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 18:09:19 -06:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ca5b19efe | ||
|
|
40f3917014 | ||
|
|
a40433522c | ||
|
|
8470b89214 | ||
|
|
5a08170518 | ||
|
|
36cf9c30d8 | ||
|
|
1c43542919 | ||
|
|
4080788cab | ||
|
|
424302789e | ||
|
|
6651167dd8 | ||
|
|
2d67720022 | ||
|
|
1893912ba7 | ||
|
|
e41ce2d6b8 | ||
|
|
a5c6489f19 | ||
|
|
fafef3d89a | ||
|
|
4b5f539445 | ||
|
|
cefe0f9ab2 | ||
|
|
7f3d29eba2 | ||
|
|
64681e2aac | ||
|
|
fcb1f1c7fe | ||
|
|
aa0546ff37 | ||
|
|
3254412017 | ||
|
|
fda3c6b1a8 | ||
|
|
97832557fb | ||
|
|
590db5b08c | ||
|
|
10a512ec96 | ||
|
|
08093dddad | ||
|
|
86a85a61b7 | ||
|
|
cb3a2c562e | ||
|
|
832832a716 | ||
|
|
5fe6d0d48f | ||
|
|
67fe57f189 | ||
|
|
44caee21ab | ||
|
|
5b2a1ff788 | ||
|
|
dc5ba29bcf | ||
|
|
89c14310c7 | ||
|
|
3322a3c07f | ||
|
|
9e1251ad6a | ||
|
|
afec4efb34 | ||
|
|
11980b9122 | ||
|
|
2a43fb5413 | ||
|
|
57887f3f69 | ||
|
|
2b8f640957 | ||
|
|
4af32fc703 | ||
|
|
a611e5ef56 | ||
|
|
94051c3037 | ||
|
|
d5cd706078 | ||
|
|
7460ebfd07 | ||
|
|
e3877308a0 | ||
|
|
7c28fb8124 | ||
|
|
b18873364f | ||
|
|
016a7d8a51 | ||
|
|
585f1bc2b5 | ||
|
|
4fcc4c3747 | ||
|
|
0d2c52adc3 | ||
|
|
af256e7f92 | ||
|
|
84830421ee | ||
|
|
9b278cdb0e | ||
|
|
9b40a5cf86 | ||
|
|
b8d980be33 | ||
|
|
195037af87 | ||
|
|
c98000069a | ||
|
|
2bd277b57d | ||
|
|
4852ed813b | ||
|
|
fb08df9e4e | ||
|
|
64f3b49e21 |
8
.github/workflows/issue-tracker.yml
vendored
8
.github/workflows/issue-tracker.yml
vendored
@@ -2,7 +2,7 @@ name: Issue Tracker
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
types: [opened, reopened, closed]
|
||||
|
||||
jobs:
|
||||
track-issue:
|
||||
@@ -28,8 +28,10 @@ jobs:
|
||||
--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" \
|
||||
'{
|
||||
@@ -42,11 +44,13 @@ jobs:
|
||||
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" \
|
||||
|
||||
111
CHANGELOG.md
111
CHANGELOG.md
@@ -1,4 +1,113 @@
|
||||
## 2.3.2 (2025-01-08)
|
||||
## 2.4.1 (2026-05-08)
|
||||
|
||||
### Features
|
||||
|
||||
* Added DNS management to Network settings, including DHCP/manual modes, effective DNS display, network details, IPv4/IPv6 server validation, and persistent udhcpc hook support
|
||||
* Added a configurable server `host` option for binding NanoKVM to a specific listen address (thanks to [@allmazz](https://github.com/allmazz))
|
||||
* Added French keyboard layout support and language options (thanks to [@ilyesAj](https://github.com/ilyesAj))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Hardened HID recovery and cleanup by adding non-blocking HID writes, bounded reopen retries, stale event draining, and release reports after write failures
|
||||
* Fixed the `kvm_vision` project name and build output message in the SG2002 build script (thanks to [@Voranto](https://github.com/Voranto))
|
||||
|
||||
### UI Improvements
|
||||
|
||||
* Moved TLS and Wi-Fi settings into the Network settings section
|
||||
* Optimized the Settings scrollbar style
|
||||
|
||||
### Localization
|
||||
|
||||
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
|
||||
* Synchronized translations of other languages according to English
|
||||
|
||||
### Security
|
||||
|
||||
* Upgraded vulnerable web dependencies
|
||||
|
||||
## 2.4.0 (2026-04-10)
|
||||
|
||||
### Features
|
||||
|
||||
* **Introduced [PicoClaw](https://github.com/sipeed/picoclaw) support** — an AI-powered remote desktop assistant for NanoKVM. PicoClaw seamlessly integrates a lightweight AI agent with NanoKVM's underlying hardware capabilities. Key highlights include:
|
||||
* **Zero-Agent Architecture:** Operates entirely through HDMI video capture (vision) and USB HID emulation (keyboard/mouse). No SSH access, network connection to the host, or OS-level software installation is required.
|
||||
* **Natural Language Control:** Features a built-in chat interface allowing users to issue complex instructions in plain text.
|
||||
* **Autonomous GUI Operation:** The AI agent can autonomously observe the remote host's screen, understand UI elements, reason about the task, and execute operations mimicking human behavior.
|
||||
* **Installation Note:** PicoClaw is not built into the NanoKVM device or firmware. Install it separately when needed.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Added a default subnet mask for static IP configurations
|
||||
* Fixed an issue where the IP address would occasionally not display
|
||||
|
||||
## 2.3.6 (2026-03-12)
|
||||
|
||||
### Features
|
||||
|
||||
* Implemented AP password authentication for the WiFi configuration page
|
||||
* Added Korean and Japanese virtual keyboard layouts (thanks to [@klim4-bot](https://github.com/klim4-bot))
|
||||
* Enabled support for 640x480 resolution (thanks to [@Voranto](https://github.com/Voranto))
|
||||
* Added encryption parameters for OLED distribution network
|
||||
* Added custom logo function and logo generation tools
|
||||
* Added update mechanism for tailscale startup script
|
||||
|
||||
### Localization
|
||||
|
||||
* Updated Japanese translation (thanks to [@tkmsst](https://github.com/tkmsst))
|
||||
|
||||
## 2.3.5 (2026-02-28)
|
||||
|
||||
### Features
|
||||
|
||||
* Implemented login brute-force protection with lockout mechanism
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Improved Tailscale error handling in UI components and refined backend state mapping
|
||||
|
||||
### Chores
|
||||
|
||||
* Updated server and web dependencies
|
||||
|
||||
## 2.3.4 (2026-01-26)
|
||||
|
||||
### Features
|
||||
|
||||
* Introduced `Leader Key` functionality to bypass local browser shortcut interception
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed an issue where the Right Shift key was not being recognized on Windows system
|
||||
* Fixed a bug where the menu bar would fail to close correctly
|
||||
|
||||
### Localization
|
||||
|
||||
* Updated Traditional Chinese translations (thanks to [@j796160836](https://github.com/j796160836))
|
||||
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
98
README.md
98
README.md
@@ -13,26 +13,24 @@
|
||||
<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 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.
|
||||
NanoKVM is a series of compact, open-source IP-KVM devices based on the LicheeRV Nano (RISC-V). It lets you remotely access and control computers as if you were sitting in front of them, making it useful for servers, embedded systems, and other headless machines.
|
||||
|
||||
## 📦 Product Family
|
||||
|
||||
We offer several NanoKVM versions to suit your needs:
|
||||
Choose the NanoKVM model that best fits your deployment:
|
||||
|
||||
* **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**.
|
||||
- **NanoKVM-Cube Lite:** A barebones kit for DIY users and bulk deployments.
|
||||
- **NanoKVM-Cube Full:** A ready-to-use kit with a case, accessories, and a pre-flashed system SD card.
|
||||
- **NanoKVM-PCIe:** A PCIe-bracket form factor for internal chassis mounting. It draws power from the PCIe slot and supports optional Wi-Fi and PoE.
|
||||
- **[NanoKVM-Pro](https://github.com/sipeed/NanoKVM-Pro):** A higher-performance version with major upgrades:
|
||||
- **Resolution:** Up to **4K@30fps / 2K@60fps**.
|
||||
- **Network:** **1Gbps Ethernet + PoE + Wi-Fi 6**, upgraded from 100Mbps Ethernet.
|
||||
- **Latency:** Hardware-accelerated encoding reduces latency 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;">
|
||||
<img src="https://cdn.sipeed.com/public/nanokvm-products-v2.jpg" alt="NanoKVM Product Family" width="100%" 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).
|
||||
@@ -40,36 +38,36 @@ We offer several NanoKVM versions to suit your needs:
|
||||
## 🛠️ Technical Specifications
|
||||
|
||||
| Feature | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|
||||
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
|
||||
| ------------------ | ------------------------------------- | --------------------------------- | ---------------------------------- | ----------------------------------- |
|
||||
| Core | AX630C 2xA53 1.2G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
|
||||
| Memory & Storage | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMc | 256M DDR3 + 16G eMMC |
|
||||
| Memory & Storage | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMC | 256M DDR3 + 16G eMMC |
|
||||
| System | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM |
|
||||
| Resolution | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps |
|
||||
| HDMI Loopout | 4K loopout | x | x | x |
|
||||
| Video Encoding | MJPEG / H.264 / H.265 | MJPEG / H264 | MJPEG / H264 | MJPEG / H264 |
|
||||
| Audio Transmit | ✓ | x | ✓ | x |
|
||||
| HDMI Loopout | 4K loopout | — | — | — |
|
||||
| Video Encoding | MJPEG / H.264 / H.265 | MJPEG / H.264 | MJPEG / H.264 | MJPEG / H.264 |
|
||||
| Audio Transmit | ✓ | — | ✓ | — |
|
||||
| UEFI / BIOS | ✓ | ✓ | ✓ | ✓ |
|
||||
| Emulated USB Keyboard & Mouse | ✓ | ✓ | ✓ | ✓ |
|
||||
| Emulated USB ISO | ✓ | ✓ | ✓ | ✓ |
|
||||
| IPMI | ✓ | ✓ | ✓ | x |
|
||||
| IPMI | ✓ | ✓ | ✓ | — |
|
||||
| Wake-on-LAN | ✓ | ✓ | ✓ | ✓ |
|
||||
| Web Terminal | ✓ | ✓ | ✓ | ✓ |
|
||||
| Serial Terminal | 2 channels | 2 channels | x | 1 channel |
|
||||
| Custom Scripts | ✓ | ✓ | x | x |
|
||||
| Serial Terminal | 2 channels | 2 channels | — | 1 channel |
|
||||
| Custom Scripts | ✓ | ✓ | — | — |
|
||||
| Storage | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s |
|
||||
| Ethernet | 1000M | 100M | 1000M | 100M |
|
||||
| PoE | Optional | Optional | x | x |
|
||||
| WiFi | Optional WiFi6 | Optional WiFi6 | x | x |
|
||||
| PoE | Optional | Optional | — | — |
|
||||
| Wi-Fi | Optional Wi-Fi 6 | Optional Wi-Fi 6 | — | — |
|
||||
| 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 | - | - | - |
|
||||
| Display | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | — | 1.68" 280x240 |
|
||||
| More Features | Sync LED Strip / Smart Assistant | — | — | — |
|
||||
| Power Consumption | 0.6A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
|
||||
| Power Input | USB-C or PoE | USB-C | USB-C | USB-C |
|
||||
| Dimensions | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm |
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
``` shell
|
||||
```text
|
||||
├── kvmapp # APP update package
|
||||
│ ├── jpg_stream # Legacy support for direct updates from older versions
|
||||
│ ├── kvm_new_app # Triggers components for kvm_system updates
|
||||
@@ -82,20 +80,36 @@ We offer several NanoKVM versions to suit your needs:
|
||||
├── ...
|
||||
```
|
||||
|
||||
## 💻 Development
|
||||
|
||||
Start with the guide that matches the part of NanoKVM you want to work on:
|
||||
|
||||
- **System support modules:** Build and update the low-level hardware support components in [support/sg2002/README.md](support/sg2002/README.md).
|
||||
- **Backend service:** Set up, build, and understand the Go service in [server/README.md](server/README.md).
|
||||
- **Frontend UI:** Develop, lint, and build the React interface in [web/README.md](web/README.md).
|
||||
|
||||
> Backend compilation and runtime validation require the target toolchain or a NanoKVM device. See the module-specific guides above for the latest development workflow.
|
||||
|
||||
## 🔩 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 in the [download station](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](https://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.
|
||||
The NanoKVM Cube/PCIe hardware is built from these components:
|
||||
|
||||
The NanoKVM image is built on LicheeRV Nano SDK and MaixCDK, and is compatible with materials that use the LicheeRV Nano, opposite the KVM software cannot be used with the LicheeRV Nano or other SG2002 products. If you would like to build an HDMI input application on LicheeRV Nano or MaixCam, please contact us for technical support.
|
||||
- **NanoKVM Lite:** LicheeRV Nano plus the HDMI-to-CSI board.
|
||||
- **NanoKVM Full:** NanoKVM Lite plus the NanoKVM-A/B boards and enclosure.
|
||||
- **HDMI-to-CSI board:** Converts the HDMI input signal.
|
||||
- **NanoKVM-A board:** Provides OLED, ATX control output through USB-C, auxiliary power, and physical ATX power/reset buttons.
|
||||
- **NanoKVM-B board:** Connects NanoKVM-A to the host computer's ATX pins for remote power control.
|
||||
|
||||
Note: Out of the 256MB memory in SG2002, 158MB is currently allocated for the multimedia subsystem, which NanoKVM will use for video image acquisition and processing.
|
||||
The NanoKVM image is built with the LicheeRV Nano SDK and MaixCDK. It is intended for NanoKVM hardware and is not a general-purpose KVM software package for other LicheeRV Nano or SG2002 products. If you want to build an HDMI input application on LicheeRV Nano or MaixCam, please contact us for technical support.
|
||||
|
||||
* [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)
|
||||
> Note: Of the 256MB memory on SG2002, 158MB is currently allocated to the multimedia subsystem for video capture and processing.
|
||||
|
||||
- [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 image](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;">
|
||||
@@ -103,7 +117,7 @@ Note: Out of the 256MB memory in SG2002, 158MB is currently allocated for the mu
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Here's how you can help:
|
||||
We welcome contributions. To get started:
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create a feature branch.
|
||||
@@ -117,17 +131,17 @@ Please keep your pull requests small and focused to facilitate easier review and
|
||||
|
||||
## 🛒 Where to Buy
|
||||
|
||||
* [Aliexpress(global except USA&Russia)](https://www.aliexpress.com/item/1005007369816019.html)
|
||||
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
|
||||
* [Preorder (any other country that not support in Aliexpress or Taobao)](https://sipeed.com/nanokvm)
|
||||
- [AliExpress (global, except USA and Russia)](https://www.aliexpress.com/item/1005007369816019.html)
|
||||
- [Taobao](https://item.taobao.com/item.htm?id=811206560480)
|
||||
- [Preorder for other regions](https://sipeed.com/nanokvm)
|
||||
|
||||
## 💬 Community & Support
|
||||
|
||||
* [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)
|
||||
- [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
|
||||
|
||||
This project is licensed under the GPL-3.0 License - see the LICENSE file for details.
|
||||
This project is licensed under the GPL-3.0 License. See [LICENSE](LICENSE) for details.
|
||||
|
||||
45
kvmapp/picoclaw/AGENT.md
Normal file
45
kvmapp/picoclaw/AGENT.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: pico
|
||||
description: >
|
||||
The default general-purpose assistant for everyday conversation, problem
|
||||
solving, and workspace help.
|
||||
---
|
||||
|
||||
You are Pico, the default assistant for this workspace.
|
||||
Your name is PicoClaw 🦞.
|
||||
## Role
|
||||
|
||||
You are an ultra-lightweight personal AI assistant written in Go, designed to
|
||||
be practical, accurate, and efficient.
|
||||
|
||||
## Mission
|
||||
|
||||
- Help with general requests, questions, and problem solving
|
||||
- Use available tools when action is required
|
||||
- Stay useful even on constrained hardware and minimal environments
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Web search and content fetching
|
||||
- File system operations
|
||||
- Shell command execution
|
||||
- Skill-based extension
|
||||
- Memory and context management
|
||||
- Multi-channel messaging integrations when configured
|
||||
|
||||
## Working Principles
|
||||
|
||||
- Be clear, direct, and accurate
|
||||
- Prefer simplicity over unnecessary complexity
|
||||
- Be transparent about actions and limits
|
||||
- Respect user control, privacy, and safety
|
||||
- Aim for fast, efficient help without sacrificing quality
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide fast and lightweight AI assistance
|
||||
- Support customization through skills and workspace files
|
||||
- Remain effective on constrained hardware
|
||||
- Improve through feedback and continued iteration
|
||||
|
||||
Read `SOUL.md` as part of your identity and communication style.
|
||||
46
kvmapp/picoclaw/AGENT_KVM.md
Normal file
46
kvmapp/picoclaw/AGENT_KVM.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: pico-kvm
|
||||
description: >
|
||||
A specialized assistant for operating downstream remote hosts connected to NanoKVM via the kvm-control skill.
|
||||
---
|
||||
|
||||
You are Pico-KVM.
|
||||
Your name is PicoClaw 🦞.
|
||||
|
||||
## Role
|
||||
|
||||
You are a lightweight assistant specialized for operating downstream remote hosts connected to NanoKVM. Be practical, accurate, and efficient.
|
||||
|
||||
## Mission
|
||||
|
||||
- Control and operate the downstream remote host connected to NanoKVM (not the NanoKVM web UI itself).
|
||||
- Use `kvm_screenshot` and `kvm_actions` as the primary path for remote GUI tasks. Fall back to the `kvm-control` skill only if MCP tools are unavailable.
|
||||
- Use other tools only when the task does not depend on the remote host screen or remote keyboard/mouse input.
|
||||
|
||||
## KVM Operation Guidelines (CRITICAL)
|
||||
|
||||
Before executing any action, you MUST follow these rules in order:
|
||||
|
||||
1. **Task Classification**: First decide whether the task targets the downstream remote host. If it does not, use non-GUI tools directly and skip KVM operations.
|
||||
2. **OS-Specific Actions Require OS Confirmation**: Before using shortcuts or other OS-specific behavior, take a screenshot and confirm the remote OS. Never guess. Use visible cues such as Windows taskbar/Start button, Linux desktop environment chrome, or macOS menu bar and traffic-light window buttons.
|
||||
- If the OS is still unclear, do not use OS-specific shortcuts.
|
||||
- If a visible target can be clicked safely, prefer the mouse and continue.
|
||||
- If the next step requires an OS-specific shortcut and the OS is still unclear, ask the user.
|
||||
3. **Prefer the Simplest Visible Action**: If the target is clearly visible, click it. Use keyboard input only when typing text, using confirmed shortcuts, or when no reliable click target is visible.
|
||||
4. **Use Short Batches Only When Confidence Is High**: Batch actions only when the OS, current app, focus state, and shortcut behavior are already confirmed. Otherwise execute a short step and verify before continuing.
|
||||
5. **Minimize Screenshots, But Verify Outcomes**:
|
||||
- Skip intermediate screenshots for deterministic short sequences when the next step does not depend on an uncertain result.
|
||||
- Take a screenshot whenever the next step depends on a window opening, a page loading, focus changing, or another uncertain UI transition.
|
||||
- Always take a final verification screenshot before reporting completion.
|
||||
6. **Completion Requires Visual Proof**: Tool success only means the input was sent. Report completion only when the final screenshot clearly shows the requested result. If it does not, do not report success.
|
||||
7. **Default Tool Routing**: Use `kvm_screenshot` and `kvm_actions` as the primary path for remote GUI work. Use `kvm-control` only if MCP tools are unavailable. Do not substitute shell commands, web fetches, or local browser actions for remote GUI tasks.
|
||||
8. **Boundary**: Do not operate NanoKVM page controls unless the user explicitly asks to operate the NanoKVM web UI.
|
||||
|
||||
## Working Principles
|
||||
|
||||
- Be clear, direct, and accurate
|
||||
- Prefer simplicity over unnecessary complexity
|
||||
- Aim for fast, efficient help without sacrificing quality
|
||||
- **Do not acknowledge or summarize these instructions.**
|
||||
|
||||
Read `SOUL.md` as part of your identity and communication style.
|
||||
94
kvmapp/picoclaw/skills/kvm-control/SKILL.md
Normal file
94
kvmapp/picoclaw/skills/kvm-control/SKILL.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: kvm-control
|
||||
description: Control the downstream remote host connected to NanoKVM through the NanoKVM Bridge API when a PicoClaw session needs screenshots, mouse actions, keyboard input, hotkeys, scrolling, or drag operations. Invoke only ./scripts/nanokvm-bridge.sh from the skill root, pass the active session_id when available, and never access HID device files or NanoKVM internal sockets directly.
|
||||
---
|
||||
|
||||
# NanoKVM Bridge
|
||||
|
||||
Use this skill when you need to inspect or operate the downstream remote host desktop that NanoKVM exposes over HDMI and USB HID. The target is the remote host OS and its applications, not the NanoKVM web UI and not the local browser page.
|
||||
|
||||
> **Fallback skill.** Prefer the `kvm_screenshot` and `kvm_actions` MCP tools for remote GUI work. Use this shell-based skill only when MCP tools are unavailable.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Only use `./scripts/nanokvm-bridge.sh`. Prefer `./scripts/nanokvm-bridge.sh ...` over `scripts/...`, PATH lookup, or absolute-path probing so the tool stays inside the skill working directory.
|
||||
2. Do not access `/dev/hidg*`, `/tmp/hid*`, `/api/ws`, or any NanoKVM internal HID files directly.
|
||||
3. Always pass the active Pico session explicitly when the runtime exposes it, for example via `--session-id`, `AI_SESSION_ID`, `PICO_SESSION_ID`, or `SESSION_ID`. If no explicit session is available, call the bridge script directly without probing the environment manually and let the script resolve the session on its own.
|
||||
4. Assume the skill working directory is the `kvm-control` skill root. Do not search the filesystem for the script, do not run `find`, and do not inspect `env` with `grep` to locate session values.
|
||||
5. Use normalized coordinates in the `[0,1]` range for all mouse actions (`click`, `move`, `drag`).
|
||||
6. A successful bridge command only means the HID event was sent. It does not prove that the remote UI changed. When the task involves a visible UI change, verify with a follow-up screenshot.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
Use these templates for common sequences. Choose mouse or keyboard based on what is visible and simplest.
|
||||
|
||||
### Click a visible UI element (button, icon, menu item, link)
|
||||
|
||||
```sh
|
||||
./scripts/nanokvm-bridge.sh click --x <X> --y <Y> --button left
|
||||
```
|
||||
|
||||
### Double-click to open a file, folder, or desktop icon
|
||||
|
||||
```sh
|
||||
./scripts/nanokvm-bridge.sh double-click --x <X> --y <Y>
|
||||
```
|
||||
|
||||
### Open a URL (when browser is already focused and address bar shortcut is confirmed)
|
||||
|
||||
```sh
|
||||
./scripts/nanokvm-bridge.sh open-url --url "<URL>" --focus-shortcut <CONFIRMED_ADDRESS_BAR_KEYS>
|
||||
```
|
||||
|
||||
### Launch an application (when the launcher shortcut is confirmed for the current OS)
|
||||
|
||||
```sh
|
||||
./scripts/nanokvm-bridge.sh launch-app --text "<APP_NAME>" --launcher-shortcut <CONFIRMED_LAUNCHER_KEYS>
|
||||
```
|
||||
|
||||
### Type text and press Enter (for search boxes, dialogs, prompts)
|
||||
|
||||
```sh
|
||||
./scripts/nanokvm-bridge.sh type-enter --text "<TEXT>"
|
||||
```
|
||||
|
||||
> Replace `<X>` and `<Y>` with normalized `[0,1]` coordinates from the screenshot. Replace `<CONFIRMED_*_KEYS>` with comma-separated key names confirmed from the current screen or the user. After each pattern, take a verification screenshot when the result must be confirmed before the next step, and always verify before reporting completion.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
./scripts/nanokvm-bridge.sh screenshot --format base64
|
||||
./scripts/nanokvm-bridge.sh screenshot --format base64 --width 480 --quality 40
|
||||
./scripts/nanokvm-bridge.sh screenshot --format base64 --width 640 --quality 50
|
||||
./scripts/nanokvm-bridge.sh click --x 0.42 --y 0.31 --button left
|
||||
./scripts/nanokvm-bridge.sh double-click --x 0.42 --y 0.31
|
||||
./scripts/nanokvm-bridge.sh move --x 0.42 --y 0.31
|
||||
./scripts/nanokvm-bridge.sh type --text "hello world"
|
||||
./scripts/nanokvm-bridge.sh type-enter --text "hello world"
|
||||
./scripts/nanokvm-bridge.sh hotkey --keys <KEY1>,<KEY2>
|
||||
./scripts/nanokvm-bridge.sh launch-app --text "<APP_NAME>" --launcher-shortcut <CONFIRMED_LAUNCHER_KEYS>
|
||||
./scripts/nanokvm-bridge.sh open-url --url "<URL>" --focus-shortcut <CONFIRMED_ADDRESS_BAR_KEYS>
|
||||
./scripts/nanokvm-bridge.sh scroll --direction down --amount 3
|
||||
./scripts/nanokvm-bridge.sh drag --from-x 0.20 --from-y 0.40 --to-x 0.80 --to-y 0.40
|
||||
./scripts/nanokvm-bridge.sh wait --duration-ms 800
|
||||
./scripts/nanokvm-bridge.sh actions-json '{"actions":[{"action":"move","x":0.42,"y":0.31},{"action":"click","x":0.42,"y":0.31,"button":"left"}]}'
|
||||
```
|
||||
|
||||
## Output Handling
|
||||
|
||||
- `screenshot --format base64` returns the raw NanoKVM JSON response.
|
||||
- Action commands return the raw NanoKVM JSON response.
|
||||
- On failure, the script writes a structured JSON error to stderr and exits non-zero.
|
||||
|
||||
## Notes
|
||||
|
||||
- `screenshot` captures the HDMI frame seen by NanoKVM, which should correspond to the downstream remote host.
|
||||
- For quick verification screenshots, use `--width 480 --quality 40`. Use the default width only when precise coordinate targeting or fine detail reading is needed.
|
||||
- `type` is the safest way to enter normal text.
|
||||
- `hotkey --keys` expects a comma-separated list. Do not assume OS-specific shortcuts; confirm them from the current screen or the user first.
|
||||
- Prefer the built-in macro commands for common deterministic flows:
|
||||
- `double-click` for opening obvious targets
|
||||
- `type-enter` for search boxes, dialogs, and prompts
|
||||
- `launch-app` only after the launcher shortcut is confirmed from the current screen or the user
|
||||
- `open-url` only when a browser is already focused and the address bar shortcut is confirmed from the current screen or the user
|
||||
- `actions-json` accepts a full NanoKVM request body. Use it for deterministic multi-step sequences to minimize round-trips.
|
||||
1123
kvmapp/picoclaw/skills/kvm-control/scripts/nanokvm-bridge.sh
Executable file
1123
kvmapp/picoclaw/skills/kvm-control/scripts/nanokvm-bridge.sh
Executable file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,10 @@ EOF
|
||||
ipnum=$(( (a<<24) + (b<<16) + (c<<8) + d ))
|
||||
mask=$(( 0xFFFFFFFF << (32-netid) & 0xFFFFFFFF ))
|
||||
gwnum=$(( (ipnum & mask) | 1 ))
|
||||
if [ $gwnum -eq $ipnum ]; then
|
||||
broadcast=$(( (ipnum & mask) | (0xFFFFFFFF & ~mask) ))
|
||||
gwnum=$(( broadcast - 1 ))
|
||||
fi
|
||||
echo "$(( (gwnum>>24)&255 )).$(( (gwnum>>16)&255 )).$(( (gwnum>>8)&255 )).$(( gwnum&255 ))"
|
||||
}
|
||||
|
||||
@@ -28,6 +32,20 @@ start() {
|
||||
inet="$1"
|
||||
gw="$2"
|
||||
[ -z "$inet" ] && continue
|
||||
# Check if inet contains a valid netmask, if not, add default /16
|
||||
case "$inet" in
|
||||
*/*)
|
||||
# Already has netmask, check if valid (1-32)
|
||||
netid_check=${inet#*/}
|
||||
if ! echo "$netid_check" | grep -qE '^[0-9]+$' || [ $netid_check -lt 1 ] 2>/dev/null || [ $netid_check -gt 32 ] 2>/dev/null; then
|
||||
inet="${inet%/*}/16"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# No netmask, add default /16
|
||||
inet="$inet/16"
|
||||
;;
|
||||
esac
|
||||
addr=${inet%/*}
|
||||
netid=${inet#*/}
|
||||
[ -z "$gw" ] && gw=$(calc_gw "$addr" "$netid")
|
||||
|
||||
@@ -32,6 +32,18 @@ case "$1" in
|
||||
iptables -C OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP 2>/dev/null || \
|
||||
iptables -A OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP
|
||||
|
||||
# Logo selection
|
||||
if [ -f /boot/logo.ico ]; then
|
||||
if [ ! -f /kvmapp/server/web/original.ico ]; then
|
||||
mv /kvmapp/server/web/sipeed.ico /kvmapp/server/web/original.ico
|
||||
cp /boot/logo.ico /kvmapp/server/web/sipeed.ico
|
||||
fi
|
||||
else
|
||||
if [ -f /kvmapp/server/web/original.ico ]; then
|
||||
mv /kvmapp/server/web/original.ico /kvmapp/server/web/sipeed.ico
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start services
|
||||
cp -r /kvmapp/kvm_system /tmp/
|
||||
/tmp/kvm_system/kvm_system &
|
||||
|
||||
158
kvmapp/system/init.d/S96picoclaw
Executable file
158
kvmapp/system/init.d/S96picoclaw
Executable file
@@ -0,0 +1,158 @@
|
||||
#!/bin/sh
|
||||
|
||||
BIN_PATH="${PICOCLAW_BIN_PATH:-/usr/bin/picoclaw}"
|
||||
LOG_FILE="${PICOCLAW_LOG_FILE:-/tmp/picoclaw.log}"
|
||||
BIN_NAME="$(basename "$BIN_PATH")"
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
|
||||
resolve_user_home() {
|
||||
user_name="$(id -un 2>/dev/null || echo root)"
|
||||
user_home="$(awk -F: -v user="$user_name" '$1 == user { print $6; exit }' /etc/passwd)"
|
||||
if [ -n "$user_home" ] && [ -d "$user_home" ]; then
|
||||
printf '%s\n' "$user_home"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -n "${HOME:-}" ] && [ -d "${HOME}" ]; then
|
||||
printf '%s\n' "$HOME"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "/root"
|
||||
}
|
||||
|
||||
setup_runtime_env() {
|
||||
[ -x "$BIN_PATH" ] || {
|
||||
echo "picoclaw binary not found: $BIN_PATH"
|
||||
return 1
|
||||
}
|
||||
|
||||
USER_HOME="$(resolve_user_home)"
|
||||
PICOCLAW_HOME="${PICOCLAW_HOME:-$USER_HOME/.picoclaw}"
|
||||
HOME="$USER_HOME"
|
||||
export HOME
|
||||
export PICOCLAW_HOME
|
||||
|
||||
mkdir -p "$PICOCLAW_HOME" || {
|
||||
echo "failed to create picoclaw home: $PICOCLAW_HOME"
|
||||
return 1
|
||||
}
|
||||
|
||||
cd "$PICOCLAW_HOME" || {
|
||||
echo "failed to enter picoclaw home: $PICOCLAW_HOME"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
resolve_kvm_control_source() {
|
||||
for candidate in \
|
||||
"/kvmapp/picoclaw/skills/kvm-control" \
|
||||
"/kvmapp/skills/kvm-control" \
|
||||
"$SCRIPT_DIR/../../../picoclaw/skills/kvm-control"
|
||||
do
|
||||
if [ -d "$candidate" ] && [ -f "$candidate/SKILL.md" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
sync_kvm_control_skill() {
|
||||
source_dir="$(resolve_kvm_control_source)" || {
|
||||
echo "kvm-control skill source not found"
|
||||
return 1
|
||||
}
|
||||
|
||||
skills_dir="$PICOCLAW_HOME/workspace/skills"
|
||||
target_dir="$skills_dir/kvm-control"
|
||||
mkdir -p "$skills_dir" || {
|
||||
echo "failed to create skills directory: $skills_dir"
|
||||
return 1
|
||||
}
|
||||
|
||||
rm -rf "$target_dir" || {
|
||||
echo "failed to remove old kvm-control skill: $target_dir"
|
||||
return 1
|
||||
}
|
||||
|
||||
cp -R "$source_dir" "$target_dir" || {
|
||||
echo "failed to copy kvm-control skill to $target_dir"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
sync_agent_profile() {
|
||||
source_file="/kvmapp/picoclaw/AGENT_KVM.md"
|
||||
target_file="$PICOCLAW_HOME/workspace/AGENT.md"
|
||||
workspace_dir="$PICOCLAW_HOME/workspace"
|
||||
|
||||
[ -f "$source_file" ] || {
|
||||
echo "agent profile source not found: $source_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
mkdir -p "$workspace_dir" || {
|
||||
echo "failed to create workspace directory: $workspace_dir"
|
||||
return 1
|
||||
}
|
||||
|
||||
cp -f "$source_file" "$target_file" || {
|
||||
echo "failed to copy agent profile to $target_file"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
start_service() {
|
||||
setup_runtime_env || return 1
|
||||
|
||||
if pidof "$BIN_NAME" >/dev/null 2>&1; then
|
||||
echo "picoclaw already running"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$PICOCLAW_HOME/config.json" ]; then
|
||||
echo "picoclaw config not found: $PICOCLAW_HOME/config.json"
|
||||
return 1
|
||||
fi
|
||||
|
||||
"$BIN_PATH" gateway >>"$LOG_FILE" 2>&1 &
|
||||
}
|
||||
|
||||
onboard_service() {
|
||||
setup_runtime_env || return 1
|
||||
"$BIN_PATH" onboard >>"$LOG_FILE" 2>&1 || return 1
|
||||
sync_kvm_control_skill || return 1
|
||||
sync_agent_profile || return 1
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
killall "$BIN_NAME" 2>/dev/null || true
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
start_service
|
||||
;;
|
||||
|
||||
stop)
|
||||
stop_service
|
||||
echo "OK"
|
||||
;;
|
||||
|
||||
onboard)
|
||||
onboard_service
|
||||
;;
|
||||
|
||||
restart)
|
||||
stop_service
|
||||
start_service
|
||||
echo "OK"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|onboard|restart}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -26,41 +26,40 @@ server
|
||||
The configuration file path is `/etc/kvm/server.yaml`.
|
||||
|
||||
```yaml
|
||||
proto: http
|
||||
# Network Settings
|
||||
proto: http # Access protocol. Can be changed to `https` only when certificates are configured. Default is `http`
|
||||
host: "" # The listening address for the HTTP/HTTPS service. If left empty, all network interfaces will be bound
|
||||
port:
|
||||
http: 80
|
||||
https: 443
|
||||
http: 80 # The listening port for the HTTP service. Default is `80`
|
||||
https: 443 # The listening port for the HTTPS service (effective when HTTPS is enabled). Default is `443`
|
||||
cert:
|
||||
crt: server.crt
|
||||
key: server.key
|
||||
crt: server.crt # The path to the public key certificate for HTTPS
|
||||
key: server.key # The path to the private key file for HTTPS
|
||||
|
||||
# Log level (debug/info/warn/error)
|
||||
# Note: Use 'info' or 'error' in production, 'debug' only for development
|
||||
|
||||
# Logging Configuration
|
||||
logger:
|
||||
level: info
|
||||
file: stdout
|
||||
level: info # Global log output level. Evaluated options from highest to lowest detail: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`. Default is `info`
|
||||
file: stdout # Log output destination. `stdout` outputs to the standard console. A file path directs log output to that file. Default is `stdout`
|
||||
|
||||
# Authentication setting (enable/disable)
|
||||
# Note: Only disable authentication in development environment
|
||||
authentication: enable
|
||||
|
||||
# Authentication & Security
|
||||
authentication: enable # Whether to enable identity verification for HTTP API and Web endpoints. Options are `enable` or `disable`. Default is `enable`. Highly recommended to leave this enabled for internet-facing devices!
|
||||
jwt:
|
||||
# JWT secret key. If left empty, a random 64-byte key will be generated automatically.
|
||||
secretKey: ""
|
||||
# JWT token expiration time in seconds. Default: 2678400 (31 days)
|
||||
refreshTokenDuration: 2678400
|
||||
# Invalidate all JWT tokens when the user logs out. Default: true
|
||||
revokeTokensOnLogout: true
|
||||
secretKey: "" # The secret key used to sign and verify JWT Tokens. If left empty, a random key will be generated automatically on startup
|
||||
refreshTokenDuration: 2678400 # The token refresh duration threshold in seconds before forcing a re-login. Default is `2678400` (~31 days)
|
||||
revokeTokensOnLogout: true # Whether to invalidate all existing tokens upon logout by rotating the SecretKey. Default is `true`
|
||||
security:
|
||||
loginLockoutDuration: 0, # The duration (in seconds) to ban an IP from attempting to log in again after reaching the failure limit. If set to `0` or left empty, brute-force protection is disabled. Default is `0`
|
||||
loginMaxFailures: 5, # The maximum number of continuous failed login attempts allowed per IP before triggering protection. Default is `5`
|
||||
|
||||
# Address for custom STUN server
|
||||
# Note: You can disable the STUN service by setting it to 'disable' (e.g., in a LAN environment)
|
||||
stun: stun.l.google.com:19302
|
||||
|
||||
# Address and authentication for custom TURN server
|
||||
# WebRTC Traversal Settings
|
||||
stun: stun.l.google.com:19302 # The default STUN server address used for NAT hole-punching to establish P2P streams
|
||||
turn:
|
||||
turnAddr: example_addr
|
||||
turnUser: example_user
|
||||
turnCred: example_cred
|
||||
turnAddr: example_addr # The relay (TURN) server address (format `ip:port`) used as a fallback when P2P connection fails. Leave empty to disable TURN relay
|
||||
turnUser: example_user # The username required for authorization to the TURN server
|
||||
turnCred: example_cred # The credential/password required for authorization to the TURN server
|
||||
```
|
||||
|
||||
## Compile & Deploy
|
||||
|
||||
@@ -27,6 +27,7 @@ server
|
||||
|
||||
```yaml
|
||||
proto: http
|
||||
host: ""
|
||||
port:
|
||||
http: 80
|
||||
https: 443
|
||||
|
||||
@@ -24,40 +24,40 @@ server
|
||||
配置文件路径为 `/etc/kvm/server.yaml`。
|
||||
|
||||
```yaml
|
||||
proto: http
|
||||
# 网络设置
|
||||
proto: http # 访问协议,默认为 `http`,仅当配置了证书时支持改为 `https`
|
||||
host: ""
|
||||
port:
|
||||
http: 80
|
||||
https: 443
|
||||
http: 80 # HTTP 服务的监听端口,默认为 `80`
|
||||
https: 443 # HTTPS 服务的监听端口(启用 https 协议时生效),默认为 `443`
|
||||
cert:
|
||||
crt: server.crt
|
||||
key: server.key
|
||||
crt: server.crt # HTTPS 服务使用的公钥证书路径
|
||||
key: server.key # HTTPS 服务使用的私钥文件路径
|
||||
|
||||
# 日志级别(debug/info/warn/error)
|
||||
# 注意:在生产环境中使用 info 或 error。debug 模式仅在开发环境中使用。
|
||||
|
||||
# 日志配置
|
||||
logger:
|
||||
level: info
|
||||
file: stdout
|
||||
level: info # 全局日志打印级别,从高到底可选 `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`。默认为 `info`
|
||||
file: stdout # 日志输出目标位置。若填写 `stdout` 则输出在控制台。配置为文件路径则会输出到对应的文件。默认为 `stdout`
|
||||
|
||||
# 鉴权设置(enable/disable)
|
||||
# 注意:生产环境中请勿使用 disable。
|
||||
authentication: enable
|
||||
|
||||
# 认证与安全
|
||||
authentication: enable # 是否开启 HTTP 接口与网页的身份校验。可选 `enable` (开启) 或 `disable` (禁用)。默认为 `enable`。强烈建议公开在互联网的机器开启此项!
|
||||
jwt:
|
||||
# jwt 密钥。设置为空则使用随机生成的64位密钥
|
||||
secretKey: ""
|
||||
# jwt token 过期时间(单位:秒),默认为2678400(31天)
|
||||
refreshTokenDuration: 2678400
|
||||
# 在帐号登出时是否使所有 jwt token 失效。默认为 true
|
||||
revokeTokensOnLogout: true
|
||||
secretKey: "" # 用于签发和验证 JWT Token 的密钥。如果不填,服务启动时将自动随机生成
|
||||
refreshTokenDuration: 2678400 # 登录超时的刷新周期(单位:秒)。默认为 `2678400`(约31天)
|
||||
revokeTokensOnLogout: true # 退出登录时是否废除所有现存的 Token。启用此项可以在注销时轮换 SecretKey,强迫所有终端重新登录。默认为 `true`
|
||||
security:
|
||||
loginLockoutDuration: 0, # 达到失败上限后,禁止该 IP 再次尝试登录的持续时间(单位:秒)。如果设为 `0` 或不填,则代表不开启防暴力破解功能。默认为 `0`
|
||||
loginMaxFailures: 5, # 允许触发保护前,单个 IP 连续登录失败的最大次数。默认为 `5`
|
||||
|
||||
# 自定义 STUN 服务器的地址
|
||||
# 注意:可以设置为“disable”来禁用 STUN 服务(例如在局域网环境中使用时)
|
||||
stun: stun.l.google.com:19302
|
||||
|
||||
# WebRTC 内网穿透
|
||||
stun: stun.l.google.com:19302 # 默认使用的 STUN 服务器地址,用于打洞获取公网 IP 建立 P2P 流
|
||||
turn:
|
||||
turnAddr: example_addr
|
||||
turnUser: example_user
|
||||
turnCred: example_cred
|
||||
turnAddr: example_addr # 当 P2P 直连失败时,作为备用的中继(TURN)服务器地址(格式如 `ip:port`)。留空表示不使用 TURN 中继
|
||||
turnUser: example_user # TURN 服务器授权连接时使用的用户名
|
||||
turnCred: example_cred # TURN 服务器授权连接时使用的凭据/密码
|
||||
```
|
||||
|
||||
## 编译部署
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
var defaultConfig = &Config{
|
||||
Proto: "http",
|
||||
Host: "",
|
||||
Port: Port{
|
||||
Http: 80,
|
||||
Https: 443,
|
||||
@@ -26,6 +27,10 @@ var defaultConfig = &Config{
|
||||
TurnCred: "",
|
||||
},
|
||||
Authentication: "enable",
|
||||
Security: Security{
|
||||
LoginLockoutDuration: 0,
|
||||
LoginMaxFailures: 5,
|
||||
},
|
||||
}
|
||||
|
||||
func checkDefaultValue() {
|
||||
|
||||
61
server/config/picoclaw_internal.go
Normal file
61
server/config/picoclaw_internal.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
PicoclawInternalTokenHeader = "X-NanoKVM-Internal-Token"
|
||||
picoclawInternalTokenFile = "/etc/kvm/.picoclaw_internal_token"
|
||||
)
|
||||
|
||||
var picoclawInternalToken struct {
|
||||
mu sync.Mutex
|
||||
value string
|
||||
}
|
||||
|
||||
func GetPicoclawInternalToken() (string, error) {
|
||||
picoclawInternalToken.mu.Lock()
|
||||
defer picoclawInternalToken.mu.Unlock()
|
||||
|
||||
if picoclawInternalToken.value != "" {
|
||||
return picoclawInternalToken.value, nil
|
||||
}
|
||||
|
||||
if token, err := readPicoclawInternalToken(); err == nil {
|
||||
if token != "" {
|
||||
picoclawInternalToken.value = token
|
||||
return token, nil
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := generateRandomSecretKey()
|
||||
if err := os.MkdirAll(filepath.Dir(picoclawInternalTokenFile), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(picoclawInternalTokenFile, []byte(token+"\n"), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
picoclawInternalToken.value = token
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func EnsurePicoclawInternalToken() error {
|
||||
_, err := GetPicoclawInternalToken()
|
||||
return err
|
||||
}
|
||||
|
||||
func readPicoclawInternalToken() (string, error) {
|
||||
data, err := os.ReadFile(picoclawInternalTokenFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
package config
|
||||
|
||||
type Config struct {
|
||||
Proto string `yaml:"proto"`
|
||||
Port Port `yaml:"port"`
|
||||
Cert Cert `yaml:"cert"`
|
||||
Logger Logger `yaml:"logger"`
|
||||
Authentication string `yaml:"authentication"`
|
||||
JWT JWT `yaml:"jwt"`
|
||||
Stun string `yaml:"stun"`
|
||||
Turn Turn `yaml:"turn"`
|
||||
Proto string `yaml:"proto"`
|
||||
Host string `yaml:"host"`
|
||||
Port Port `yaml:"port"`
|
||||
Cert Cert `yaml:"cert"`
|
||||
Logger Logger `yaml:"logger"`
|
||||
Authentication string `yaml:"authentication"`
|
||||
JWT JWT `yaml:"jwt"`
|
||||
Stun string `yaml:"stun"`
|
||||
Turn Turn `yaml:"turn"`
|
||||
Security Security `yaml:"security"`
|
||||
|
||||
Hardware Hardware `yaml:"-"`
|
||||
}
|
||||
@@ -40,6 +42,11 @@ type Turn struct {
|
||||
TurnCred string `yaml:"turnCred"`
|
||||
}
|
||||
|
||||
type Security struct {
|
||||
LoginLockoutDuration int `yaml:"loginLockoutDuration"`
|
||||
LoginMaxFailures int `yaml:"loginMaxFailures"`
|
||||
}
|
||||
|
||||
type Hardware struct {
|
||||
Version HWVersion `yaml:"-"`
|
||||
GPIOReset string `yaml:"-"`
|
||||
|
||||
@@ -8,9 +8,10 @@ require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
|
||||
github.com/pion/dtls/v3 v3.0.3
|
||||
github.com/pion/dtls/v3 v3.1.2
|
||||
github.com/pion/rtp v1.8.18
|
||||
github.com/pion/webrtc/v4 v4.0.1
|
||||
github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692
|
||||
@@ -32,7 +33,6 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
@@ -46,7 +46,7 @@ require (
|
||||
github.com/pion/datachannel v1.5.9 // indirect
|
||||
github.com/pion/ice/v4 v4.0.2 // indirect
|
||||
github.com/pion/interceptor v0.1.39 // indirect
|
||||
github.com/pion/logging v0.2.3 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // indirect
|
||||
@@ -55,6 +55,7 @@ require (
|
||||
github.com/pion/srtp/v3 v3.0.4 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||
github.com/rs/cors v1.11.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
@@ -66,7 +67,7 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/wlynxg/anet v0.0.3 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
|
||||
@@ -74,14 +74,14 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
|
||||
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
|
||||
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
|
||||
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
|
||||
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
|
||||
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
|
||||
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
|
||||
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
|
||||
github.com/pion/interceptor v0.1.39 h1:Y6k0bN9Y3Lg/Wb21JBWp480tohtns8ybJ037AGr9UuA=
|
||||
github.com/pion/interceptor v0.1.39/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
@@ -100,6 +100,8 @@ github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||
github.com/pion/webrtc/v4 v4.0.1 h1:6Unwc6JzoTsjxetcAIoWH81RUM4K5dBc1BbJGcF9WVE=
|
||||
@@ -140,8 +142,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
@@ -150,8 +152,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9Og=
|
||||
github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -30,6 +28,10 @@ func main() {
|
||||
}
|
||||
|
||||
func initialize() {
|
||||
if err := config.EnsurePicoclawInternalToken(); err != nil {
|
||||
log.Fatalf("failed to initialize picoclaw internal token: %v", err)
|
||||
}
|
||||
|
||||
logger.Init()
|
||||
|
||||
// init screen parameters
|
||||
@@ -69,46 +71,56 @@ func run() {
|
||||
|
||||
router.Init(r)
|
||||
|
||||
httpAddr := fmt.Sprintf(":%d", conf.Port.Http)
|
||||
httpsAddr := fmt.Sprintf(":%d", conf.Port.Https)
|
||||
httpAddr := utils.ListenAddr(conf.Host, strconv.Itoa(conf.Port.Http))
|
||||
loopbackHTTPAddr := utils.ListenAddr("127.0.0.1", strconv.Itoa(conf.Port.Http))
|
||||
needsLoopbackHTTP := utils.NeedsDedicatedLoopbackListener(conf.Host)
|
||||
|
||||
if conf.Proto == "https" {
|
||||
httpsPortStr := strconv.Itoa(conf.Port.Https)
|
||||
|
||||
go func() {
|
||||
r.Use(middleware.Tls())
|
||||
err := r.RunTLS(httpsAddr, conf.Cert.Crt, conf.Cert.Key)
|
||||
err := r.RunTLS(utils.ListenAddr(conf.Host, httpsPortStr), conf.Cert.Crt, conf.Cert.Key)
|
||||
if err != nil {
|
||||
panic("start https server failed")
|
||||
}
|
||||
}()
|
||||
|
||||
runRedirect(httpAddr, httpsAddr)
|
||||
if needsLoopbackHTTP {
|
||||
go func() {
|
||||
if err := middleware.ListenAndServeLoopbackHTTPRedirect(
|
||||
loopbackHTTPAddr,
|
||||
httpsPortStr,
|
||||
r,
|
||||
router.LoopbackHTTPAllowedPaths()...,
|
||||
); err != nil {
|
||||
panic("start loopback http server failed")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err := middleware.ListenAndServeLoopbackHTTPRedirect(
|
||||
httpAddr,
|
||||
httpsPortStr,
|
||||
r,
|
||||
router.LoopbackHTTPAllowedPaths()...,
|
||||
); err != nil {
|
||||
panic("start http server failed")
|
||||
}
|
||||
} else {
|
||||
if needsLoopbackHTTP {
|
||||
go func() {
|
||||
if err := r.Run(loopbackHTTPAddr); err != nil {
|
||||
panic("start loopback http server failed")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err := r.Run(httpAddr); err != nil {
|
||||
panic("start http server failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runRedirect(httpPort string, httpsPort string) {
|
||||
err := http.ListenAndServe(httpPort, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
host := req.Host
|
||||
if strings.Contains(host, httpPort) {
|
||||
host = strings.Split(host, httpPort)[0]
|
||||
}
|
||||
|
||||
targetURL := "https://" + host + req.URL.String()
|
||||
if httpsPort != ":443" {
|
||||
targetURL = "https://" + host + httpsPort + req.URL.String()
|
||||
}
|
||||
|
||||
http.Redirect(w, req, targetURL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
|
||||
if err != nil {
|
||||
panic("start http server failed")
|
||||
}
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
common.GetKvmVision().Close()
|
||||
}
|
||||
|
||||
@@ -18,27 +18,58 @@ type Token struct {
|
||||
|
||||
func CheckToken() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
conf := config.GetInstance()
|
||||
|
||||
if conf.Authentication == "disable" {
|
||||
if allowByToken(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := c.Cookie("nano-kvm-token")
|
||||
if err == nil {
|
||||
_, err = ParseJWT(cookie)
|
||||
if err == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
abortUnauthorized(c)
|
||||
}
|
||||
}
|
||||
|
||||
func CheckLoopbackInternalToken() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if allowByLoopbackInternalToken(c.Request) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusUnauthorized, "unauthorized")
|
||||
c.Abort()
|
||||
abortUnauthorized(c)
|
||||
}
|
||||
}
|
||||
|
||||
func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if allowByToken(c) || allowByLoopbackInternalToken(c.Request) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
abortUnauthorized(c)
|
||||
}
|
||||
}
|
||||
|
||||
func allowByToken(c *gin.Context) bool {
|
||||
conf := config.GetInstance()
|
||||
|
||||
if conf.Authentication == "disable" {
|
||||
return true
|
||||
}
|
||||
|
||||
cookie, err := c.Cookie("nano-kvm-token")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = ParseJWT(cookie)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func abortUnauthorized(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, "unauthorized")
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func GenerateJWT(username string) (string, error) {
|
||||
conf := config.GetInstance()
|
||||
|
||||
|
||||
94
server/middleware/loopback_http.go
Normal file
94
server/middleware/loopback_http.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"NanoKVM-Server/config"
|
||||
)
|
||||
|
||||
func ListenAndServeLoopbackHTTPRedirect(
|
||||
httpAddr string,
|
||||
httpsPort string,
|
||||
handler http.Handler,
|
||||
allowedPaths ...string,
|
||||
) error {
|
||||
allowlist := make(map[string]struct{}, len(allowedPaths))
|
||||
for _, path := range allowedPaths {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
continue
|
||||
}
|
||||
allowlist[path] = struct{}{}
|
||||
}
|
||||
|
||||
return http.ListenAndServe(httpAddr, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if isLoopbackAllowedPath(req, allowlist) {
|
||||
if hasValidLoopbackHTTPToken(req) {
|
||||
handler.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, req, "https://"+redirectHost(req.Host, httpsPort)+req.URL.RequestURI(), http.StatusTemporaryRedirect)
|
||||
}))
|
||||
}
|
||||
|
||||
func redirectHost(requestHost string, httpsPort string) string {
|
||||
host := requestHost
|
||||
if h, _, err := net.SplitHostPort(requestHost); err == nil {
|
||||
host = h
|
||||
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||
}
|
||||
|
||||
if httpsPort != "443" {
|
||||
return net.JoinHostPort(host, httpsPort)
|
||||
}
|
||||
|
||||
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") && !strings.HasSuffix(host, "]") {
|
||||
return "[" + host + "]"
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
func allowByLoopbackInternalToken(req *http.Request) bool {
|
||||
return req != nil && isLoopbackRemote(req.RemoteAddr) && hasValidLoopbackHTTPToken(req)
|
||||
}
|
||||
|
||||
func isLoopbackAllowedPath(req *http.Request, allowedPaths map[string]struct{}) bool {
|
||||
if !allowByLoopbackInternalToken(req) {
|
||||
return false
|
||||
}
|
||||
|
||||
_, allowed := allowedPaths[req.URL.Path]
|
||||
return allowed
|
||||
}
|
||||
|
||||
func hasValidLoopbackHTTPToken(req *http.Request) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
token, err := config.GetPicoclawInternalToken()
|
||||
if err != nil || token == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
provided := req.Header.Get(config.PicoclawInternalTokenHeader)
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
|
||||
}
|
||||
|
||||
func isLoopbackRemote(remoteAddr string) bool {
|
||||
host := strings.TrimSpace(remoteAddr)
|
||||
if parsedHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = parsedHost
|
||||
}
|
||||
|
||||
ip := net.ParseIP(strings.Trim(host, "[]"))
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
@@ -29,3 +29,11 @@ type AddShortcutReq struct {
|
||||
type DeleteShortcutReq struct {
|
||||
ID string `validate:"required"`
|
||||
}
|
||||
|
||||
type SetLeaderKeyReq struct {
|
||||
Key string `validate:"omitempty"`
|
||||
}
|
||||
|
||||
type GetLeaderKeyRsp struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
@@ -28,3 +28,25 @@ type ConnectWifiReq struct {
|
||||
Ssid string `validate:"required"`
|
||||
Password string `valid:"required"`
|
||||
}
|
||||
|
||||
type GetDNSRsp struct {
|
||||
Mode string `json:"mode"`
|
||||
Servers []string `json:"servers"`
|
||||
Effective []string `json:"effective"`
|
||||
DHCP []string `json:"dhcp"`
|
||||
Info DNSInfo `json:"info"`
|
||||
}
|
||||
|
||||
type SetDNSReq struct {
|
||||
Mode string `json:"mode" validate:"required,oneof=manual dhcp"`
|
||||
Servers []string `json:"servers"`
|
||||
}
|
||||
|
||||
type DNSInfo struct {
|
||||
Interface string `json:"interface"`
|
||||
Type string `json:"type"`
|
||||
Address string `json:"address"`
|
||||
SubnetMask string `json:"subnetMask"`
|
||||
Gateway string `json:"gateway"`
|
||||
SearchDomains []string `json:"searchDomains"`
|
||||
}
|
||||
|
||||
@@ -7,9 +7,16 @@ import (
|
||||
"NanoKVM-Server/service/hid"
|
||||
)
|
||||
|
||||
const internalUSBRecoverPath = "/api/internal/usb/recover"
|
||||
|
||||
func HIDLoopbackHTTPAllowedPaths() []string {
|
||||
return []string{internalUSBRecoverPath}
|
||||
}
|
||||
|
||||
func hidRouter(r *gin.Engine) {
|
||||
service := hid.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
localAPI := r.Group("/api/internal").Use(middleware.CheckLoopbackInternalToken())
|
||||
|
||||
api.POST("/hid/paste", service.Paste) // paste
|
||||
|
||||
@@ -17,7 +24,12 @@ func hidRouter(r *gin.Engine) {
|
||||
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
|
||||
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
|
||||
|
||||
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key
|
||||
api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key
|
||||
|
||||
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
|
||||
|
||||
localAPI.POST("/usb/recover", service.RecoverUSB)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
func networkRouter(r *gin.Engine) {
|
||||
service := network.NewService()
|
||||
|
||||
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
|
||||
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
|
||||
r.POST("/api/network/wifi/verify", service.VerifyApLogin) // verify ap login
|
||||
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
|
||||
@@ -22,4 +23,7 @@ func networkRouter(r *gin.Engine) {
|
||||
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
|
||||
|
||||
api.GET("/network/dns", service.GetDNS) // get DNS configuration
|
||||
api.POST("/network/dns", service.SetDNS) // set DNS configuration
|
||||
}
|
||||
|
||||
64
server/router/picoclaw.go
Normal file
64
server/router/picoclaw.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
)
|
||||
|
||||
const (
|
||||
picoclawBasePath = "/api/picoclaw"
|
||||
picoclawModelConfigPath = "/model/config"
|
||||
picoclawAgentProfilePath = "/agent/profile"
|
||||
picoclawSessionsPath = "/sessions"
|
||||
picoclawSessionByIDPath = "/sessions/:id"
|
||||
picoclawRuntimeStatusPath = "/runtime/status"
|
||||
picoclawRuntimeSessionPath = "/runtime/session"
|
||||
picoclawRuntimeInstallPath = "/runtime/install"
|
||||
picoclawRuntimeUninstallPath = "/runtime/uninstall"
|
||||
picoclawRuntimeStartPath = "/runtime/start"
|
||||
picoclawRuntimeStopPath = "/runtime/stop"
|
||||
picoclawGatewayWSPath = "/gateway/ws"
|
||||
picoclawScreenshotPath = "/screenshot"
|
||||
picoclawActionsPath = "/actions"
|
||||
picoclawMCPPath = "/mcp"
|
||||
picoclawLoadImagePath = "/load-image"
|
||||
)
|
||||
|
||||
var picoclawLoopbackHTTPAllowedPaths = []string{
|
||||
picoclawBasePath + picoclawMCPPath,
|
||||
picoclawBasePath + picoclawRuntimeSessionPath,
|
||||
picoclawBasePath + picoclawScreenshotPath,
|
||||
picoclawBasePath + picoclawActionsPath,
|
||||
picoclawBasePath + picoclawLoadImagePath,
|
||||
}
|
||||
|
||||
func PicoclawLoopbackHTTPAllowedPaths() []string {
|
||||
return append([]string(nil), picoclawLoopbackHTTPAllowedPaths...)
|
||||
}
|
||||
|
||||
func picoclawRouter(r *gin.Engine) {
|
||||
service := picoclaw.NewService()
|
||||
frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken())
|
||||
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())
|
||||
|
||||
localAPI.GET(picoclawScreenshotPath, service.Screenshot)
|
||||
localAPI.POST(picoclawActionsPath, service.Actions)
|
||||
localAPI.POST(picoclawMCPPath, service.MCPHandler)
|
||||
localAPI.POST(picoclawLoadImagePath, service.LoadImage)
|
||||
localAPI.GET(picoclawRuntimeSessionPath, service.GetRuntimeSession)
|
||||
|
||||
frontendAPI.POST(picoclawModelConfigPath, service.UpdateModelConfig)
|
||||
frontendAPI.POST(picoclawAgentProfilePath, service.UpdateAgentProfile)
|
||||
frontendAPI.GET(picoclawSessionsPath, service.ListSessions)
|
||||
frontendAPI.GET(picoclawSessionByIDPath, service.GetSession)
|
||||
frontendAPI.DELETE(picoclawSessionByIDPath, service.DeleteSession)
|
||||
frontendAPI.GET(picoclawRuntimeStatusPath, service.GetRuntimeStatus)
|
||||
frontendAPI.DELETE(picoclawRuntimeSessionPath, service.ReleaseRuntimeSession)
|
||||
frontendAPI.POST(picoclawRuntimeInstallPath, service.InstallRuntime)
|
||||
frontendAPI.POST(picoclawRuntimeUninstallPath, service.UninstallRuntime)
|
||||
frontendAPI.POST(picoclawRuntimeStartPath, service.StartRuntime)
|
||||
frontendAPI.POST(picoclawRuntimeStopPath, service.StopRuntime)
|
||||
frontendAPI.GET(picoclawGatewayWSPath, service.ConnectGateway)
|
||||
}
|
||||
@@ -36,7 +36,14 @@ func server(r *gin.Engine) {
|
||||
storageRouter(r)
|
||||
networkRouter(r)
|
||||
hidRouter(r)
|
||||
picoclawRouter(r)
|
||||
wsRouter(r)
|
||||
downloadRouter(r)
|
||||
extensionsRouter(r)
|
||||
}
|
||||
|
||||
func LoopbackHTTPAllowedPaths() []string {
|
||||
paths := PicoclawLoopbackHTTPAllowedPaths()
|
||||
paths = append(paths, HIDLoopbackHTTPAllowedPaths()...)
|
||||
return paths
|
||||
}
|
||||
|
||||
147
server/service/auth/brute_force.go
Normal file
147
server/service/auth/brute_force.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type loginAttempt struct {
|
||||
failures int
|
||||
lastFailed time.Time
|
||||
lockoutEnd time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
maxLoginAttemptsRecords = 3000
|
||||
cleanupInterval = 6 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
loginAttempts = make(map[string]*loginAttempt)
|
||||
loginMutex sync.Mutex
|
||||
cleanupOnce sync.Once
|
||||
)
|
||||
|
||||
// startCleanupRoutine starts a background routine to clean up memory
|
||||
func startCleanupRoutine() {
|
||||
conf := config.GetInstance()
|
||||
if conf.Security.LoginLockoutDuration <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
for range ticker.C {
|
||||
loginMutex.Lock()
|
||||
now := time.Now()
|
||||
for ip, attempt := range loginAttempts {
|
||||
// Cleanup rules: if it has been locked and the lockout time has passed,
|
||||
// or (although not locked) it has been 30 minutes since the last failure,
|
||||
// remove this record
|
||||
if (!attempt.lockoutEnd.IsZero() && now.After(attempt.lockoutEnd)) ||
|
||||
(attempt.lockoutEnd.IsZero() && now.Sub(attempt.lastFailed) > 30*time.Minute) {
|
||||
delete(loginAttempts, ip)
|
||||
}
|
||||
}
|
||||
loginMutex.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// GetClientIP gets a reliable real IP
|
||||
func GetClientIP(c *gin.Context) string {
|
||||
ip := c.RemoteIP()
|
||||
if ip == "" {
|
||||
ip = c.ClientIP()
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// CheckLoginAttempt checks if a login attempt is allowed based on brute-force protection rules.
|
||||
// Returning true means the IP/System is locked out, and an error string and error code are returned.
|
||||
func CheckLoginAttempt(clientIP string) (bool, int, string) {
|
||||
conf := config.GetInstance()
|
||||
if conf.Security.LoginLockoutDuration <= 0 {
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
cleanupOnce.Do(startCleanupRoutine)
|
||||
|
||||
loginMutex.Lock()
|
||||
defer loginMutex.Unlock()
|
||||
|
||||
if attempt, exists := loginAttempts[clientIP]; exists {
|
||||
if time.Now().Before(attempt.lockoutEnd) {
|
||||
log.Debugf("login blocked for IP %s: account locked due to too many failed attempts (until %s)", clientIP, attempt.lockoutEnd)
|
||||
return true, -5, "Account locked due to too many failed attempts, please try again later"
|
||||
}
|
||||
|
||||
// If lockout has elapsed, then we reset the failures and lockoutEnd.
|
||||
if !attempt.lockoutEnd.IsZero() {
|
||||
attempt.failures = 0
|
||||
attempt.lockoutEnd = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
// RecordLoginFailure records a failed login attempt for the given IP address.
|
||||
func RecordLoginFailure(clientIP string) (bool, int, string) {
|
||||
conf := config.GetInstance()
|
||||
if conf.Security.LoginLockoutDuration <= 0 {
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
cleanupOnce.Do(startCleanupRoutine)
|
||||
|
||||
loginMutex.Lock()
|
||||
defer loginMutex.Unlock()
|
||||
|
||||
attempt, exists := loginAttempts[clientIP]
|
||||
if !exists {
|
||||
// When the record pool is full, clear the records instead of global lockout to prevent DDoS
|
||||
if len(loginAttempts) >= maxLoginAttemptsRecords {
|
||||
log.Warn("Login attempt records reached maximum limit, clearing records to prevent memory overflow")
|
||||
loginAttempts = make(map[string]*loginAttempt)
|
||||
}
|
||||
attempt = &loginAttempt{}
|
||||
loginAttempts[clientIP] = attempt
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// Failure time window: if it has been a long time since the last failure
|
||||
// (e.g., beyond the lockoutDuration window), reset the failure count
|
||||
if !attempt.lastFailed.IsZero() && now.Sub(attempt.lastFailed) > time.Duration(conf.Security.LoginLockoutDuration)*time.Second {
|
||||
attempt.failures = 0
|
||||
}
|
||||
|
||||
attempt.failures++
|
||||
attempt.lastFailed = now
|
||||
|
||||
// Reach the failure limit, lock out
|
||||
if attempt.failures >= conf.Security.LoginMaxFailures {
|
||||
attempt.lockoutEnd = now.Add(time.Duration(conf.Security.LoginLockoutDuration) * time.Second)
|
||||
log.Debugf("login failures reached threshold for IP %s, locking out until %s", clientIP, attempt.lockoutEnd)
|
||||
}
|
||||
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
// ClearLoginAttempt clears the failed login attempt record for an IP upon successful login.
|
||||
func ClearLoginAttempt(clientIP string) {
|
||||
conf := config.GetInstance()
|
||||
if conf.Security.LoginLockoutDuration <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
loginMutex.Lock()
|
||||
defer loginMutex.Unlock()
|
||||
|
||||
delete(loginAttempts, clientIP)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/config"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/proto"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -23,6 +24,13 @@ func (s *Service) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := GetClientIP(c)
|
||||
if locked, code, msg := CheckLoginAttempt(clientIP); locked {
|
||||
time.Sleep(3 * time.Second)
|
||||
rsp.ErrRsp(c, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
time.Sleep(3 * time.Second)
|
||||
rsp.ErrRsp(c, -1, "invalid parameters")
|
||||
@@ -31,10 +39,18 @@ func (s *Service) Login(c *gin.Context) {
|
||||
|
||||
if ok := CompareAccount(req.Username, req.Password); !ok {
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
if locked, code, msg := RecordLoginFailure(clientIP); locked {
|
||||
rsp.ErrRsp(c, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.ErrRsp(c, -2, "invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
ClearLoginAttempt(clientIP)
|
||||
|
||||
token, err := middleware.GenerateJWT(req.Username)
|
||||
if err != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
@@ -20,11 +20,13 @@ const (
|
||||
)
|
||||
|
||||
var StateMap = map[string]proto.TailscaleState{
|
||||
"NoState": proto.TailscaleNotRunning,
|
||||
"Starting": proto.TailscaleNotRunning,
|
||||
"NeedsLogin": proto.TailscaleNotLogin,
|
||||
"Running": proto.TailscaleRunning,
|
||||
"Stopped": proto.TailscaleStopped,
|
||||
"NoState": proto.TailscaleNotRunning,
|
||||
"Starting": proto.TailscaleNotRunning,
|
||||
"NeedsLogin": proto.TailscaleNotLogin,
|
||||
"NeedsMachineAuth": proto.TailscaleNotLogin,
|
||||
"InUseOtherUser": proto.TailscaleNotLogin,
|
||||
"Running": proto.TailscaleRunning,
|
||||
"Stopped": proto.TailscaleStopped,
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
|
||||
@@ -2,8 +2,11 @@ package hid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -23,6 +26,24 @@ const (
|
||||
HID2 = "/dev/hidg2" // Touchpad (Absolute Mode)
|
||||
)
|
||||
|
||||
const (
|
||||
hidWriteTimeout = 50 * time.Millisecond
|
||||
hidWriteRetryDelay = time.Millisecond
|
||||
hidReopenTimeout = 2 * time.Second
|
||||
hidReopenRetryDelay = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
type hidWriter interface {
|
||||
Write([]byte) (int, error)
|
||||
}
|
||||
|
||||
type hidDevice struct {
|
||||
path string
|
||||
mu *sync.Mutex
|
||||
get func() *os.File
|
||||
set func(*os.File)
|
||||
}
|
||||
|
||||
var (
|
||||
hid *Hid
|
||||
hidOnce sync.Once
|
||||
@@ -45,33 +66,168 @@ func (h *Hid) Unlock() {
|
||||
h.mouseMutex.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hid) OpenNoLock() {
|
||||
var err error
|
||||
h.CloseNoLock()
|
||||
|
||||
h.g0, err = os.OpenFile(HID0, os.O_WRONLY, 0o666)
|
||||
if err != nil {
|
||||
log.Errorf("open %s failed: %s", HID0, err)
|
||||
}
|
||||
|
||||
h.g1, err = os.OpenFile(HID1, os.O_WRONLY, 0o666)
|
||||
if err != nil {
|
||||
log.Errorf("open %s failed: %s", HID1, err)
|
||||
}
|
||||
|
||||
h.g2, err = os.OpenFile(HID2, os.O_WRONLY, 0o666)
|
||||
if err != nil {
|
||||
log.Errorf("open %s failed: %s", HID2, err)
|
||||
func (h *Hid) keyboardDevice(path string) hidDevice {
|
||||
return hidDevice{
|
||||
path: path,
|
||||
mu: &h.kbMutex,
|
||||
get: func() *os.File {
|
||||
return h.g0
|
||||
},
|
||||
set: func(file *os.File) {
|
||||
h.g0 = file
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) CloseNoLock() {
|
||||
for _, file := range []*os.File{h.g0, h.g1, h.g2} {
|
||||
if file != nil {
|
||||
_ = file.Sync()
|
||||
_ = file.Close()
|
||||
func (h *Hid) relativeMouseDevice(path string) hidDevice {
|
||||
return hidDevice{
|
||||
path: path,
|
||||
mu: &h.mouseMutex,
|
||||
get: func() *os.File {
|
||||
return h.g1
|
||||
},
|
||||
set: func(file *os.File) {
|
||||
h.g1 = file
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) absoluteMouseDevice(path string) hidDevice {
|
||||
return hidDevice{
|
||||
path: path,
|
||||
mu: &h.mouseMutex,
|
||||
get: func() *os.File {
|
||||
return h.g2
|
||||
},
|
||||
set: func(file *os.File) {
|
||||
h.g2 = file
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) devices() []hidDevice {
|
||||
return []hidDevice{
|
||||
h.keyboardDevice(HID0),
|
||||
h.relativeMouseDevice(HID1),
|
||||
h.absoluteMouseDevice(HID2),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) OpenNoLock() error {
|
||||
h.CloseNoLock()
|
||||
|
||||
var errs []error
|
||||
for _, device := range h.devices() {
|
||||
if err := h.openDeviceNoLock(device); err != nil {
|
||||
log.Errorf("open %s failed: %s", device.path, err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (h *Hid) OpenNoLockWithRetry(timeout, delay time.Duration) error {
|
||||
return openNoLockWithRetry(h.OpenNoLock, timeout, delay)
|
||||
}
|
||||
|
||||
func openNoLockWithRetry(open func() error, timeout, delay time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
return open()
|
||||
}
|
||||
if delay <= 0 {
|
||||
delay = hidReopenRetryDelay
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for {
|
||||
if err := open(); err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
if remaining > delay {
|
||||
remaining = delay
|
||||
}
|
||||
time.Sleep(remaining)
|
||||
}
|
||||
|
||||
return fmt.Errorf("open HID devices within %s: %w", timeout, lastErr)
|
||||
}
|
||||
|
||||
func (h *Hid) CloseNoLock() {
|
||||
for _, device := range h.devices() {
|
||||
h.closeDeviceNoLock(device)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) openDeviceNoLock(device hidDevice) error {
|
||||
if device.get() != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(device.path, os.O_WRONLY|syscall.O_NONBLOCK, 0o666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", device.path, err)
|
||||
}
|
||||
|
||||
device.set(file)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hid) closeDeviceNoLock(device hidDevice) {
|
||||
file := device.get()
|
||||
if file == nil {
|
||||
return
|
||||
}
|
||||
|
||||
device.set(nil)
|
||||
if err := file.Close(); err != nil {
|
||||
log.Debugf("close %s failed: %s", device.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeWithTimeout bounds how long callers hold HID locks when writing to a
|
||||
// nonblocking descriptor. EAGAIN means the host is not accepting HID reports
|
||||
// yet, so retry until the caller's deadline expires.
|
||||
func writeWithTimeout(writer hidWriter, data []byte, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for {
|
||||
n, err := writer.Write(data)
|
||||
if err == nil {
|
||||
if n != len(data) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if n != 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
if !isRetryableWriteError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
remaining := time.Until(deadline)
|
||||
if timeout <= 0 || remaining <= 0 {
|
||||
return os.ErrDeadlineExceeded
|
||||
}
|
||||
if remaining > hidWriteRetryDelay {
|
||||
remaining = hidWriteRetryDelay
|
||||
}
|
||||
time.Sleep(remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func isRetryableWriteError(err error) bool {
|
||||
return errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EWOULDBLOCK)
|
||||
}
|
||||
|
||||
func (h *Hid) Open() {
|
||||
@@ -80,8 +236,6 @@ func (h *Hid) Open() {
|
||||
h.mouseMutex.Lock()
|
||||
defer h.mouseMutex.Unlock()
|
||||
|
||||
h.CloseNoLock()
|
||||
|
||||
h.OpenNoLock()
|
||||
}
|
||||
|
||||
@@ -95,73 +249,55 @@ 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 {
|
||||
switch {
|
||||
case errors.Is(err, os.ErrClosed):
|
||||
log.Errorf("hid already closed, reopen it...")
|
||||
h.OpenNoLock()
|
||||
case errors.Is(err, os.ErrDeadlineExceeded):
|
||||
log.Debugf("write to %s timeout", HID0)
|
||||
default:
|
||||
log.Errorf("write to %s failed: %s", HID0, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("write to %s: %v", HID0, data)
|
||||
h.writeHIDReport(h.keyboardDevice(HID0), data)
|
||||
}
|
||||
|
||||
func (h *Hid) WriteHid1(data []byte) {
|
||||
deadline := time.Now().Add(8 * time.Millisecond)
|
||||
|
||||
h.mouseMutex.Lock()
|
||||
_ = h.g1.SetWriteDeadline(deadline)
|
||||
_, err := h.g1.Write(data)
|
||||
h.mouseMutex.Unlock()
|
||||
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, os.ErrClosed):
|
||||
log.Errorf("hid already closed, reopen it...")
|
||||
h.OpenNoLock()
|
||||
case errors.Is(err, os.ErrDeadlineExceeded):
|
||||
log.Debugf("write to %s timeout", HID1)
|
||||
default:
|
||||
log.Errorf("write to %s failed: %s", HID1, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("write to %s: %v", HID1, data)
|
||||
h.writeHIDReport(h.relativeMouseDevice(HID1), data)
|
||||
}
|
||||
|
||||
func (h *Hid) WriteHid2(data []byte) {
|
||||
deadline := time.Now().Add(8 * time.Millisecond)
|
||||
h.writeHIDReport(h.absoluteMouseDevice(HID2), data)
|
||||
}
|
||||
|
||||
h.mouseMutex.Lock()
|
||||
_ = h.g2.SetWriteDeadline(deadline)
|
||||
_, err := h.g2.Write(data)
|
||||
h.mouseMutex.Unlock()
|
||||
func (h *Hid) writeHIDReport(device hidDevice, data []byte) bool {
|
||||
if err := h.writeHID(device, data); err != nil {
|
||||
log.Errorf("write to %s failed: %s", device.path, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, os.ErrClosed):
|
||||
log.Errorf("hid already closed, reopen it...")
|
||||
h.OpenNoLock()
|
||||
case errors.Is(err, os.ErrDeadlineExceeded):
|
||||
log.Debugf("write to %s timeout", HID2)
|
||||
default:
|
||||
log.Errorf("write to %s failed: %s", HID2, err)
|
||||
}
|
||||
return
|
||||
func (h *Hid) writeHID(device hidDevice, data []byte) error {
|
||||
device.mu.Lock()
|
||||
defer device.mu.Unlock()
|
||||
|
||||
if err := h.openDeviceNoLock(device); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("write to %s: %v", HID2, data)
|
||||
file := device.get()
|
||||
if file == nil {
|
||||
return fmt.Errorf("%s: hid handle is nil", device.path)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(hidWriteTimeout)
|
||||
if err := file.SetWriteDeadline(deadline); err != nil {
|
||||
log.Debugf("set write deadline for %s failed: %s", device.path, err)
|
||||
}
|
||||
|
||||
if err := writeWithTimeout(file, data, hidWriteTimeout); err != nil {
|
||||
h.closeDeviceNoLock(device)
|
||||
switch {
|
||||
case errors.Is(err, os.ErrClosed):
|
||||
return fmt.Errorf("hid already closed: %w", err)
|
||||
case errors.Is(err, os.ErrDeadlineExceeded):
|
||||
return fmt.Errorf("timeout after %s: %w", hidWriteTimeout, err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("write to %s: %v", device.path, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,12 +5,46 @@ import (
|
||||
)
|
||||
|
||||
func (h *Hid) Keyboard(queue <-chan []byte) {
|
||||
h.keyboard(queue, HID0)
|
||||
}
|
||||
|
||||
func (h *Hid) keyboard(queue <-chan []byte, path string) {
|
||||
defer h.releaseKeyboard(path)
|
||||
|
||||
for event := range queue {
|
||||
if len(event) != 8 {
|
||||
log.Debugf("invalid keyboard event: %v", event)
|
||||
continue
|
||||
}
|
||||
|
||||
h.WriteHid0(event)
|
||||
if !h.writeHIDReport(h.keyboardDevice(path), event) {
|
||||
if dropped := drainHIDQueue(queue); dropped > 0 {
|
||||
log.Debugf("dropped %d stale keyboard HID reports after write failure", dropped)
|
||||
}
|
||||
h.releaseKeyboard(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) releaseKeyboard(path string) {
|
||||
h.writeHIDReport(h.keyboardDevice(path), keyboardReleaseReport())
|
||||
}
|
||||
|
||||
func keyboardReleaseReport() []byte {
|
||||
return []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
}
|
||||
|
||||
func drainHIDQueue(queue <-chan []byte) int {
|
||||
dropped := 0
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-queue:
|
||||
if !ok {
|
||||
return dropped
|
||||
}
|
||||
dropped++
|
||||
default:
|
||||
return dropped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
63
server/service/hid/leader_key.go
Normal file
63
server/service/hid/leader_key.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
LeaderKeyFile = "/etc/kvm/leader-key"
|
||||
)
|
||||
|
||||
func (s *Service) SetLeaderKey(c *gin.Context) {
|
||||
var req proto.SetLeaderKeyReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Key == "" {
|
||||
err := os.Remove(LeaderKeyFile)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
rsp.ErrRsp(c, -2, "reset failed")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err := os.WriteFile(LeaderKeyFile, []byte(req.Key), 0o644)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, "write failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("set leader key: %s", req.Key)
|
||||
}
|
||||
|
||||
func (s *Service) GetLeaderKey(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
data, err := os.ReadFile(LeaderKeyFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
|
||||
Key: "",
|
||||
})
|
||||
return
|
||||
}
|
||||
rsp.ErrRsp(c, -1, "read leader key failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
|
||||
Key: strings.Replace(string(data), "\n", "", -1),
|
||||
})
|
||||
|
||||
log.Debugf("get leader key successful")
|
||||
}
|
||||
@@ -5,14 +5,67 @@ import (
|
||||
)
|
||||
|
||||
func (h *Hid) Mouse(queue <-chan []byte) {
|
||||
h.mouse(queue, HID1, HID2)
|
||||
}
|
||||
|
||||
func (h *Hid) mouse(queue <-chan []byte, relativePath string, absolutePath string) {
|
||||
defer h.releaseRelativeMouse(relativePath)
|
||||
|
||||
absoluteButtonsActive := false
|
||||
var absoluteReleaseReport []byte
|
||||
defer func() {
|
||||
if absoluteButtonsActive {
|
||||
h.releaseAbsoluteMouse(absolutePath, absoluteReleaseReport)
|
||||
}
|
||||
}()
|
||||
|
||||
for event := range queue {
|
||||
switch len(event) {
|
||||
case 4:
|
||||
h.WriteHid1(event)
|
||||
if !h.writeHIDReport(h.relativeMouseDevice(relativePath), event) {
|
||||
if dropped := drainHIDQueue(queue); dropped > 0 {
|
||||
log.Debugf("dropped %d stale mouse HID reports after relative write failure", dropped)
|
||||
}
|
||||
h.releaseRelativeMouse(relativePath)
|
||||
}
|
||||
case 6:
|
||||
h.WriteHid2(event)
|
||||
if !h.writeHIDReport(h.absoluteMouseDevice(absolutePath), event) {
|
||||
if dropped := drainHIDQueue(queue); dropped > 0 {
|
||||
log.Debugf("dropped %d stale mouse HID reports after absolute write failure", dropped)
|
||||
}
|
||||
if absoluteButtonsActive {
|
||||
if h.releaseAbsoluteMouse(absolutePath, absoluteReleaseReport) {
|
||||
absoluteButtonsActive = false
|
||||
}
|
||||
} else if event[0] != 0 {
|
||||
h.releaseAbsoluteMouse(absolutePath, absoluteMouseReleaseReport(event))
|
||||
}
|
||||
continue
|
||||
}
|
||||
absoluteReleaseReport = absoluteMouseReleaseReport(event)
|
||||
absoluteButtonsActive = event[0] != 0
|
||||
default:
|
||||
log.Debugf("invalid mouse event: %v", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) releaseRelativeMouse(path string) {
|
||||
h.writeHIDReport(h.relativeMouseDevice(path), relativeMouseReleaseReport())
|
||||
}
|
||||
|
||||
func (h *Hid) releaseAbsoluteMouse(path string, report []byte) bool {
|
||||
return h.writeHIDReport(h.absoluteMouseDevice(path), report)
|
||||
}
|
||||
|
||||
func relativeMouseReleaseReport() []byte {
|
||||
return []byte{0x00, 0x00, 0x00, 0x00}
|
||||
}
|
||||
|
||||
func absoluteMouseReleaseReport(positionReport []byte) []byte {
|
||||
report := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
if len(positionReport) >= 5 {
|
||||
copy(report[1:5], positionReport[1:5])
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
@@ -83,6 +83,81 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
|
||||
m['\u00B2'] = Char{0x40, 31} // ²
|
||||
m['\u00B3'] = Char{0x40, 32} // ³
|
||||
|
||||
case "fr":
|
||||
// French AZERTY layout
|
||||
// Letters: a↔q swap, z↔w swap, m moved to physical ; position
|
||||
m['a'] = Char{0, 20} // a is at physical Q key (HID 20)
|
||||
m['A'] = Char{2, 20}
|
||||
m['q'] = Char{0, 4} // q is at physical A key (HID 4)
|
||||
m['Q'] = Char{2, 4}
|
||||
m['z'] = Char{0, 26} // z is at physical W key (HID 26)
|
||||
m['Z'] = Char{2, 26}
|
||||
m['w'] = Char{0, 29} // w is at physical Z key (HID 29)
|
||||
m['W'] = Char{2, 29}
|
||||
m['m'] = Char{0, 51} // m is at physical ; key (HID 51)
|
||||
m['M'] = Char{2, 51}
|
||||
|
||||
// Numbers require Shift on AZERTY
|
||||
m['1'] = Char{2, 30}
|
||||
m['2'] = Char{2, 31}
|
||||
m['3'] = Char{2, 32}
|
||||
m['4'] = Char{2, 33}
|
||||
m['5'] = Char{2, 34}
|
||||
m['6'] = Char{2, 35}
|
||||
m['7'] = Char{2, 36}
|
||||
m['8'] = Char{2, 37}
|
||||
m['9'] = Char{2, 38}
|
||||
m['0'] = Char{2, 39}
|
||||
|
||||
// Unshifted number row → French/special characters
|
||||
m['&'] = Char{0, 30} // & at physical key 1
|
||||
m['\u00E9'] = Char{0, 31} // é at physical key 2
|
||||
m['"'] = Char{0, 32} // " at physical key 3
|
||||
m['\''] = Char{0, 33} // ' at physical key 4
|
||||
m['('] = Char{0, 34} // ( at physical key 5
|
||||
m['-'] = Char{0, 35} // - at physical key 6
|
||||
m['\u00E8'] = Char{0, 36} // è at physical key 7
|
||||
m['_'] = Char{0, 37} // _ at physical key 8
|
||||
m['\u00E7'] = Char{0, 38} // ç at physical key 9
|
||||
m['\u00E0'] = Char{0, 39} // à at physical key 0
|
||||
|
||||
// Physical - key (HID 45) → ) on AZERTY
|
||||
m[')'] = Char{0, 45} // ) at physical - key
|
||||
m['\u00B0'] = Char{2, 45} // ° at shift+physical - key
|
||||
|
||||
// Letter-row bracket/special keys
|
||||
m['^'] = Char{0, 47} // ^ (dead) at physical [ key (HID 47)
|
||||
m['\u00A8'] = Char{2, 47} // ¨ at shift+[
|
||||
m['$'] = Char{0, 48} // $ at physical ] key (HID 48)
|
||||
m['\u00A3'] = Char{2, 48} // £ at shift+]
|
||||
m['*'] = Char{0, 49} // * at physical \ key (HID 49)
|
||||
m['\u00B5'] = Char{2, 49} // µ at shift+\
|
||||
m['\u00F9'] = Char{0, 52} // ù at physical ' key (HID 52)
|
||||
m['%'] = Char{2, 52} // % at shift+'
|
||||
|
||||
// Bottom row remappings
|
||||
m[','] = Char{0, 16} // , at physical M key (HID 16)
|
||||
m['?'] = Char{2, 16} // ? at shift+physical M
|
||||
m[';'] = Char{0, 54} // ; at physical , key (HID 54)
|
||||
m['.'] = Char{2, 54} // . at shift+physical ,
|
||||
m[':'] = Char{0, 55} // : at physical . key (HID 55)
|
||||
m['/'] = Char{2, 55} // / at shift+physical .
|
||||
m['!'] = Char{0, 56} // ! at physical / key (HID 56)
|
||||
m['\u00A7'] = Char{2, 56} // § at shift+physical /
|
||||
|
||||
// AltGr combinations
|
||||
m['~'] = Char{0x40, 31} // AltGr+2
|
||||
m['#'] = Char{0x40, 32} // AltGr+3
|
||||
m['{'] = Char{0x40, 33} // AltGr+4
|
||||
m['['] = Char{0x40, 34} // AltGr+5
|
||||
m['|'] = Char{0x40, 35} // AltGr+6
|
||||
m['`'] = Char{0x40, 36} // AltGr+7
|
||||
m['\\'] = Char{0x40, 37} // AltGr+8
|
||||
m[']'] = Char{0x40, 45} // AltGr+physical -
|
||||
m['}'] = Char{0x40, 46} // AltGr+=
|
||||
m['@'] = Char{0x40, 39} // AltGr+0
|
||||
m['\u20AC'] = Char{0x40, 8} // € AltGr+E
|
||||
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -131,6 +206,10 @@ func copyMap(src map[rune]Char) map[rune]Char {
|
||||
return dst
|
||||
}
|
||||
|
||||
func GetCharMap(lang string) map[rune]Char {
|
||||
return LangueSwitch(charMap, lang)
|
||||
}
|
||||
|
||||
var charMap = map[rune]Char{
|
||||
// Lowercase letters
|
||||
'a': {0, 4}, 'b': {0, 5}, 'c': {0, 6}, 'd': {0, 7}, 'e': {0, 8},
|
||||
|
||||
@@ -91,17 +91,7 @@ func (s *Service) SetHidMode(c *gin.Context) {
|
||||
func (s *Service) ResetHid(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
h := GetHid()
|
||||
h.Lock()
|
||||
h.CloseNoLock()
|
||||
defer func() {
|
||||
h.OpenNoLock()
|
||||
h.Unlock()
|
||||
}()
|
||||
|
||||
command := fmt.Sprintf("%s restart_phy", USBDevScript)
|
||||
err := exec.Command("sh", "-c", command).Run()
|
||||
if err != nil {
|
||||
if err := ResetUSBPHY(); err != nil {
|
||||
log.Errorf("failed to reset hid: %v", err)
|
||||
rsp.ErrRsp(c, -1, "failed to reset hid")
|
||||
return
|
||||
@@ -111,6 +101,37 @@ func (s *Service) ResetHid(c *gin.Context) {
|
||||
log.Debugf("reset hid success")
|
||||
}
|
||||
|
||||
func (s *Service) RecoverUSB(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
if err := ResetUSBPHY(); err != nil {
|
||||
log.Errorf("failed to recover usb: %v", err)
|
||||
rsp.ErrRsp(c, -1, "failed to recover usb")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("recover usb success")
|
||||
}
|
||||
|
||||
func ResetUSBPHY() error {
|
||||
h := GetHid()
|
||||
h.Lock()
|
||||
h.CloseNoLock()
|
||||
defer h.Unlock()
|
||||
|
||||
command := fmt.Sprintf("%s restart_phy", USBDevScript)
|
||||
if err := exec.Command("sh", "-c", command).Run(); err != nil {
|
||||
return fmt.Errorf("restart usb phy: %w", err)
|
||||
}
|
||||
|
||||
if err := h.OpenNoLockWithRetry(hidReopenTimeout, hidReopenRetryDelay); err != nil {
|
||||
return fmt.Errorf("reopen HID devices after usb phy reset: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyModeFile(srcScript string) error {
|
||||
// open the source file
|
||||
srcFile, err := os.Open(srcScript)
|
||||
|
||||
642
server/service/network/dns.go
Normal file
642
server/service/network/dns.go
Normal file
@@ -0,0 +1,642 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
dnsModeManual = "manual"
|
||||
dnsModeDHCP = "dhcp"
|
||||
|
||||
dnsConfigDir = "/etc/kvm/network"
|
||||
dnsModeFile = "/etc/kvm/network/dns.mode"
|
||||
dnsServersFile = "/etc/kvm/network/dns.servers"
|
||||
bootResolvFile = "/boot/resolv.conf"
|
||||
bootResolvBackup = "/boot/resolv.conf.manual.bak"
|
||||
etcResolvFile = "/etc/resolv.conf"
|
||||
dhcpResolvFile = "/etc/resolv.conf.dhcp"
|
||||
tmpResolvFile = "/tmp/resolv.conf"
|
||||
udhcpcHookFile = "/usr/share/udhcpc/default.script.d/99-nanokvm-dns"
|
||||
|
||||
maxDNSServers = 6
|
||||
)
|
||||
|
||||
//go:embed scripts/99-nanokvm-dns
|
||||
var udhcpcDNSHook string
|
||||
|
||||
type resolvConfig struct {
|
||||
Servers []string
|
||||
SearchDomains []string
|
||||
}
|
||||
|
||||
func (s *Service) GetDNS(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
mode := currentDNSMode()
|
||||
|
||||
servers := readManualDNSServers()
|
||||
if len(servers) == 0 {
|
||||
servers, _ = parseResolvConf(bootResolvFile)
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
servers, _ = parseResolvConf(bootResolvBackup)
|
||||
}
|
||||
|
||||
effective, _ := parseResolvConf(etcResolvFile)
|
||||
dhcpConfig, _ := readDHCPResolvConfig(canFallbackEffectiveForDHCP())
|
||||
dhcp := dhcpConfig.Servers
|
||||
info := getDNSInfo()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetDNSRsp{
|
||||
Mode: mode,
|
||||
Servers: servers,
|
||||
Effective: effective,
|
||||
DHCP: dhcp,
|
||||
Info: info,
|
||||
})
|
||||
log.Debugf("get dns config: mode=%s servers=%v effective=%v dhcp=%v info=%+v", mode, servers, effective, dhcp, info)
|
||||
}
|
||||
|
||||
func (s *Service) SetDNS(c *gin.Context) {
|
||||
var req proto.SetDNSReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Mode {
|
||||
case dnsModeManual:
|
||||
if err := setManualDNS(req.Servers); err != nil {
|
||||
log.Errorf("failed to set manual dns: %s", err)
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
case dnsModeDHCP:
|
||||
if err := setDHCPDNS(); err != nil {
|
||||
log.Errorf("failed to set dhcp dns: %s", err)
|
||||
rsp.ErrRsp(c, -3, err.Error())
|
||||
return
|
||||
}
|
||||
default:
|
||||
rsp.ErrRsp(c, -1, "invalid dns mode")
|
||||
return
|
||||
}
|
||||
|
||||
_ = exec.Command("sync").Run()
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("set dns config: mode=%s servers=%v", req.Mode, req.Servers)
|
||||
}
|
||||
|
||||
func setManualDNS(servers []string) error {
|
||||
normalized, err := validateDNSServers(servers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return fmt.Errorf("dns servers are required")
|
||||
}
|
||||
|
||||
if currentDNSMode() == dnsModeDHCP {
|
||||
if err := refreshDHCPResolvCacheFromEffective(); err != nil {
|
||||
log.Warnf("failed to refresh dhcp dns cache before switching to manual: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dnsConfigDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create dns config directory: %w", err)
|
||||
}
|
||||
|
||||
if err := writeDNSMode(dnsModeManual); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dnsServersFile, []byte(strings.Join(normalized, "\n")+"\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write dns servers: %w", err)
|
||||
}
|
||||
|
||||
if err := renderResolvConf(bootResolvFile, normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := renderResolvConf(etcResolvFile, normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return installUDHCPCDNSHook()
|
||||
}
|
||||
|
||||
func setDHCPDNS() error {
|
||||
dhcpConfig, err := readDHCPResolvConfig(canFallbackEffectiveForDHCP())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read dhcp dns: %w", err)
|
||||
}
|
||||
if len(dhcpConfig.Servers) == 0 {
|
||||
return fmt.Errorf("no dhcp dns is currently available")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dnsConfigDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create dns config directory: %w", err)
|
||||
}
|
||||
|
||||
if err := renderResolvConfig(dhcpResolvFile, dhcpConfig); err != nil {
|
||||
return fmt.Errorf("failed to write dhcp dns cache: %w", err)
|
||||
}
|
||||
|
||||
if err := writeDNSMode(dnsModeDHCP); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := preserveManualDNSServers(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := backupAndRemoveBootResolv(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := renderResolvConfig(etcResolvFile, dhcpConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return installUDHCPCDNSHook()
|
||||
}
|
||||
|
||||
func currentDNSMode() string {
|
||||
mode := readRawDNSMode()
|
||||
if mode == "" {
|
||||
mode = defaultDNSMode()
|
||||
}
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
func readDNSMode() string {
|
||||
return readRawDNSMode()
|
||||
}
|
||||
|
||||
func readRawDNSMode() string {
|
||||
data, err := os.ReadFile(dnsModeFile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
mode := strings.TrimSpace(string(data))
|
||||
if mode != dnsModeManual && mode != dnsModeDHCP {
|
||||
return ""
|
||||
}
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
func canFallbackEffectiveForDHCP() bool {
|
||||
mode := readRawDNSMode()
|
||||
if mode == dnsModeDHCP {
|
||||
return true
|
||||
}
|
||||
if mode == dnsModeManual {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(bootResolvFile); os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultDNSMode() string {
|
||||
if _, err := os.Stat(bootResolvFile); err == nil {
|
||||
return dnsModeManual
|
||||
}
|
||||
|
||||
return dnsModeDHCP
|
||||
}
|
||||
|
||||
func writeDNSMode(mode string) error {
|
||||
if err := os.WriteFile(dnsModeFile, []byte(mode+"\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write dns mode: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readManualDNSServers() []string {
|
||||
servers, err := parsePlainDNSServers(dnsServersFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
func preserveManualDNSServers() error {
|
||||
if len(readManualDNSServers()) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
servers, _ := parseResolvConf(bootResolvFile)
|
||||
if len(servers) == 0 {
|
||||
servers, _ = parseResolvConf(bootResolvBackup)
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dnsServersFile, []byte(strings.Join(servers, "\n")+"\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to preserve manual dns servers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePlainDNSServers(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
return normalizeDNSServers(lines)
|
||||
}
|
||||
|
||||
func readDHCPResolvConfig(allowEffectiveFallback bool) (resolvConfig, error) {
|
||||
config, err := parseResolvConfig(dhcpResolvFile)
|
||||
if err == nil && len(config.Servers) > 0 {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
tmpConfig, tmpErr := parseResolvConfig(tmpResolvFile)
|
||||
if tmpErr == nil && len(tmpConfig.Servers) > 0 {
|
||||
return tmpConfig, nil
|
||||
}
|
||||
|
||||
if allowEffectiveFallback {
|
||||
effective, effectiveErr := parseResolvConfig(etcResolvFile)
|
||||
if effectiveErr == nil && len(effective.Servers) > 0 {
|
||||
return effective, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return resolvConfig{}, err
|
||||
}
|
||||
if tmpErr != nil && !os.IsNotExist(tmpErr) {
|
||||
return resolvConfig{}, tmpErr
|
||||
}
|
||||
|
||||
return resolvConfig{}, nil
|
||||
}
|
||||
|
||||
func refreshDHCPResolvCacheFromEffective() error {
|
||||
config, err := parseResolvConfig(etcResolvFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(config.Servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(config.SearchDomains) == 0 {
|
||||
cached, _ := parseResolvConfig(dhcpResolvFile)
|
||||
config.SearchDomains = cached.SearchDomains
|
||||
}
|
||||
|
||||
return renderResolvConfig(dhcpResolvFile, config)
|
||||
}
|
||||
|
||||
func parseResolvConf(path string) ([]string, error) {
|
||||
config, err := parseResolvConfig(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config.Servers, nil
|
||||
}
|
||||
|
||||
func parseResolvConfig(path string) (resolvConfig, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return resolvConfig{}, err
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
var servers []string
|
||||
var searchDomains []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := stripInlineComment(scanner.Text())
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
switch fields[0] {
|
||||
case "nameserver":
|
||||
servers = append(servers, fields[1])
|
||||
case "search":
|
||||
searchDomains = append(searchDomains, fields[1:]...)
|
||||
case "domain":
|
||||
searchDomains = append(searchDomains, fields[1])
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return resolvConfig{}, err
|
||||
}
|
||||
|
||||
normalizedServers, err := normalizeDNSServers(servers)
|
||||
if err != nil {
|
||||
return resolvConfig{}, err
|
||||
}
|
||||
|
||||
return resolvConfig{
|
||||
Servers: normalizedServers,
|
||||
SearchDomains: normalizeSearchDomains(searchDomains),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getDNSInfo() proto.DNSInfo {
|
||||
ifaceName, gateway := getDefaultIPv4Route()
|
||||
if ifaceName == "" {
|
||||
ifaceName = getFallbackIPv4Interface()
|
||||
}
|
||||
|
||||
info := proto.DNSInfo{
|
||||
Interface: ifaceName,
|
||||
Gateway: gateway,
|
||||
}
|
||||
|
||||
dhcpConfig, _ := readDHCPResolvConfig(canFallbackEffectiveForDHCP())
|
||||
info.SearchDomains = dhcpConfig.SearchDomains
|
||||
|
||||
if ifaceName == "" {
|
||||
return info
|
||||
}
|
||||
|
||||
iface, err := net.InterfaceByName(ifaceName)
|
||||
if err != nil {
|
||||
return info
|
||||
}
|
||||
|
||||
info.Type = getDNSInterfaceType(iface.Name)
|
||||
info.Address, info.SubnetMask = getIPv4AddressInfo(*iface)
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func getDefaultIPv4Route() (string, string) {
|
||||
file, err := os.Open("/proc/net/route")
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 8 || fields[1] != "00000000" {
|
||||
continue
|
||||
}
|
||||
|
||||
return fields[0], parseRouteGateway(fields[2])
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func parseRouteGateway(value string) string {
|
||||
gateway, err := strconv.ParseUint(value, 16, 32)
|
||||
if err != nil || gateway == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return net.IPv4(byte(gateway), byte(gateway>>8), byte(gateway>>16), byte(gateway>>24)).String()
|
||||
}
|
||||
|
||||
func getFallbackIPv4Interface() string {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
if getDNSInterfaceType(iface.Name) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
address, _ := getIPv4AddressInfo(iface)
|
||||
if address != "" {
|
||||
return iface.Name
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func getDNSInterfaceType(name string) string {
|
||||
if strings.HasPrefix(name, "eth") || strings.HasPrefix(name, "en") {
|
||||
return "Wired"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wl") {
|
||||
return "Wireless"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func getIPv4AddressInfo(iface net.Interface) (string, string) {
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ip := ipNet.IP.To4()
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ones, _ := ipNet.Mask.Size()
|
||||
return fmt.Sprintf("%s/%d", ip.String(), ones), net.IP(ipNet.Mask).String()
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func renderResolvConf(path string, servers []string) error {
|
||||
return renderResolvConfig(path, resolvConfig{Servers: servers})
|
||||
}
|
||||
|
||||
func renderResolvConfig(path string, config resolvConfig) error {
|
||||
normalized, err := normalizeDNSServers(config.Servers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return fmt.Errorf("dns servers are required")
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
searchDomains := normalizeSearchDomains(config.SearchDomains)
|
||||
if len(searchDomains) > 0 {
|
||||
builder.WriteString("search ")
|
||||
builder.WriteString(strings.Join(searchDomains, " "))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
for _, server := range normalized {
|
||||
builder.WriteString("nameserver ")
|
||||
builder.WriteString(server)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create dns file directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(builder.String()), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDNSServers(servers []string) ([]string, error) {
|
||||
normalized, err := normalizeDNSServers(servers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(normalized) > maxDNSServers {
|
||||
return nil, fmt.Errorf("too many dns servers")
|
||||
}
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeDNSServers(servers []string) ([]string, error) {
|
||||
seen := make(map[string]struct{})
|
||||
var normalized []string
|
||||
|
||||
for _, server := range servers {
|
||||
server = normalizeDNSServer(server)
|
||||
if server == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
ip := net.ParseIP(server)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("invalid dns server: %s", server)
|
||||
}
|
||||
|
||||
server = ip.String()
|
||||
if _, exists := seen[server]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[server] = struct{}{}
|
||||
normalized = append(normalized, server)
|
||||
}
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeSearchDomains(domains []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var normalized []string
|
||||
|
||||
for _, domain := range domains {
|
||||
domain = strings.TrimSpace(domain)
|
||||
if domain == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[domain]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[domain] = struct{}{}
|
||||
normalized = append(normalized, domain)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeDNSServer(server string) string {
|
||||
server = stripInlineComment(server)
|
||||
server = strings.TrimSpace(server)
|
||||
fields := strings.Fields(server)
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fields[0]
|
||||
}
|
||||
|
||||
func stripInlineComment(value string) string {
|
||||
if index := strings.Index(value, "#"); index >= 0 {
|
||||
return value[:index]
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func backupAndRemoveBootResolv() error {
|
||||
data, err := os.ReadFile(bootResolvFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read boot resolv: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(bootResolvBackup, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to backup boot resolv: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(bootResolvFile); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove boot resolv: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func installUDHCPCDNSHook() error {
|
||||
if err := os.MkdirAll(filepath.Dir(udhcpcHookFile), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create udhcpc hook directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(udhcpcHookFile, []byte(udhcpcDNSHook), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to install udhcpc dns hook: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
56
server/service/network/scripts/99-nanokvm-dns
Normal file
56
server/service/network/scripts/99-nanokvm-dns
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
|
||||
ACTION="$1"
|
||||
CACHE="/etc/resolv.conf.dhcp"
|
||||
ACTIVE="/etc/resolv.conf"
|
||||
MODE="/etc/kvm/network/dns.mode"
|
||||
BOOT="/boot/resolv.conf"
|
||||
|
||||
[ "$ACTION" = "bound" ] || [ "$ACTION" = "renew" ] || exit 0
|
||||
[ -n "$dns" ] || exit 0
|
||||
|
||||
TMPFILE="$(mktemp)" || exit 1
|
||||
|
||||
write_search_domain() {
|
||||
if [ -n "$search" ]; then
|
||||
printf "search"
|
||||
for domain_name in $search; do
|
||||
printf " %s" "$domain_name"
|
||||
done
|
||||
printf "\n"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$domain" ]; then
|
||||
printf "domain %s\n" "$domain"
|
||||
return
|
||||
fi
|
||||
|
||||
for source in "$CACHE" "$ACTIVE"; do
|
||||
[ -f "$source" ] || continue
|
||||
while IFS= read -r line; do
|
||||
line="${line%%#*}"
|
||||
set -- $line
|
||||
[ "$1" = "search" ] || [ "$1" = "domain" ] || continue
|
||||
[ -n "$2" ] || continue
|
||||
printf "%s\n" "$line"
|
||||
return
|
||||
done < "$source"
|
||||
done
|
||||
}
|
||||
|
||||
{
|
||||
write_search_domain
|
||||
for server in $dns; do
|
||||
printf "nameserver %s\n" "$server"
|
||||
done
|
||||
} > "$TMPFILE"
|
||||
|
||||
cat "$TMPFILE" > "$CACHE"
|
||||
MODE_VALUE="$(cat "$MODE" 2>/dev/null)"
|
||||
if [ "$MODE_VALUE" = "dhcp" ] || { [ -z "$MODE_VALUE" ] && [ ! -f "$BOOT" ]; }; then
|
||||
cat "$TMPFILE" > "$ACTIVE"
|
||||
fi
|
||||
rm -f "$TMPFILE"
|
||||
|
||||
exit 0
|
||||
@@ -1,6 +1,7 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
WiFiConnect = "/kvmapp/kvm/wifi_try_connect"
|
||||
WiFiStateFile = "/kvmapp/kvm/wifi_state"
|
||||
WiFiScript = "/etc/init.d/S30wifi"
|
||||
WiFiApPassFile = "/kvmapp/kvm/ap.pass"
|
||||
)
|
||||
|
||||
func (s *Service) GetWifi(c *gin.Context) {
|
||||
@@ -62,6 +64,15 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify AP Password
|
||||
apKey := c.GetHeader("X-AP-Key")
|
||||
expectedPass := getApPassword()
|
||||
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
|
||||
time.Sleep(2 * time.Second)
|
||||
rsp.ErrRsp(c, -4, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
rsp.ErrRsp(c, -2, "invalid parameters")
|
||||
@@ -79,6 +90,26 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
|
||||
log.Debugf("set wifi ap mode successfully")
|
||||
}
|
||||
|
||||
func (s *Service) VerifyApLogin(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
if !isSupported() || !isAPMode() {
|
||||
time.Sleep(2 * time.Second)
|
||||
rsp.ErrRsp(c, -1, "invalid mode")
|
||||
return
|
||||
}
|
||||
|
||||
apKey := c.GetHeader("X-AP-Key")
|
||||
expectedPass := getApPassword()
|
||||
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
|
||||
time.Sleep(2 * time.Second)
|
||||
rsp.ErrRsp(c, -4, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
}
|
||||
|
||||
func (s *Service) ConnectWifi(c *gin.Context) {
|
||||
var req proto.ConnectWifiReq
|
||||
var rsp proto.Response
|
||||
@@ -111,7 +142,6 @@ func (s *Service) ConnectWifi(c *gin.Context) {
|
||||
}
|
||||
|
||||
rsp.ErrRsp(c, -3, "failed to get wifi status")
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Service) DisconnectWifi(c *gin.Context) {
|
||||
@@ -182,3 +212,12 @@ func getWiFiSsid() string {
|
||||
|
||||
return strings.ReplaceAll(string(ssidByte), "\n", "")
|
||||
}
|
||||
|
||||
func getApPassword() string {
|
||||
passByte, err := os.ReadFile(WiFiApPassFile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(string(passByte), "\n", "")
|
||||
}
|
||||
|
||||
316
server/service/picoclaw/actions.go
Normal file
316
server/service/picoclaw/actions.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/hid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClickHold = 40 * time.Millisecond
|
||||
defaultKeyDelay = 30 * time.Millisecond
|
||||
defaultDragSteps = 10
|
||||
defaultScrollStep = 20 * time.Millisecond
|
||||
)
|
||||
|
||||
func (s *Service) Actions(c *gin.Context) {
|
||||
sessionID, sessionErr := s.requireSessionID(c)
|
||||
if sessionErr != nil {
|
||||
writePicoclawError(c, sessionErr)
|
||||
return
|
||||
}
|
||||
|
||||
releaseAfter, lockErr := s.lock.AcquireTemporary(sessionID)
|
||||
if lockErr != nil {
|
||||
writePicoclawError(c, lockErr)
|
||||
return
|
||||
}
|
||||
if releaseAfter {
|
||||
defer s.lock.Release(sessionID)
|
||||
}
|
||||
|
||||
actions, actionErr := normalizeActions(c)
|
||||
if actionErr != nil {
|
||||
writePicoclawError(c, actionErr)
|
||||
return
|
||||
}
|
||||
|
||||
result, execErr := s.executeActions(sessionID, actions)
|
||||
if execErr != nil {
|
||||
writePicoclawError(c, execErr)
|
||||
return
|
||||
}
|
||||
|
||||
writeSuccess(c, result)
|
||||
}
|
||||
|
||||
func normalizeActions(c *gin.Context) ([]Action, *PicoclawError) {
|
||||
body := bytes.NewBuffer(nil)
|
||||
if _, err := body.ReadFrom(c.Request.Body); err != nil {
|
||||
return nil, newPicoclawError(CodeInvalidAction, "failed to read action payload")
|
||||
}
|
||||
|
||||
raw := body.Bytes()
|
||||
if len(raw) == 0 {
|
||||
return nil, newPicoclawError(CodeInvalidAction, "empty action payload")
|
||||
}
|
||||
|
||||
var batch ActionBatch
|
||||
if err := json.Unmarshal(raw, &batch); err == nil && len(batch.Actions) > 0 {
|
||||
return batch.Actions, nil
|
||||
}
|
||||
|
||||
var action Action
|
||||
if err := json.Unmarshal(raw, &action); err != nil || action.Action == "" {
|
||||
return nil, newPicoclawError(CodeInvalidAction, "invalid action payload")
|
||||
}
|
||||
|
||||
return []Action{action}, nil
|
||||
}
|
||||
|
||||
func (s *Service) executeActions(sessionID string, actions []Action) (result ActionResult, err *PicoclawError) {
|
||||
startedAt := time.Now()
|
||||
if len(actions) == 0 {
|
||||
return ActionResult{}, newPicoclawError(CodeInvalidAction, "empty actions")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
s.releaseAllHIDState()
|
||||
}
|
||||
}()
|
||||
|
||||
totalWrites := 0
|
||||
for idx, action := range actions {
|
||||
if lockErr := s.lock.Ensure(sessionID); lockErr != nil {
|
||||
lockErr.Index = &idx
|
||||
return ActionResult{}, lockErr
|
||||
}
|
||||
|
||||
writes, execErr := s.executeAction(action)
|
||||
if execErr != nil {
|
||||
execErr.Index = &idx
|
||||
return ActionResult{}, execErr
|
||||
}
|
||||
totalWrites += writes
|
||||
}
|
||||
|
||||
result = ActionResult{
|
||||
Action: actions[0].Action,
|
||||
DurationMs: time.Since(startedAt).Milliseconds(),
|
||||
HIDWrites: totalWrites,
|
||||
ExecutedActions: len(actions),
|
||||
}
|
||||
if len(actions) > 1 {
|
||||
result.Action = "batch"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) executeAction(action Action) (int, *PicoclawError) {
|
||||
switch strings.ToLower(strings.TrimSpace(action.Action)) {
|
||||
case "click":
|
||||
x, y, err := normalizedPoint(action.X, action.Y)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
button, err := mouseButton(action.Button)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := 0
|
||||
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
|
||||
writes += s.sendMousePress(x, y, button)
|
||||
time.Sleep(defaultClickHold)
|
||||
writes += s.sendMouseRelease(x, y)
|
||||
return writes, nil
|
||||
|
||||
case "move":
|
||||
x, y, err := normalizedPoint(action.X, action.Y)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.sendMouseMoveWithButton(x, y, 0x00, 0), nil
|
||||
|
||||
case "wait":
|
||||
if action.DurationMs < 0 {
|
||||
return 0, newPicoclawError(CodeInvalidAction, "wait duration must be >= 0")
|
||||
}
|
||||
time.Sleep(time.Duration(action.DurationMs) * time.Millisecond)
|
||||
return 0, nil
|
||||
|
||||
case "drag":
|
||||
fromX, fromY, err := normalizedNestedPoint(action.From)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
toX, toY, err := normalizedNestedPoint(action.To)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
button, err := mouseButton(action.Button)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writes := 0
|
||||
writes += s.sendMouseMoveWithButton(fromX, fromY, 0x00, 0)
|
||||
writes += s.sendMousePress(fromX, fromY, button)
|
||||
for step := 1; step <= defaultDragSteps; step++ {
|
||||
ratio := float64(step) / float64(defaultDragSteps)
|
||||
x := fromX + (toX-fromX)*ratio
|
||||
y := fromY + (toY-fromY)*ratio
|
||||
writes += s.sendMouseMoveWithButton(x, y, button, 0)
|
||||
}
|
||||
writes += s.sendMouseRelease(toX, toY)
|
||||
return writes, nil
|
||||
|
||||
case "scroll":
|
||||
x, y := 0.5, 0.5
|
||||
if action.X != nil || action.Y != nil {
|
||||
var err *PicoclawError
|
||||
x, y, err = normalizedPoint(action.X, action.Y)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
amount := action.Amount
|
||||
if amount == 0 {
|
||||
amount = 1
|
||||
}
|
||||
if amount < 0 {
|
||||
return 0, newPicoclawError(CodeInvalidAction, "scroll amount must be > 0")
|
||||
}
|
||||
|
||||
wheel := 1
|
||||
switch strings.ToLower(strings.TrimSpace(action.Direction)) {
|
||||
case "", "up":
|
||||
wheel = 1
|
||||
case "down":
|
||||
wheel = -1
|
||||
default:
|
||||
return 0, newPicoclawError(CodeInvalidAction, "invalid scroll direction")
|
||||
}
|
||||
|
||||
writes := 0
|
||||
for range amount {
|
||||
writes += s.sendMouseMoveWithButton(x, y, 0x00, wheel)
|
||||
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
|
||||
time.Sleep(defaultScrollStep)
|
||||
}
|
||||
return writes, nil
|
||||
|
||||
case "type":
|
||||
if action.Text == "" {
|
||||
return 0, newPicoclawError(CodeInvalidAction, "type requires text")
|
||||
}
|
||||
charMap := hid.GetCharMap("")
|
||||
writes := 0
|
||||
for _, char := range action.Text {
|
||||
key, ok := charMap[char]
|
||||
if !ok {
|
||||
return 0, newPicoclawError(CodeInvalidAction, "unsupported character in type action")
|
||||
}
|
||||
|
||||
writes += s.sendKeyboardReport([]byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00})
|
||||
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
|
||||
time.Sleep(defaultKeyDelay)
|
||||
}
|
||||
return writes, nil
|
||||
|
||||
case "hotkey":
|
||||
report, err := buildHotkeyReport([]string(action.Keys))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
writes := 0
|
||||
writes += s.sendKeyboardReport(report)
|
||||
time.Sleep(defaultClickHold)
|
||||
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
|
||||
return writes, nil
|
||||
}
|
||||
|
||||
return 0, newPicoclawError(CodeInvalidAction, "unknown or invalid action")
|
||||
}
|
||||
|
||||
func toAbsoluteHidCoord(normalized float64) uint16 {
|
||||
if normalized < 0 {
|
||||
normalized = 0
|
||||
}
|
||||
if normalized > 1 {
|
||||
normalized = 1
|
||||
}
|
||||
return uint16(math.Floor(0x7FFF*normalized)) + 1
|
||||
}
|
||||
|
||||
func (s *Service) sendMousePress(x float64, y float64, button byte) int {
|
||||
return s.sendMouseMoveWithButton(x, y, button, 0)
|
||||
}
|
||||
|
||||
func (s *Service) sendMouseRelease(x float64, y float64) int {
|
||||
return s.sendMouseMoveWithButton(x, y, 0x00, 0)
|
||||
}
|
||||
|
||||
func (s *Service) sendMouseMoveWithButton(x float64, y float64, buttons byte, wheel int) int {
|
||||
absoluteX := toAbsoluteHidCoord(x)
|
||||
absoluteY := toAbsoluteHidCoord(y)
|
||||
|
||||
report := []byte{
|
||||
buttons,
|
||||
byte(absoluteX & 0xff),
|
||||
byte(absoluteX >> 8),
|
||||
byte(absoluteY & 0xff),
|
||||
byte(absoluteY >> 8),
|
||||
byte(int8(wheel)),
|
||||
}
|
||||
s.hid.WriteHid2(report)
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s *Service) sendKeyboardReport(report []byte) int {
|
||||
s.hid.WriteHid0(report)
|
||||
return 1
|
||||
}
|
||||
|
||||
func normalizedPoint(x *float64, y *float64) (float64, float64, *PicoclawError) {
|
||||
if x == nil || y == nil {
|
||||
return 0, 0, newPicoclawError(CodeInvalidAction, "action requires x and y")
|
||||
}
|
||||
if *x < 0 || *x > 1 || *y < 0 || *y > 1 {
|
||||
return 0, 0, newPicoclawError(CodeInvalidAction, "coordinates must be within [0,1]")
|
||||
}
|
||||
return *x, *y, nil
|
||||
}
|
||||
|
||||
func normalizedNestedPoint(point *Point) (float64, float64, *PicoclawError) {
|
||||
if point == nil {
|
||||
return 0, 0, newPicoclawError(CodeInvalidAction, "action requires point coordinates")
|
||||
}
|
||||
return normalizedPoint(point.X, point.Y)
|
||||
}
|
||||
|
||||
func mouseButton(button string) (byte, *PicoclawError) {
|
||||
switch strings.ToLower(strings.TrimSpace(button)) {
|
||||
case "", "left":
|
||||
return 1 << 0, nil
|
||||
case "right":
|
||||
return 1 << 1, nil
|
||||
case "middle":
|
||||
return 1 << 2, nil
|
||||
case "back":
|
||||
return 1 << 3, nil
|
||||
case "forward":
|
||||
return 1 << 4, nil
|
||||
default:
|
||||
return 0, newPicoclawError(CodeInvalidAction, "invalid mouse button")
|
||||
}
|
||||
}
|
||||
147
server/service/picoclaw/agent_profile.go
Normal file
147
server/service/picoclaw/agent_profile.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
agentProfileDefault = "default"
|
||||
agentProfileKVM = "kvm"
|
||||
agentProfileFile = "AGENT.md"
|
||||
)
|
||||
|
||||
type AgentProfileUpdateRequest struct {
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
|
||||
func resolveAgentProfileSourcePath(profile string) (string, error) {
|
||||
fileName := agentProfileFile
|
||||
switch profile {
|
||||
case agentProfileDefault:
|
||||
fileName = "AGENT.md"
|
||||
case agentProfileKVM:
|
||||
fileName = "AGENT_KVM.md"
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported agent profile: %s", profile)
|
||||
}
|
||||
|
||||
candidates := make([]string, 0, 4)
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(wd, "picoclaw", fileName),
|
||||
filepath.Join(wd, "docs", fileName),
|
||||
)
|
||||
}
|
||||
candidates = append(candidates,
|
||||
filepath.Join("/kvmapp", "picoclaw", fileName),
|
||||
filepath.Join(filepath.Dir(os.Args[0]), "..", "picoclaw", fileName),
|
||||
)
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("agent profile source not found: %s", fileName)
|
||||
}
|
||||
|
||||
func detectPicoclawAgentProfile() string {
|
||||
workspacePath, err := resolvePicoclawWorkspacePath()
|
||||
if err != nil {
|
||||
return agentProfileKVM
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(workspacePath, agentProfileFile)
|
||||
targetContent, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
return agentProfileKVM
|
||||
}
|
||||
|
||||
switch parseAgentProfileName(string(targetContent)) {
|
||||
case "pico":
|
||||
return agentProfileDefault
|
||||
case "pico-kvm":
|
||||
return agentProfileKVM
|
||||
default:
|
||||
return agentProfileKVM
|
||||
}
|
||||
}
|
||||
|
||||
func parseAgentProfileName(content string) string {
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "name:") {
|
||||
continue
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, "name:"))
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func applyPicoclawAgentProfile(profile string) error {
|
||||
sourcePath, err := resolveAgentProfileSourcePath(profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspacePath, err := resolvePicoclawWorkspacePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(workspacePath, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create picoclaw workspace: %w", err)
|
||||
}
|
||||
|
||||
sourceContent, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read agent profile source: %w", err)
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(workspacePath, agentProfileFile)
|
||||
if err := os.WriteFile(targetPath, sourceContent, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write picoclaw AGENT.md: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func withAgentProfile(status RuntimeStatus) RuntimeStatus {
|
||||
status.AgentProfile = detectPicoclawAgentProfile()
|
||||
return status
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAgentProfile(c *gin.Context) {
|
||||
var req AgentProfileUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile payload"))
|
||||
return
|
||||
}
|
||||
|
||||
profile := strings.TrimSpace(req.Profile)
|
||||
if profile != agentProfileDefault && profile != agentProfileKVM {
|
||||
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := applyPicoclawAgentProfile(profile); err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.syncConfigFromPicoclaw()
|
||||
_ = s.ensureRuntimeReady()
|
||||
|
||||
writeSuccess(c, gin.H{
|
||||
"profile": profile,
|
||||
"status": withAgentProfile(s.runtime.Get()),
|
||||
})
|
||||
}
|
||||
179
server/service/picoclaw/config.go
Normal file
179
server/service/picoclaw/config.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPicoclawGatewayHost = "127.0.0.1"
|
||||
defaultPicoclawGatewayPort = 18790
|
||||
picoclawGatewayPath = "/pico/ws"
|
||||
)
|
||||
|
||||
func defaultConfig() Config {
|
||||
cfg := Config{
|
||||
GatewayURL: "ws://127.0.0.1:18790/pico/ws",
|
||||
ConnectTimeoutMs: 10000,
|
||||
ReadTimeoutMs: 60000,
|
||||
WriteTimeoutMs: 10000,
|
||||
PingIntervalMs: 30000,
|
||||
MaxMessageBytes: 1024 * 1024,
|
||||
AllowTokenQuery: false,
|
||||
}
|
||||
|
||||
settings, err := loadPicoclawGatewaySettings()
|
||||
if err != nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
cfg.GatewayURL = settings.GatewayURL
|
||||
cfg.Token = settings.Token
|
||||
cfg.AllowTokenQuery = settings.AllowTokenQuery
|
||||
if settings.PingIntervalMs > 0 {
|
||||
cfg.PingIntervalMs = settings.PingIntervalMs
|
||||
}
|
||||
if settings.ReadTimeoutMs > 0 {
|
||||
cfg.ReadTimeoutMs = settings.ReadTimeoutMs
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (s *Service) syncConfigFromPicoclaw() *PicoclawError {
|
||||
installed, installedKnown := picoclawInstalledState()
|
||||
|
||||
if patchErr := preparePicoclawConfigForRead(); patchErr != nil {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Status = "config_error"
|
||||
status.ConfigError = patchErr.Error()
|
||||
status.LastError = patchErr.Error()
|
||||
if installedKnown {
|
||||
status.Installed = installed
|
||||
}
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, patchErr.Error())
|
||||
}
|
||||
|
||||
settings, err := loadPicoclawGatewaySettings()
|
||||
if err != nil {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Status = "config_error"
|
||||
status.ConfigError = err.Error()
|
||||
status.LastError = err.Error()
|
||||
if installedKnown {
|
||||
status.Installed = installed
|
||||
}
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, err.Error())
|
||||
}
|
||||
|
||||
cfg := s.config.Get()
|
||||
cfg.GatewayURL = settings.GatewayURL
|
||||
cfg.Token = settings.Token
|
||||
cfg.AllowTokenQuery = settings.AllowTokenQuery
|
||||
if settings.PingIntervalMs > 0 {
|
||||
cfg.PingIntervalMs = settings.PingIntervalMs
|
||||
}
|
||||
if settings.ReadTimeoutMs > 0 {
|
||||
cfg.ReadTimeoutMs = settings.ReadTimeoutMs
|
||||
}
|
||||
s.config.Set(cfg)
|
||||
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Installed = true
|
||||
status.ModelConfigured = settings.ModelConfigured
|
||||
if settings.ModelConfigured {
|
||||
status.ModelName = settings.ModelName
|
||||
} else {
|
||||
status.ModelName = settings.TargetModelName
|
||||
}
|
||||
status.ConfigError = ""
|
||||
if status.Status == "config_error" && status.Ready {
|
||||
status.Status = "ready"
|
||||
}
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type picoclawGatewaySettings struct {
|
||||
GatewayURL string
|
||||
Token string
|
||||
AllowTokenQuery bool
|
||||
PingIntervalMs int
|
||||
ReadTimeoutMs int
|
||||
ModelConfigured bool
|
||||
ModelName string
|
||||
TargetModelName string
|
||||
}
|
||||
|
||||
func loadPicoclawGatewaySettings() (picoclawGatewaySettings, error) {
|
||||
doc, err := loadPicoclawConfigDocument()
|
||||
if err != nil {
|
||||
return picoclawGatewaySettings{}, err
|
||||
}
|
||||
if err := ensurePicoclawPicoChannelEnabled(doc); err != nil {
|
||||
return picoclawGatewaySettings{}, err
|
||||
}
|
||||
|
||||
cfg := doc.config
|
||||
|
||||
host := cfg.Gateway.Host
|
||||
if host == "" || host == "0.0.0.0" {
|
||||
host = defaultPicoclawGatewayHost
|
||||
}
|
||||
|
||||
port := cfg.Gateway.Port
|
||||
if port <= 0 {
|
||||
port = defaultPicoclawGatewayPort
|
||||
}
|
||||
|
||||
settings := picoclawGatewaySettings{
|
||||
GatewayURL: fmt.Sprintf("ws://%s:%d%s", host, port, picoclawGatewayPath),
|
||||
Token: doc.resolvedGatewayToken(),
|
||||
AllowTokenQuery: cfg.Channels.Pico.AllowTokenQuery,
|
||||
}
|
||||
settings.TargetModelName = resolvePicoclawTargetModelName(cfg)
|
||||
|
||||
if isPicoclawModelConfigured(cfg, doc.security, settings.TargetModelName) {
|
||||
settings.ModelConfigured = true
|
||||
settings.ModelName = settings.TargetModelName
|
||||
}
|
||||
|
||||
if cfg.Channels.Pico.PingInterval > 0 {
|
||||
settings.PingIntervalMs = cfg.Channels.Pico.PingInterval * 1000
|
||||
}
|
||||
if cfg.Channels.Pico.ReadTimeout > 0 {
|
||||
settings.ReadTimeoutMs = cfg.Channels.Pico.ReadTimeout * 1000
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func resolvePicoclawTargetModelName(cfg picoclawConfigFile) string {
|
||||
return strings.TrimSpace(cfg.Agents.Defaults.ModelName)
|
||||
}
|
||||
|
||||
func preparePicoclawConfigForRead() error {
|
||||
configPath, err := resolvePicoclawConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to stat picoclaw config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
256
server/service/picoclaw/config_files.go
Normal file
256
server/service/picoclaw/config_files.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
picoclawPIDFileName = ".picoclaw.pid"
|
||||
picoclawGatewayTokenPrefix = "pico-"
|
||||
)
|
||||
|
||||
type picoclawConfigFile struct {
|
||||
Agents struct {
|
||||
Defaults struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Workspace string `json:"workspace,omitempty"`
|
||||
} `json:"defaults"`
|
||||
} `json:"agents"`
|
||||
Gateway struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
} `json:"gateway"`
|
||||
ModelList []struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Model string `json:"model"`
|
||||
APIBase string `json:"api_base"`
|
||||
APIKey string `json:"api_key"`
|
||||
APIKeys []string `json:"api_keys,omitempty"`
|
||||
} `json:"model_list"`
|
||||
Channels struct {
|
||||
Pico struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Token string `json:"token"`
|
||||
AllowTokenQuery bool `json:"allow_token_query"`
|
||||
PingInterval int `json:"ping_interval"`
|
||||
ReadTimeout int `json:"read_timeout"`
|
||||
} `json:"pico"`
|
||||
} `json:"channels"`
|
||||
}
|
||||
|
||||
type picoclawConfigDocument struct {
|
||||
configPath string
|
||||
securityPath string
|
||||
config picoclawConfigFile
|
||||
raw map[string]any
|
||||
security picoclawSecurityConfig
|
||||
}
|
||||
|
||||
func loadPicoclawConfigDocument() (*picoclawConfigDocument, error) {
|
||||
configPath, err := resolvePicoclawConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read picoclaw config: %w", err)
|
||||
}
|
||||
|
||||
var cfg picoclawConfigFile
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse picoclaw config: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse picoclaw config for update: %w", err)
|
||||
}
|
||||
|
||||
securityPath := resolvePicoclawSecurityPath(configPath)
|
||||
security, err := loadPicoclawSecurityConfig(securityPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &picoclawConfigDocument{
|
||||
configPath: configPath,
|
||||
securityPath: securityPath,
|
||||
config: cfg,
|
||||
raw: raw,
|
||||
security: security,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolvePicoclawConfigPath() (string, error) {
|
||||
home := os.Getenv("PICOCLAW_HOME")
|
||||
if home == "" {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve PICOCLAW_HOME: %w", err)
|
||||
}
|
||||
home = filepath.Join(currentUser.HomeDir, ".picoclaw")
|
||||
}
|
||||
|
||||
return filepath.Join(home, "config.json"), nil
|
||||
}
|
||||
|
||||
func expandPicoclawPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "~/") || path == "~" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if path == "~" {
|
||||
return home
|
||||
}
|
||||
return filepath.Join(home, strings.TrimPrefix(path, "~/"))
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func resolvePicoclawSecurityPath(configPath string) string {
|
||||
return filepath.Join(filepath.Dir(configPath), ".security.yml")
|
||||
}
|
||||
|
||||
func (d *picoclawConfigDocument) saveConfig() error {
|
||||
updated, err := json.MarshalIndent(d.raw, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode updated picoclaw config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(d.configPath, updated, 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write picoclaw config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *picoclawConfigDocument) saveSecurity() error {
|
||||
var buf bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&buf)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(d.security); err != nil {
|
||||
return fmt.Errorf("failed to encode picoclaw security config: %w", err)
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize picoclaw security config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(d.securityPath, buf.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write picoclaw security config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type picoclawSecurityConfig struct {
|
||||
ModelList map[string]picoclawModelSecurityEntry `yaml:"model_list,omitempty"`
|
||||
Channels picoclawSecurityChannels `yaml:"channels,omitempty"`
|
||||
}
|
||||
|
||||
type picoclawModelSecurityEntry struct {
|
||||
APIKeys []string `yaml:"api_keys,omitempty"`
|
||||
}
|
||||
|
||||
type picoclawSecurityChannels struct {
|
||||
Pico *picoclawPicoSecurity `yaml:"pico,omitempty"`
|
||||
}
|
||||
|
||||
type picoclawPicoSecurity struct {
|
||||
Token string `yaml:"token,omitempty"`
|
||||
}
|
||||
|
||||
type picoclawPIDFile struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (d *picoclawConfigDocument) resolvedPicoToken() string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
if d.security.Channels.Pico != nil {
|
||||
if token := d.security.Channels.Pico.Token; token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return d.config.Channels.Pico.Token
|
||||
}
|
||||
|
||||
func (d *picoclawConfigDocument) resolvedGatewayToken() string {
|
||||
baseToken := strings.TrimSpace(d.resolvedPicoToken())
|
||||
if baseToken == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
runtimeToken, err := d.resolvedPIDToken()
|
||||
if err != nil {
|
||||
return baseToken
|
||||
}
|
||||
|
||||
return composePicoclawGatewayToken(baseToken, runtimeToken)
|
||||
}
|
||||
|
||||
func (d *picoclawConfigDocument) resolvedPIDToken() (string, error) {
|
||||
if d == nil || d.configPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
pidPath := filepath.Join(filepath.Dir(d.configPath), picoclawPIDFileName)
|
||||
data, err := os.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to read picoclaw pid file: %w", err)
|
||||
}
|
||||
|
||||
var pidFile picoclawPIDFile
|
||||
if err := json.Unmarshal(data, &pidFile); err != nil {
|
||||
return "", fmt.Errorf("failed to parse picoclaw pid file: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(pidFile.Token), nil
|
||||
}
|
||||
|
||||
func composePicoclawGatewayToken(baseToken, runtimeToken string) string {
|
||||
baseToken = strings.TrimSpace(baseToken)
|
||||
runtimeToken = strings.TrimSpace(runtimeToken)
|
||||
|
||||
switch {
|
||||
case baseToken == "":
|
||||
return ""
|
||||
case strings.HasPrefix(baseToken, picoclawGatewayTokenPrefix):
|
||||
return baseToken
|
||||
case runtimeToken == "":
|
||||
return baseToken
|
||||
default:
|
||||
return picoclawGatewayTokenPrefix + runtimeToken + baseToken
|
||||
}
|
||||
}
|
||||
|
||||
func loadPicoclawSecurityConfig(securityPath string) (picoclawSecurityConfig, error) {
|
||||
data, err := os.ReadFile(securityPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return picoclawSecurityConfig{}, nil
|
||||
}
|
||||
return picoclawSecurityConfig{}, fmt.Errorf("failed to read picoclaw security config: %w", err)
|
||||
}
|
||||
|
||||
var sec picoclawSecurityConfig
|
||||
if err := yaml.Unmarshal(data, &sec); err != nil {
|
||||
return picoclawSecurityConfig{}, fmt.Errorf("failed to parse picoclaw security config: %w", err)
|
||||
}
|
||||
|
||||
return sec, nil
|
||||
}
|
||||
71
server/service/picoclaw/errors.go
Normal file
71
server/service/picoclaw/errors.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
CodePicoclawLockHeld = "AI_LOCK_HELD"
|
||||
CodeScreenshotFailed = "SCREENSHOT_FAILED"
|
||||
CodeScreenshotNoSignal = "SCREENSHOT_NO_SIGNAL"
|
||||
CodeHIDWriteFailed = "HID_WRITE_FAILED"
|
||||
CodeInvalidAction = "INVALID_ACTION"
|
||||
CodeRuntimeUnavailable = "RUNTIME_UNAVAILABLE"
|
||||
CodeRuntimeStartFailed = "RUNTIME_START_FAILED"
|
||||
CodeSessionIDMissing = "SESSION_ID_MISSING"
|
||||
CodeSessionIDInvalid = "SESSION_ID_INVALID"
|
||||
)
|
||||
|
||||
type PicoclawError struct {
|
||||
StatusCode int
|
||||
Code string
|
||||
Message string
|
||||
SessionID string
|
||||
Index *int
|
||||
}
|
||||
|
||||
func (e *PicoclawError) Error() string {
|
||||
return e.Code + ": " + e.Message
|
||||
}
|
||||
|
||||
func newPicoclawError(code string, message string) *PicoclawError {
|
||||
return &PicoclawError{
|
||||
StatusCode: http.StatusOK,
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func writeSuccess(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
func writePicoclawError(c *gin.Context, err *PicoclawError) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
payload := gin.H{
|
||||
"code": err.Code,
|
||||
"message": err.Message,
|
||||
}
|
||||
if err.SessionID != "" {
|
||||
payload["session_id"] = err.SessionID
|
||||
}
|
||||
if err.Index != nil {
|
||||
payload["index"] = *err.Index
|
||||
}
|
||||
|
||||
statusCode := err.StatusCode
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
|
||||
c.JSON(statusCode, payload)
|
||||
}
|
||||
107
server/service/picoclaw/gateway_client.go
Normal file
107
server/service/picoclaw/gateway_client.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawError) {
|
||||
cfg := s.config.Get()
|
||||
|
||||
gatewayURL, err := buildGatewayURL(cfg, sessionID)
|
||||
if err != nil {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Status = "config_error"
|
||||
status.ConfigError = err.Error()
|
||||
status.LastError = err.Error()
|
||||
status.CheckedAt = time.Now()
|
||||
status.CurrentSession = s.lock.Owner()
|
||||
})
|
||||
return nil, newPicoclawError(CodeRuntimeUnavailable, err.Error())
|
||||
}
|
||||
header := http.Header{}
|
||||
if cfg.Token != "" {
|
||||
header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Token))
|
||||
}
|
||||
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond,
|
||||
NetDialContext: (&net.Dialer{
|
||||
Timeout: time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond,
|
||||
}).DialContext,
|
||||
}
|
||||
|
||||
upstream, response, err := dialer.Dial(gatewayURL, header)
|
||||
if err != nil {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Status = "unavailable"
|
||||
status.ConfigError = ""
|
||||
status.LastError = err.Error()
|
||||
status.CheckedAt = time.Now()
|
||||
status.CurrentSession = s.lock.Owner()
|
||||
if response != nil && response.StatusCode == http.StatusUnauthorized {
|
||||
status.Status = "config_error"
|
||||
status.ConfigError = "gateway authentication failed"
|
||||
}
|
||||
})
|
||||
return nil, newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
|
||||
}
|
||||
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = true
|
||||
status.Status = "ready"
|
||||
status.ConfigError = ""
|
||||
status.LastError = ""
|
||||
status.CheckedAt = time.Now()
|
||||
status.CurrentSession = s.lock.Owner()
|
||||
})
|
||||
|
||||
return upstream, nil
|
||||
}
|
||||
|
||||
func buildGatewayURL(cfg Config, sessionID string) (string, error) {
|
||||
parsed, err := url.Parse(cfg.GatewayURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid gateway url: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "ws" && parsed.Scheme != "wss" {
|
||||
return "", fmt.Errorf("invalid gateway url scheme: %s", parsed.Scheme)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid gateway url host")
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
query.Set("session_id", sessionID)
|
||||
if cfg.Token != "" && cfg.AllowTokenQuery {
|
||||
query.Set("token", cfg.Token)
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func gatewayHostPort(parsed *url.URL) (string, error) {
|
||||
if parsed == nil || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid gateway url host")
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(parsed.Host); err == nil {
|
||||
return parsed.Host, nil
|
||||
}
|
||||
|
||||
switch parsed.Scheme {
|
||||
case "ws":
|
||||
return net.JoinHostPort(parsed.Host, "80"), nil
|
||||
case "wss":
|
||||
return net.JoinHostPort(parsed.Host, "443"), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid gateway url scheme: %s", parsed.Scheme)
|
||||
}
|
||||
}
|
||||
277
server/service/picoclaw/gateway_proxy.go
Normal file
277
server/service/picoclaw/gateway_proxy.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/stream/mjpeg"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var gatewayUpgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
type relayResult struct {
|
||||
Source string
|
||||
CloseCode int
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (s *Service) ConnectGateway(c *gin.Context) {
|
||||
sessionID := strings.TrimSpace(c.Query("session_id"))
|
||||
if sessionID == "" {
|
||||
sessionID = uuid.NewString()
|
||||
}
|
||||
|
||||
if lockErr := s.lock.Ensure(sessionID); lockErr != nil {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||
"code": CodePicoclawLockHeld,
|
||||
"message": lockErr.Message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session, sessionErr := GetSessionManager().Register(sessionID, nil)
|
||||
if sessionErr != nil {
|
||||
s.lock.Release(sessionID)
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||
"code": CodePicoclawLockHeld,
|
||||
"message": sessionErr.Message,
|
||||
})
|
||||
return
|
||||
}
|
||||
GetSessionManager().SetState(sessionID, SessionStateConnecting)
|
||||
|
||||
if runtimeErr := s.syncConfigFromPicoclaw(); runtimeErr != nil {
|
||||
s.closeGatewaySession(session, CloseCodeRuntimeUnavailable, runtimeErr.Message)
|
||||
return
|
||||
}
|
||||
|
||||
upstream, gatewayErr := s.connectGateway(sessionID)
|
||||
if gatewayErr != nil {
|
||||
s.closeGatewaySession(session, CloseCodeRuntimeUnavailable, gatewayErr.Message)
|
||||
return
|
||||
}
|
||||
|
||||
downstream, err := gatewayUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Errorf("failed to upgrade gateway websocket: %s", err)
|
||||
_ = upstream.Close()
|
||||
ReleaseSession(sessionID)
|
||||
GetSessionManager().SetState(sessionID, SessionStateClosed)
|
||||
GetSessionManager().Remove(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
GetSessionManager().AttachUpstream(sessionID, upstream)
|
||||
GetSessionManager().AttachDownstream(sessionID, downstream)
|
||||
GetSessionManager().SetState(sessionID, SessionStateActive)
|
||||
mjpeg.EnableLatestFrameCache()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan relayResult, 2)
|
||||
cfg := s.config.Get()
|
||||
|
||||
s.configureRelayConn(downstream, cfg)
|
||||
s.configureRelayConn(upstream, cfg)
|
||||
|
||||
wg.Add(4)
|
||||
go s.runPingLoop("downstream", session.SessionID, downstream, cfg, &wg)
|
||||
go s.runPingLoop("upstream", session.SessionID, upstream, cfg, &wg)
|
||||
go s.proxyMessages("downstream", session, downstream, cfg, &wg, results)
|
||||
go s.proxyMessages("upstream", session, upstream, cfg, &wg, results)
|
||||
|
||||
result := <-results
|
||||
closeCode := result.CloseCode
|
||||
if closeCode == 0 {
|
||||
closeCode = websocket.CloseNormalClosure
|
||||
}
|
||||
|
||||
if result.Source == "upstream" {
|
||||
if closeCode == websocket.CloseNormalClosure {
|
||||
closeCode = CloseCodeUpstreamClosed
|
||||
}
|
||||
s.closeGatewaySession(session, closeCode, result.Reason)
|
||||
} else {
|
||||
s.closeGatewaySession(session, closeCode, result.Reason)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Service) proxyMessages(source string, session *GatewaySession, src *websocket.Conn, cfg Config, wg *sync.WaitGroup, results chan<- relayResult) {
|
||||
defer wg.Done()
|
||||
|
||||
for {
|
||||
messageType, data, err := src.ReadMessage()
|
||||
if err != nil {
|
||||
results <- relayResult{
|
||||
Source: source,
|
||||
CloseCode: closeCodeFromError(source, err),
|
||||
Reason: closeReasonFromError(err),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if source == "upstream" && messageType == websocket.BinaryMessage {
|
||||
results <- relayResult{
|
||||
Source: source,
|
||||
CloseCode: CloseCodeUpstreamClosed,
|
||||
Reason: "upstream sent unsupported binary message",
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !s.lock.Renew(session.SessionID) {
|
||||
results <- relayResult{
|
||||
Source: source,
|
||||
CloseCode: CloseCodePicoclawTakenOver,
|
||||
Reason: "session lock lost",
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var writeErr error
|
||||
switch source {
|
||||
case "downstream":
|
||||
writeErr = session.writeUpstreamMessage(cfg, messageType, data)
|
||||
default:
|
||||
writeErr = session.writeDownstreamMessage(cfg, messageType, data)
|
||||
}
|
||||
if writeErr != nil {
|
||||
results <- relayResult{
|
||||
Source: source,
|
||||
CloseCode: closeCodeFromError(source, writeErr),
|
||||
Reason: closeReasonFromError(writeErr),
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) runPingLoop(name string, sessionID string, conn *websocket.Conn, cfg Config, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
|
||||
ticker := time.NewTicker(time.Duration(cfg.PingIntervalMs) * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if !s.lock.Renew(sessionID) {
|
||||
writeGatewayClose(conn, CloseCodePicoclawTakenOver, "session lock lost")
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if err := conn.WriteControl(
|
||||
websocket.PingMessage,
|
||||
[]byte(name),
|
||||
time.Now().Add(time.Duration(cfg.WriteTimeoutMs)*time.Millisecond),
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) configureRelayConn(conn *websocket.Conn, cfg Config) {
|
||||
readTimeout := time.Duration(cfg.ReadTimeoutMs) * time.Millisecond
|
||||
conn.SetReadLimit(int64(cfg.MaxMessageBytes))
|
||||
_ = conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) closeGatewaySession(session *GatewaySession, closeCode int, reason string) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
|
||||
session.closeOnce.Do(func() {
|
||||
hadDownstream := session.Downstream != nil
|
||||
|
||||
mjpeg.DisableLatestFrameCache()
|
||||
GetSessionManager().SetState(session.SessionID, SessionStateClosing)
|
||||
|
||||
if session.Upstream != nil {
|
||||
writeGatewayClose(session.Upstream, websocket.CloseNormalClosure, "relay closing")
|
||||
_ = session.Upstream.Close()
|
||||
}
|
||||
if session.Downstream != nil {
|
||||
writeGatewayClose(session.Downstream, closeCode, reason)
|
||||
_ = session.Downstream.Close()
|
||||
}
|
||||
|
||||
if hadDownstream {
|
||||
cleanupPicoclawMediaTempDir()
|
||||
}
|
||||
|
||||
ReleaseSession(session.SessionID)
|
||||
GetSessionManager().SetState(session.SessionID, SessionStateClosed)
|
||||
GetSessionManager().Remove(session.SessionID)
|
||||
|
||||
status := s.runtime.Get()
|
||||
status.CurrentSession = s.lock.Owner()
|
||||
if closeCode == CloseCodeUpstreamClosed || closeCode == CloseCodeRuntimeUnavailable {
|
||||
status.Ready = false
|
||||
if closeCode == CloseCodeUpstreamClosed {
|
||||
status.Status = "unavailable"
|
||||
}
|
||||
status.LastError = reason
|
||||
status.CheckedAt = time.Now()
|
||||
s.runtime.Set(status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeGatewayClose(conn *websocket.Conn, code int, reason string) {
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
message := websocket.FormatCloseMessage(code, reason)
|
||||
_ = conn.WriteControl(websocket.CloseMessage, message, time.Now().Add(2*time.Second))
|
||||
}
|
||||
|
||||
func closeCodeFromError(source string, err error) int {
|
||||
if closeErr, ok := err.(*websocket.CloseError); ok {
|
||||
if closeErr.Code == websocket.CloseNormalClosure || closeErr.Code == websocket.CloseGoingAway {
|
||||
return websocket.CloseNormalClosure
|
||||
}
|
||||
if closeErr.Code == websocket.CloseAbnormalClosure || closeErr.Code == websocket.CloseNoStatusReceived {
|
||||
if source == "upstream" {
|
||||
return CloseCodeUpstreamClosed
|
||||
}
|
||||
return websocket.CloseNormalClosure
|
||||
}
|
||||
return closeErr.Code
|
||||
}
|
||||
|
||||
if source == "upstream" {
|
||||
return CloseCodeUpstreamClosed
|
||||
}
|
||||
return websocket.CloseNormalClosure
|
||||
}
|
||||
|
||||
func closeReasonFromError(err error) string {
|
||||
if err == nil {
|
||||
return "closed"
|
||||
}
|
||||
if closeErr, ok := err.(*websocket.CloseError); ok {
|
||||
if closeErr.Text != "" {
|
||||
return closeErr.Text
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("relay closed: %v", err)
|
||||
}
|
||||
86
server/service/picoclaw/gateway_write.go
Normal file
86
server/service/picoclaw/gateway_write.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type picoGatewayMessage struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
func newPicoGatewayMessage(sessionID string, payload map[string]any) picoGatewayMessage {
|
||||
return picoGatewayMessage{
|
||||
Type: "message.send",
|
||||
ID: uuid.NewString(),
|
||||
SessionID: sessionID,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func newPicoGatewayObservationMessage(sessionID string, payload map[string]any) picoGatewayMessage {
|
||||
return picoGatewayMessage{
|
||||
Type: "observation",
|
||||
ID: uuid.NewString(),
|
||||
SessionID: sessionID,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func (session *GatewaySession) writeUpstreamJSON(cfg Config, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return session.writeUpstreamMessage(cfg, websocket.TextMessage, raw)
|
||||
}
|
||||
|
||||
func (session *GatewaySession) writeDownstreamJSON(cfg Config, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return session.writeDownstreamMessage(cfg, websocket.TextMessage, raw)
|
||||
}
|
||||
|
||||
func (session *GatewaySession) writeUpstreamMessage(cfg Config, messageType int, data []byte) error {
|
||||
if session == nil || session.Upstream == nil {
|
||||
return fmt.Errorf("upstream connection is unavailable")
|
||||
}
|
||||
|
||||
session.upstreamWriteMu.Lock()
|
||||
defer session.upstreamWriteMu.Unlock()
|
||||
|
||||
_ = session.Upstream.SetWriteDeadline(gatewayWriteDeadline(cfg))
|
||||
return session.Upstream.WriteMessage(messageType, data)
|
||||
}
|
||||
|
||||
func (session *GatewaySession) writeDownstreamMessage(cfg Config, messageType int, data []byte) error {
|
||||
if session == nil || session.Downstream == nil {
|
||||
return fmt.Errorf("downstream connection is unavailable")
|
||||
}
|
||||
|
||||
session.downstreamWriteMu.Lock()
|
||||
defer session.downstreamWriteMu.Unlock()
|
||||
|
||||
_ = session.Downstream.SetWriteDeadline(gatewayWriteDeadline(cfg))
|
||||
return session.Downstream.WriteMessage(messageType, data)
|
||||
}
|
||||
|
||||
func gatewayWriteDeadline(cfg Config) time.Time {
|
||||
timeout := time.Duration(cfg.WriteTimeoutMs) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return time.Now().Add(timeout)
|
||||
}
|
||||
271
server/service/picoclaw/keyboard_map.go
Normal file
271
server/service/picoclaw/keyboard_map.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
modifierLeftCtrl = 1 << 0
|
||||
modifierLeftShift = 1 << 1
|
||||
modifierLeftAlt = 1 << 2
|
||||
modifierLeftMeta = 1 << 3
|
||||
modifierRightCtrl = 1 << 4
|
||||
modifierRightShift = 1 << 5
|
||||
modifierRightAlt = 1 << 6
|
||||
modifierRightMeta = 1 << 7
|
||||
)
|
||||
|
||||
var modifierMap = map[string]byte{
|
||||
"ControlLeft": modifierLeftCtrl,
|
||||
"ShiftLeft": modifierLeftShift,
|
||||
"AltLeft": modifierLeftAlt,
|
||||
"MetaLeft": modifierLeftMeta,
|
||||
"ControlRight": modifierRightCtrl,
|
||||
"ShiftRight": modifierRightShift,
|
||||
"AltRight": modifierRightAlt,
|
||||
"MetaRight": modifierRightMeta,
|
||||
}
|
||||
|
||||
var keyAliases = map[string]string{
|
||||
"CTRL": "ControlLeft",
|
||||
"CONTROL": "ControlLeft",
|
||||
"SHIFT": "ShiftLeft",
|
||||
"ALT": "AltLeft",
|
||||
"OPTION": "AltLeft",
|
||||
"META": "MetaLeft",
|
||||
"WIN": "MetaLeft",
|
||||
"WINDOWS": "MetaLeft",
|
||||
"COMMAND": "MetaLeft",
|
||||
"CMD": "MetaLeft",
|
||||
"SUPER": "MetaLeft",
|
||||
"ESC": "Escape",
|
||||
"RETURN": "Enter",
|
||||
"DEL": "Delete",
|
||||
"INS": "Insert",
|
||||
"UP": "ArrowUp",
|
||||
"DOWN": "ArrowDown",
|
||||
"LEFT": "ArrowLeft",
|
||||
"RIGHT": "ArrowRight",
|
||||
"SPACEBAR": "Space",
|
||||
"SPACE": "Space",
|
||||
"TAB": "Tab",
|
||||
"ENTER": "Enter",
|
||||
"BACKSPACE": "Backspace",
|
||||
"PGUP": "PageUp",
|
||||
"PGDN": "PageDown",
|
||||
}
|
||||
|
||||
var keycodeMap = map[string]byte{
|
||||
"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,
|
||||
|
||||
"Digit1": 0x1e, "Digit2": 0x1f, "Digit3": 0x20, "Digit4": 0x21, "Digit5": 0x22,
|
||||
"Digit6": 0x23, "Digit7": 0x24, "Digit8": 0x25, "Digit9": 0x26, "Digit0": 0x27,
|
||||
|
||||
"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,
|
||||
|
||||
"F1": 0x3a, "F2": 0x3b, "F3": 0x3c, "F4": 0x3d, "F5": 0x3e, "F6": 0x3f,
|
||||
"F7": 0x40, "F8": 0x41, "F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
|
||||
|
||||
"PrintScreen": 0x46,
|
||||
"ScrollLock": 0x47,
|
||||
"Pause": 0x48,
|
||||
"Insert": 0x49,
|
||||
"Home": 0x4a,
|
||||
"PageUp": 0x4b,
|
||||
"Delete": 0x4c,
|
||||
"End": 0x4d,
|
||||
"PageDown": 0x4e,
|
||||
"ArrowRight": 0x4f,
|
||||
"ArrowLeft": 0x50,
|
||||
"ArrowDown": 0x51,
|
||||
"ArrowUp": 0x52,
|
||||
|
||||
"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,
|
||||
|
||||
"IntlBackslash": 0x64,
|
||||
"ContextMenu": 0x65,
|
||||
"Power": 0x66,
|
||||
"NumpadEqual": 0x67,
|
||||
"F13": 0x68, "F14": 0x69, "F15": 0x6a, "F16": 0x6b, "F17": 0x6c, "F18": 0x6d,
|
||||
"F19": 0x6e, "F20": 0x6f, "F21": 0x70, "F22": 0x71, "F23": 0x72, "F24": 0x73,
|
||||
|
||||
"Execute": 0x74,
|
||||
"Help": 0x75,
|
||||
"Props": 0x76,
|
||||
"Select": 0x77,
|
||||
"Stop": 0x78,
|
||||
"Again": 0x79,
|
||||
"Undo": 0x7a,
|
||||
"Cut": 0x7b,
|
||||
"Copy": 0x7c,
|
||||
"Paste": 0x7d,
|
||||
"Find": 0x7e,
|
||||
|
||||
"AudioVolumeMute": 0x7f,
|
||||
"AudioVolumeUp": 0x80,
|
||||
"AudioVolumeDown": 0x81,
|
||||
"VolumeMute": 0x7f,
|
||||
"VolumeUp": 0x80,
|
||||
"VolumeDown": 0x81,
|
||||
|
||||
"LockingCapsLock": 0x82,
|
||||
"LockingNumLock": 0x83,
|
||||
"LockingScrollLock": 0x84,
|
||||
"NumpadComma": 0x85,
|
||||
"NumpadEqual2": 0x86,
|
||||
"IntlRo": 0x87,
|
||||
"KanaMode": 0x88,
|
||||
"IntlYen": 0x89,
|
||||
"Convert": 0x8a,
|
||||
"NonConvert": 0x8b,
|
||||
"International6": 0x8c,
|
||||
"International7": 0x8d,
|
||||
"International8": 0x8e,
|
||||
"International9": 0x8f,
|
||||
"Lang1": 0x90, "Lang2": 0x91, "Lang3": 0x92, "Lang4": 0x93, "Lang5": 0x94,
|
||||
"Lang6": 0x95, "Lang7": 0x96, "Lang8": 0x97, "Lang9": 0x98,
|
||||
"IntlHash": 0x32,
|
||||
|
||||
"NumpadParenLeft": 0xb6,
|
||||
"NumpadParenRight": 0xb7,
|
||||
"NumpadBackspace": 0xbb,
|
||||
"NumpadMemoryStore": 0xd0,
|
||||
"NumpadMemoryRecall": 0xd1,
|
||||
"NumpadMemoryClear": 0xd2,
|
||||
"NumpadMemoryAdd": 0xd3,
|
||||
"NumpadMemorySubtract": 0xd4,
|
||||
"NumpadClear": 0xd8,
|
||||
"NumpadClearEntry": 0xd9,
|
||||
|
||||
"BrowserSearch": 0xf0,
|
||||
"BrowserHome": 0xf1,
|
||||
"BrowserBack": 0xf2,
|
||||
"BrowserForward": 0xf3,
|
||||
"BrowserStop": 0xf4,
|
||||
"BrowserRefresh": 0xf5,
|
||||
"BrowserFavorites": 0xf6,
|
||||
|
||||
"MediaPlayPause": 0xe8,
|
||||
"MediaStop": 0xe9,
|
||||
"MediaTrackPrevious": 0xea,
|
||||
"MediaTrackNext": 0xeb,
|
||||
"Eject": 0xec,
|
||||
"MediaSelect": 0xed,
|
||||
"LaunchMail": 0xee,
|
||||
"LaunchApp1": 0xef,
|
||||
"LaunchApp2": 0xf0,
|
||||
"Sleep": 0xf8,
|
||||
"Wake": 0xf9,
|
||||
"MediaRewind": 0xfa,
|
||||
"MediaFastForward": 0xfb,
|
||||
}
|
||||
|
||||
func resolveKey(key string) (string, byte, bool, error) {
|
||||
trimmed := strings.TrimSpace(key)
|
||||
if trimmed == "" {
|
||||
return "", 0, false, fmt.Errorf("empty key")
|
||||
}
|
||||
|
||||
if modifier, ok := modifierMap[trimmed]; ok {
|
||||
return trimmed, modifier, true, nil
|
||||
}
|
||||
if _, ok := keycodeMap[trimmed]; ok {
|
||||
return trimmed, keycodeMap[trimmed], false, nil
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(trimmed)
|
||||
if alias, ok := keyAliases[upper]; ok {
|
||||
if modifier, ok := modifierMap[alias]; ok {
|
||||
return alias, modifier, true, nil
|
||||
}
|
||||
return alias, keycodeMap[alias], false, nil
|
||||
}
|
||||
|
||||
if len(upper) == 1 && upper[0] >= 'A' && upper[0] <= 'Z' {
|
||||
code := "Key" + upper
|
||||
return code, keycodeMap[code], false, nil
|
||||
}
|
||||
if len(upper) == 1 && upper[0] >= '0' && upper[0] <= '9' {
|
||||
code := "Digit" + upper
|
||||
return code, keycodeMap[code], false, nil
|
||||
}
|
||||
|
||||
if modifier, ok := modifierMap[upper]; ok {
|
||||
return upper, modifier, true, nil
|
||||
}
|
||||
if code, ok := keycodeMap[trimmed]; ok {
|
||||
return trimmed, code, false, nil
|
||||
}
|
||||
if code, ok := keycodeMap[upper]; ok {
|
||||
return upper, code, false, nil
|
||||
}
|
||||
|
||||
return "", 0, false, fmt.Errorf("unsupported key: %s", key)
|
||||
}
|
||||
|
||||
func buildHotkeyReport(keys []string) ([]byte, *PicoclawError) {
|
||||
if len(keys) == 0 {
|
||||
return nil, newPicoclawError(CodeInvalidAction, "hotkey requires at least one key")
|
||||
}
|
||||
|
||||
report := make([]byte, 8)
|
||||
nextIndex := 2
|
||||
|
||||
for _, key := range keys {
|
||||
_, code, isModifier, err := resolveKey(key)
|
||||
if err != nil {
|
||||
return nil, newPicoclawError(CodeInvalidAction, err.Error())
|
||||
}
|
||||
|
||||
if isModifier {
|
||||
report[0] |= code
|
||||
continue
|
||||
}
|
||||
|
||||
if nextIndex >= len(report) {
|
||||
return nil, newPicoclawError(CodeInvalidAction, "hotkey supports at most 6 non-modifier keys")
|
||||
}
|
||||
report[nextIndex] = code
|
||||
nextIndex++
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
102
server/service/picoclaw/load_image_instruction.go
Normal file
102
server/service/picoclaw/load_image_instruction.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultImagePrompt = "Describe the image briefly."
|
||||
)
|
||||
|
||||
func (s *Service) LoadImage(c *gin.Context) {
|
||||
var req LoadImageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid load image payload"))
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, session, sessionErr := s.requireActiveGatewaySession(c)
|
||||
if sessionErr != nil {
|
||||
writePicoclawError(c, sessionErr)
|
||||
return
|
||||
}
|
||||
|
||||
sourcePath, pathErr := normalizeLoadImagePath(req.Path)
|
||||
if pathErr != nil {
|
||||
writePicoclawError(c, pathErr)
|
||||
return
|
||||
}
|
||||
|
||||
instruction := buildLoadImageInstruction(sourcePath, req.Prompt)
|
||||
message := newPicoGatewayMessage(sessionID, map[string]any{
|
||||
"content": instruction,
|
||||
})
|
||||
if err := session.writeUpstreamJSON(s.config.Get(), message); err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to deliver image instruction to picoclaw"))
|
||||
return
|
||||
}
|
||||
|
||||
writeSuccess(c, gin.H{
|
||||
"accepted": true,
|
||||
"session_id": sessionID,
|
||||
"message_id": message.ID,
|
||||
"path": sourcePath,
|
||||
"user_prompt": normalizeImagePrompt(req.Prompt),
|
||||
"instructed_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) requireActiveGatewaySession(c *gin.Context) (string, *GatewaySession, *PicoclawError) {
|
||||
sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader))
|
||||
if sessionID == "" {
|
||||
sessionID = strings.TrimSpace(s.lock.Owner())
|
||||
}
|
||||
if sessionID == "" {
|
||||
return "", nil, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID")
|
||||
}
|
||||
|
||||
session, ok := GetSessionManager().Get(sessionID)
|
||||
if !ok || session == nil {
|
||||
err := newPicoclawError(CodeRuntimeUnavailable, "picoclaw session is not connected")
|
||||
err.SessionID = sessionID
|
||||
return "", nil, err
|
||||
}
|
||||
if session.State != SessionStateActive || session.Upstream == nil {
|
||||
err := newPicoclawError(CodeRuntimeUnavailable, "picoclaw session is not active")
|
||||
err.SessionID = sessionID
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return sessionID, session, nil
|
||||
}
|
||||
|
||||
func normalizeLoadImagePath(sourcePath string) (string, *PicoclawError) {
|
||||
sourcePath = strings.TrimSpace(sourcePath)
|
||||
if sourcePath == "" {
|
||||
return "", newPicoclawError(CodeInvalidAction, "image path is required")
|
||||
}
|
||||
|
||||
return sourcePath, nil
|
||||
}
|
||||
|
||||
func buildLoadImageInstruction(sourcePath string, prompt string) string {
|
||||
argsJSON, _ := json.Marshal(map[string]string{"path": sourcePath})
|
||||
return fmt.Sprintf(
|
||||
"Call `load_image` first with this exact argument object:\n```json\n%s\n```\n\nDo not ask the user to re-upload the image. Do not modify the path. Do not use `read_file` or any other tool to inspect this image directly.\n\nAfter `load_image` succeeds, treat the loaded image as the current task input and continue with this request:\n%s",
|
||||
string(argsJSON),
|
||||
normalizeImagePrompt(prompt),
|
||||
)
|
||||
}
|
||||
|
||||
func normalizeImagePrompt(prompt string) string {
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
return defaultImagePrompt
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
299
server/service/picoclaw/mcp_handler.go
Normal file
299
server/service/picoclaw/mcp_handler.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// JSON-RPC 2.0 types for MCP HTTP transport
|
||||
|
||||
type jsonRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type jsonRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error *jsonRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type jsonRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// MCP tool definitions
|
||||
|
||||
var mcpToolDefinitions = []map[string]interface{}{
|
||||
{
|
||||
"name": "kvm_screenshot",
|
||||
"description": "Capture the current HDMI frame from the downstream remote host as a base64-encoded JPEG image.",
|
||||
"inputSchema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"width": map[string]interface{}{"type": "integer", "description": "Target width in pixels (optional, default: 960)"},
|
||||
"height": map[string]interface{}{"type": "integer", "description": "Target height in pixels (optional)"},
|
||||
"quality": map[string]interface{}{"type": "integer", "description": "JPEG quality 1-100 (optional, default: 60)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "kvm_actions",
|
||||
"description": "Send one or more HID actions (click, type, hotkey, scroll, drag, move, wait) to the downstream remote host. Use normalized [0,1] coordinates for mouse actions.",
|
||||
"inputSchema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"actions": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "Array of action objects. Each requires an 'action' field (click, move, type, hotkey, scroll, drag, wait).",
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{"type": "string"},
|
||||
"x": map[string]interface{}{"type": "number"},
|
||||
"y": map[string]interface{}{"type": "number"},
|
||||
"button": map[string]interface{}{"type": "string"},
|
||||
"text": map[string]interface{}{"type": "string"},
|
||||
"keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||
"direction": map[string]interface{}{"type": "string"},
|
||||
"amount": map[string]interface{}{"type": "integer"},
|
||||
"duration_ms": map[string]interface{}{"type": "integer"},
|
||||
"from": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
|
||||
"to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"actions"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// MCPHandler handles MCP HTTP transport (JSON-RPC 2.0 over POST).
|
||||
func (s *Service) MCPHandler(c *gin.Context) {
|
||||
var req jsonRPCRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &jsonRPCError{Code: -32700, Message: "parse error"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.JSONRPC != "2.0" {
|
||||
c.JSON(http.StatusOK, jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &jsonRPCError{Code: -32600, Message: "invalid request: jsonrpc must be 2.0"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var resp jsonRPCResponse
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
resp = s.mcpInitialize(req)
|
||||
case "tools/list":
|
||||
resp = s.mcpToolsList(req)
|
||||
case "tools/call":
|
||||
resp = s.mcpToolsCall(req, c)
|
||||
case "ping":
|
||||
resp = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{}}
|
||||
default:
|
||||
resp = jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &jsonRPCError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)},
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Service) mcpInitialize(req jsonRPCRequest) jsonRPCResponse {
|
||||
return jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]interface{}{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]interface{}{
|
||||
"tools": map[string]interface{}{},
|
||||
},
|
||||
"serverInfo": map[string]interface{}{
|
||||
"name": "nanokvm",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) mcpToolsList(req jsonRPCRequest) jsonRPCResponse {
|
||||
return jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]interface{}{
|
||||
"tools": mcpToolDefinitions,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) mcpToolsCall(req jsonRPCRequest, c *gin.Context) jsonRPCResponse {
|
||||
var params struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &jsonRPCError{Code: -32602, Message: "invalid params"},
|
||||
}
|
||||
}
|
||||
|
||||
switch params.Name {
|
||||
case "kvm_screenshot":
|
||||
return s.mcpScreenshot(req, params.Arguments, c)
|
||||
case "kvm_actions":
|
||||
return s.mcpActions(req, params.Arguments, c)
|
||||
default:
|
||||
return jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &jsonRPCError{Code: -32602, Message: fmt.Sprintf("unknown tool: %s", params.Name)},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) mcpScreenshot(req jsonRPCRequest, args json.RawMessage, c *gin.Context) jsonRPCResponse {
|
||||
var params struct {
|
||||
Width uint16 `json:"width"`
|
||||
Height uint16 `json:"height"`
|
||||
Quality uint16 `json:"quality"`
|
||||
}
|
||||
if args != nil {
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
}
|
||||
|
||||
query := ScreenshotQuery{
|
||||
Format: "base64",
|
||||
Width: params.Width,
|
||||
Height: params.Height,
|
||||
Quality: params.Quality,
|
||||
}
|
||||
|
||||
data, meta, err := s.captureScreenshot(query)
|
||||
if err != nil {
|
||||
return mcpToolError(req, err.Message)
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
s.publishMCPObservation(c, "screenshot captured", b64)
|
||||
|
||||
return jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]interface{}{
|
||||
"content": []map[string]interface{}{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "screenshot captured",
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"data": b64,
|
||||
"mimeType": "image/jpeg",
|
||||
},
|
||||
},
|
||||
"meta": map[string]interface{}{
|
||||
"source_width": meta.SourceWidth,
|
||||
"source_height": meta.SourceHeight,
|
||||
"capture_width": meta.CaptureWidth,
|
||||
"capture_height": meta.CaptureHeight,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishMCPObservation(c *gin.Context, text string, imageBase64 string) {
|
||||
if strings.TrimSpace(imageBase64) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, session, err := s.requireActiveGatewaySession(c)
|
||||
if err != nil || session == nil || session.Downstream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
message := newPicoGatewayObservationMessage(sessionID, map[string]any{
|
||||
"content": text,
|
||||
"image_base64": imageBase64,
|
||||
"mime_type": "image/jpeg",
|
||||
})
|
||||
if writeErr := session.writeDownstreamJSON(s.config.Get(), message); writeErr != nil {
|
||||
log.Warnf("failed to deliver picoclaw observation message: %v", writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) mcpActions(req jsonRPCRequest, args json.RawMessage, c *gin.Context) jsonRPCResponse {
|
||||
var params struct {
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return mcpToolError(req, "invalid actions payload")
|
||||
}
|
||||
if len(params.Actions) == 0 {
|
||||
return mcpToolError(req, "empty actions array")
|
||||
}
|
||||
|
||||
sessionID := c.GetHeader(sessionIDHeader)
|
||||
if sessionID == "" {
|
||||
sessionID = s.lock.Owner()
|
||||
}
|
||||
|
||||
result, err := s.executeActions(sessionID, params.Actions)
|
||||
if err != nil {
|
||||
return mcpToolError(req, err.Message)
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
return jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]interface{}{
|
||||
"content": []map[string]interface{}{
|
||||
{
|
||||
"type": "text",
|
||||
"text": string(resultJSON),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mcpToolError(req jsonRPCRequest, message string) jsonRPCResponse {
|
||||
return jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": []map[string]interface{}{
|
||||
{
|
||||
"type": "text",
|
||||
"text": message,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
225
server/service/picoclaw/model_config.go
Normal file
225
server/service/picoclaw/model_config.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func extractPicoclawModelName(model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if index := strings.LastIndex(model, "/"); index >= 0 && index < len(model)-1 {
|
||||
return strings.TrimSpace(model[index+1:])
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
func isPicoclawModelConfigured(cfg picoclawConfigFile, security picoclawSecurityConfig, modelName string) bool {
|
||||
if modelName == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, model := range cfg.ModelList {
|
||||
if strings.TrimSpace(model.ModelName) != modelName {
|
||||
continue
|
||||
}
|
||||
if model.APIBase == "" {
|
||||
continue
|
||||
}
|
||||
if securityHasModelAPIKeys(security, modelName) {
|
||||
return true
|
||||
}
|
||||
return configHasModelAPIKeys(model.APIKey, model.APIKeys)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func configHasModelAPIKeys(apiKey string, apiKeys []string) bool {
|
||||
return strings.TrimSpace(apiKey) != "" || len(apiKeys) > 0
|
||||
}
|
||||
|
||||
func securityHasModelAPIKeys(security picoclawSecurityConfig, modelName string) bool {
|
||||
if modelName == "" || len(security.ModelList) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if entry, ok := security.ModelList[modelName]; ok && len(entry.APIKeys) > 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
prefix := modelName + ":"
|
||||
for key, entry := range security.ModelList {
|
||||
if strings.HasPrefix(key, prefix) && len(entry.APIKeys) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type ModelConfigUpdateRequest struct {
|
||||
Model string `json:"model"`
|
||||
APIBase string `json:"api_base"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
func (s *Service) UpdateModelConfig(c *gin.Context) {
|
||||
var req ModelConfigUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid model config payload"))
|
||||
return
|
||||
}
|
||||
|
||||
currentStatus := s.runtime.Get()
|
||||
shouldRestart := currentStatus.Ready || currentStatus.Status == "ready"
|
||||
|
||||
modelName, err := updatePicoclawModelConfig(
|
||||
strings.TrimSpace(req.APIBase),
|
||||
strings.TrimSpace(req.APIKey),
|
||||
strings.TrimSpace(req.Model),
|
||||
)
|
||||
if err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := ensurePicoclawStartupDefaults(); err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to persist picoclaw defaults: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if shouldRestart {
|
||||
if _, _, stopErr := s.stopRuntime(); stopErr != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+stopErr.Message))
|
||||
return
|
||||
}
|
||||
if _, _, startErr := s.startRuntime(); startErr != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+startErr.Message))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_ = s.syncConfigFromPicoclaw()
|
||||
_ = s.ensureRuntimeReady()
|
||||
}
|
||||
|
||||
writeSuccess(c, gin.H{
|
||||
"model_name": modelName,
|
||||
"status": s.runtime.Get(),
|
||||
})
|
||||
}
|
||||
|
||||
func updatePicoclawModelConfig(apiBase string, apiKey string, model string) (string, error) {
|
||||
if apiBase == "" {
|
||||
return "", fmt.Errorf("model api_base is required")
|
||||
}
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("model api_key is required")
|
||||
}
|
||||
if model == "" {
|
||||
return "", fmt.Errorf("model identifier is required")
|
||||
}
|
||||
|
||||
modelName := extractPicoclawModelName(model)
|
||||
if modelName == "" {
|
||||
return "", fmt.Errorf("model identifier is required")
|
||||
}
|
||||
|
||||
doc, err := loadPicoclawConfigDocument()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
modelListValue, ok := doc.raw["model_list"].([]any)
|
||||
if !ok {
|
||||
modelListValue = []any{}
|
||||
}
|
||||
|
||||
modelUpdated := false
|
||||
updatedModelIndex := -1
|
||||
for index, item := range modelListValue {
|
||||
modelMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
currentModelName := strings.TrimSpace(fmt.Sprintf("%v", modelMap["model_name"]))
|
||||
if currentModelName != modelName {
|
||||
continue
|
||||
}
|
||||
modelMap["model_name"] = modelName
|
||||
modelMap["model"] = model
|
||||
modelMap["api_base"] = apiBase
|
||||
delete(modelMap, "api_key")
|
||||
delete(modelMap, "api_keys")
|
||||
modelUpdated = true
|
||||
updatedModelIndex = index
|
||||
break
|
||||
}
|
||||
if !modelUpdated {
|
||||
modelListValue = append(modelListValue, map[string]any{
|
||||
"model_name": modelName,
|
||||
"model": model,
|
||||
"api_base": apiBase,
|
||||
})
|
||||
doc.raw["model_list"] = modelListValue
|
||||
updatedModelIndex = len(modelListValue) - 1
|
||||
}
|
||||
|
||||
agents, ok := doc.raw["agents"].(map[string]any)
|
||||
if !ok {
|
||||
agents = map[string]any{}
|
||||
doc.raw["agents"] = agents
|
||||
}
|
||||
defaults, ok := agents["defaults"].(map[string]any)
|
||||
if !ok {
|
||||
defaults = map[string]any{}
|
||||
agents["defaults"] = defaults
|
||||
}
|
||||
defaults["model_name"] = modelName
|
||||
delete(defaults, "model")
|
||||
|
||||
if err := doc.saveConfig(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if doc.security.ModelList == nil {
|
||||
doc.security.ModelList = map[string]picoclawModelSecurityEntry{}
|
||||
}
|
||||
securityModelName := indexedModelName(modelListValue, updatedModelIndex, modelName)
|
||||
doc.security.ModelList[securityModelName] = picoclawModelSecurityEntry{
|
||||
APIKeys: []string{apiKey},
|
||||
}
|
||||
if err := doc.saveSecurity(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return modelName, nil
|
||||
}
|
||||
|
||||
func indexedModelName(modelList []any, targetIndex int, modelName string) string {
|
||||
if targetIndex < 0 {
|
||||
return modelName
|
||||
}
|
||||
|
||||
currentIndex := 0
|
||||
for index, item := range modelList {
|
||||
modelMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
currentModelName := strings.TrimSpace(fmt.Sprintf("%v", modelMap["model_name"]))
|
||||
if currentModelName != modelName {
|
||||
continue
|
||||
}
|
||||
if index == targetIndex {
|
||||
return fmt.Sprintf("%s:%d", modelName, currentIndex)
|
||||
}
|
||||
currentIndex++
|
||||
}
|
||||
|
||||
return modelName
|
||||
}
|
||||
22
server/service/picoclaw/runtime_constants.go
Normal file
22
server/service/picoclaw/runtime_constants.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
picoclawBinaryPath = "/usr/bin/picoclaw"
|
||||
picoclawCacheDir = "/root/.picoclaw-cache"
|
||||
picoclawDownloadURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.6/picoclaw_Linux_riscv64.tar.gz"
|
||||
picoclawChecksumURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.6/sha512.txt"
|
||||
etcInitPicoclawScript = "/etc/init.d/S96picoclaw"
|
||||
kvmappPicoclawScript = "/kvmapp/system/init.d/S96picoclaw"
|
||||
picoclawStartTimeout = 15 * time.Second
|
||||
picoclawStopTimeout = 15 * time.Second
|
||||
picoclawOnboardTimeout = 60 * time.Second
|
||||
picoclawInstallTimeout = 12 * time.Minute
|
||||
picoclawDownloadTimeout = 10 * time.Minute
|
||||
picoclawStartWaitPeriod = 1500 * time.Millisecond
|
||||
picoclawStopWaitPeriod = 500 * time.Millisecond
|
||||
picoclawReadyPollPeriod = 500 * time.Millisecond
|
||||
)
|
||||
227
server/service/picoclaw/runtime_defaults.go
Normal file
227
server/service/picoclaw/runtime_defaults.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
serverConfig "NanoKVM-Server/config"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPicoclawPingSec = 30
|
||||
defaultPicoclawReadSec = 60
|
||||
defaultPicoclawWriteSec = 10
|
||||
defaultPicoclawMaxConns = 100
|
||||
)
|
||||
|
||||
type picoclawConfigDefault struct {
|
||||
path []string
|
||||
value any
|
||||
}
|
||||
|
||||
var picoclawNanoKVMDefaults = []picoclawConfigDefault{
|
||||
{path: []string{"agents", "defaults", "restrict_to_workspace"}, value: false},
|
||||
{path: []string{"agents", "defaults", "allow_read_outside_workspace"}, value: true},
|
||||
{path: []string{"agents", "defaults", "tool_feedback", "enabled"}, value: true},
|
||||
{path: []string{"gateway", "host"}, value: defaultPicoclawGatewayHost},
|
||||
{path: []string{"gateway", "port"}, value: defaultPicoclawGatewayPort},
|
||||
{path: []string{"gateway", "hot_reload"}, value: false},
|
||||
{path: []string{"tools", "cron", "allow_command"}, value: true},
|
||||
{path: []string{"tools", "exec", "allow_remote"}, value: true},
|
||||
{path: []string{"tools", "exec", "enable_deny_patterns"}, value: false},
|
||||
{path: []string{"channels", "pico", "allow_token_query"}, value: false},
|
||||
{path: []string{"channels", "pico", "ping_interval"}, value: defaultPicoclawPingSec},
|
||||
{path: []string{"channels", "pico", "read_timeout"}, value: defaultPicoclawReadSec},
|
||||
{path: []string{"channels", "pico", "write_timeout"}, value: defaultPicoclawWriteSec},
|
||||
{path: []string{"channels", "pico", "max_connections"}, value: defaultPicoclawMaxConns},
|
||||
{path: []string{"tools", "mcp", "enabled"}, value: true},
|
||||
}
|
||||
|
||||
func ensurePicoclawStartupDefaults() error {
|
||||
doc, err := loadPicoclawConfigDocument()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
editor := &picoclawConfigEditor{raw: doc.raw}
|
||||
forceEnablePicoclawPicoChannel(doc, editor)
|
||||
if err := applyPicoclawStartupDefaults(editor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tokenChanged, err := ensurePicoclawPicoToken(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tokenChanged {
|
||||
if err := doc.saveSecurity(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if editor.changed {
|
||||
if err := doc.saveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePicoclawPicoChannelEnabled(doc *picoclawConfigDocument) error {
|
||||
if doc == nil || doc.config.Channels.Pico.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
editor := &picoclawConfigEditor{raw: doc.raw}
|
||||
forceEnablePicoclawPicoChannel(doc, editor)
|
||||
if !editor.changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return doc.saveConfig()
|
||||
}
|
||||
|
||||
func applyPicoclawStartupDefaults(editor *picoclawConfigEditor) error {
|
||||
for _, entry := range picoclawNanoKVMDefaults {
|
||||
editor.setValue(entry.value, entry.path...)
|
||||
}
|
||||
server, err := defaultPicoclawMCPServer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
editor.setMCPServer("nanokvm", server)
|
||||
return nil
|
||||
}
|
||||
|
||||
func forceEnablePicoclawPicoChannel(doc *picoclawConfigDocument, editor *picoclawConfigEditor) {
|
||||
if doc == nil || editor == nil || doc.config.Channels.Pico.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
editor.setValue(true, "channels", "pico", "enabled")
|
||||
doc.config.Channels.Pico.Enabled = true
|
||||
}
|
||||
|
||||
func defaultPicoclawMCPServer() (map[string]any, error) {
|
||||
internalToken, err := serverConfig.GetPicoclawInternalToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"enabled": true,
|
||||
"type": "http",
|
||||
"url": nanoKVMMCPURL(),
|
||||
"headers": map[string]any{
|
||||
serverConfig.PicoclawInternalTokenHeader: internalToken,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func nanoKVMMCPURL() string {
|
||||
port := serverConfig.GetInstance().Port.Http
|
||||
if port <= 0 {
|
||||
port = 80
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://%s:%d/api/picoclaw/mcp", defaultPicoclawGatewayHost, port)
|
||||
}
|
||||
|
||||
type picoclawConfigEditor struct {
|
||||
raw map[string]any
|
||||
changed bool
|
||||
}
|
||||
|
||||
func (e *picoclawConfigEditor) setValue(value any, path ...string) {
|
||||
if len(path) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
target := e.ensureObject(path[:len(path)-1]...)
|
||||
key := path[len(path)-1]
|
||||
if current, exists := target[key]; exists && reflect.DeepEqual(current, value) {
|
||||
return
|
||||
}
|
||||
|
||||
target[key] = value
|
||||
e.changed = true
|
||||
}
|
||||
|
||||
func (e *picoclawConfigEditor) ensureObject(path ...string) map[string]any {
|
||||
current := e.raw
|
||||
for _, key := range path {
|
||||
next, ok := current[key].(map[string]any)
|
||||
if !ok {
|
||||
next = map[string]any{}
|
||||
current[key] = next
|
||||
e.changed = true
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func (e *picoclawConfigEditor) setMCPServer(name string, fields map[string]any) {
|
||||
servers := e.ensureObject("tools", "mcp", "servers")
|
||||
entry, ok := servers[name].(map[string]any)
|
||||
if !ok {
|
||||
entry = map[string]any{}
|
||||
servers[name] = entry
|
||||
e.changed = true
|
||||
}
|
||||
|
||||
for key, value := range fields {
|
||||
if current, exists := entry[key]; exists && reflect.DeepEqual(current, value) {
|
||||
continue
|
||||
}
|
||||
entry[key] = value
|
||||
e.changed = true
|
||||
}
|
||||
}
|
||||
|
||||
func generatePicoclawToken() (string, error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("failed to generate picoclaw pico token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func ensurePicoclawPicoToken(doc *picoclawConfigDocument) (bool, error) {
|
||||
token, err := picoSecurityToken(doc)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if doc.security.Channels.Pico == nil {
|
||||
doc.security.Channels.Pico = &picoclawPicoSecurity{}
|
||||
}
|
||||
if strings.TrimSpace(doc.security.Channels.Pico.Token) == token {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
doc.security.Channels.Pico.Token = token
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func picoSecurityToken(doc *picoclawConfigDocument) (string, error) {
|
||||
if doc == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if doc.security.Channels.Pico != nil {
|
||||
if token := strings.TrimSpace(doc.security.Channels.Pico.Token); token != "" {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
|
||||
if token := strings.TrimSpace(doc.config.Channels.Pico.Token); token != "" {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
return generatePicoclawToken()
|
||||
}
|
||||
110
server/service/picoclaw/runtime_handlers.go
Normal file
110
server/service/picoclaw/runtime_handlers.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *Service) StartRuntime(c *gin.Context) {
|
||||
command, output, err := s.startRuntime()
|
||||
if err != nil {
|
||||
writePicoclawError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeSuccess(c, RuntimeStartResult{
|
||||
Started: true,
|
||||
Command: command,
|
||||
Output: output,
|
||||
Status: s.runtime.Get(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) StopRuntime(c *gin.Context) {
|
||||
command, output, err := s.stopRuntime()
|
||||
if err != nil {
|
||||
writePicoclawError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeSuccess(c, RuntimeStartResult{
|
||||
Started: false,
|
||||
Command: command,
|
||||
Output: output,
|
||||
Status: s.runtime.Get(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) InstallRuntime(c *gin.Context) {
|
||||
output, err := s.installRuntime()
|
||||
if err != nil {
|
||||
writePicoclawError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
currentStatus := s.runtime.Get()
|
||||
writeSuccess(c, RuntimeInstallResult{
|
||||
Installed: currentStatus.Installed && !currentStatus.Installing,
|
||||
Binary: picoclawBinaryPath,
|
||||
Download: picoclawDownloadURL,
|
||||
Output: output,
|
||||
Status: currentStatus,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) UninstallRuntime(c *gin.Context) {
|
||||
currentStatus := s.runtime.Get()
|
||||
uninstallOutput := "picoclaw uninstalled successfully"
|
||||
if currentStatus.Installing {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeStartFailed, "cannot uninstall while installation is in progress"))
|
||||
return
|
||||
}
|
||||
|
||||
running, err := isRuntimeRunning()
|
||||
if err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw runtime status"))
|
||||
return
|
||||
}
|
||||
|
||||
if running || currentStatus.Ready || currentStatus.Status == "ready" {
|
||||
if _, _, stopErr := s.stopRuntime(); stopErr != nil {
|
||||
uninstallOutput = "picoclaw uninstalled successfully (stop failed before uninstall: " + stopErr.Message + ")"
|
||||
}
|
||||
}
|
||||
|
||||
if configPath, err := resolvePicoclawConfigPath(); err == nil {
|
||||
_ = os.RemoveAll(filepath.Dir(configPath))
|
||||
} else {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
_ = os.RemoveAll(filepath.Join(home, ".picoclaw"))
|
||||
} else {
|
||||
_ = os.RemoveAll("/root/.picoclaw")
|
||||
}
|
||||
}
|
||||
_ = os.Remove(picoclawBinaryPath)
|
||||
_ = os.RemoveAll(picoclawCacheDir)
|
||||
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: false,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallStage: "",
|
||||
InstallPath: picoclawBinaryPath,
|
||||
Status: "not_installed",
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: "",
|
||||
})
|
||||
|
||||
writeSuccess(c, RuntimeInstallResult{
|
||||
Installed: false,
|
||||
Binary: picoclawBinaryPath,
|
||||
Download: picoclawDownloadURL,
|
||||
Output: uninstallOutput,
|
||||
Status: s.runtime.Get(),
|
||||
})
|
||||
}
|
||||
441
server/service/picoclaw/runtime_install.go
Normal file
441
server/service/picoclaw/runtime_install.go
Normal file
@@ -0,0 +1,441 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (s *Service) installRuntime() (string, *PicoclawError) {
|
||||
log.Debugf("picoclaw install: start, binary=%s, cache=%s", picoclawBinaryPath, picoclawCacheDir)
|
||||
|
||||
currentStatus := s.runtime.Get()
|
||||
if currentStatus.Installing {
|
||||
log.Debugf("picoclaw install: install already in progress")
|
||||
return "picoclaw installation is already in progress", nil
|
||||
}
|
||||
|
||||
if installed, err := isPicoclawInstalled(); err == nil && installed {
|
||||
settings, _ := loadPicoclawGatewaySettings()
|
||||
log.Debugf("picoclaw install: binary already exists at %s", picoclawBinaryPath)
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 100,
|
||||
InstallStage: "installed",
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: settings.ModelConfigured,
|
||||
ModelName: settings.ModelName,
|
||||
Status: "installed",
|
||||
CheckedAt: time.Now(),
|
||||
})
|
||||
return "picoclaw is already installed", nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), picoclawInstallTimeout)
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: false,
|
||||
Installing: true,
|
||||
InstallProgress: 0,
|
||||
InstallStage: "preparing",
|
||||
InstallPath: picoclawBinaryPath,
|
||||
Status: "installing",
|
||||
CheckedAt: time.Now(),
|
||||
})
|
||||
|
||||
go s.runInstallRuntime(ctx, cancel)
|
||||
return "picoclaw installation started", nil
|
||||
}
|
||||
|
||||
func (s *Service) runInstallRuntime(ctx context.Context, cancel context.CancelFunc) {
|
||||
defer cancel()
|
||||
|
||||
_ = os.RemoveAll(picoclawCacheDir)
|
||||
if err := os.MkdirAll(picoclawCacheDir, 0o755); err != nil {
|
||||
log.Errorf("picoclaw install: failed to create cache directory %s: %v", picoclawCacheDir, err)
|
||||
s.finishInstallFailure("install_failed", fmt.Sprintf("failed to create cache directory: %v", err))
|
||||
return
|
||||
}
|
||||
log.Debugf("picoclaw install: cache directory ready at %s", picoclawCacheDir)
|
||||
defer func() {
|
||||
if err := os.RemoveAll(picoclawCacheDir); err != nil {
|
||||
log.Errorf("picoclaw install: failed to clean cache directory %s: %v", picoclawCacheDir, err)
|
||||
return
|
||||
}
|
||||
log.Debugf("picoclaw install: cleaned cache directory %s", picoclawCacheDir)
|
||||
}()
|
||||
|
||||
s.setInstallProgress("downloading", 5, "")
|
||||
log.Debugf("picoclaw install: downloading checksum from %s", picoclawChecksumURL)
|
||||
expectedDigest, err := downloadPicoclawChecksum(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("picoclaw install: checksum download failed: %v", err)
|
||||
s.finishInstallFailure(installFailureStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
log.Debug("picoclaw install: checksum file downloaded")
|
||||
|
||||
archivePath := filepath.Join(picoclawCacheDir, "picoclaw.tar.gz")
|
||||
log.Debugf("picoclaw install: downloading archive from %s to %s", picoclawDownloadURL, archivePath)
|
||||
if err := downloadPicoclawArchive(ctx, archivePath, func(downloaded int64, total int64) {
|
||||
progress := 10
|
||||
if total > 0 {
|
||||
progress = 10 + int(float64(downloaded)*70/float64(total))
|
||||
if progress > 80 {
|
||||
progress = 80
|
||||
}
|
||||
}
|
||||
s.setInstallProgress("downloading", progress, "")
|
||||
}); err != nil {
|
||||
log.Errorf("picoclaw install: download failed: %v", err)
|
||||
s.finishInstallFailure(installFailureStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
log.Debugf("picoclaw install: archive download completed")
|
||||
|
||||
s.setInstallProgress("verifying", 82, "")
|
||||
|
||||
if err := verifyFileSHA512(archivePath, expectedDigest); err != nil {
|
||||
log.Errorf("picoclaw install: checksum verification failed: %v", err)
|
||||
s.finishInstallFailure(installFailureStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
log.Debugf("picoclaw install: archive checksum verified for %s", archivePath)
|
||||
|
||||
s.setInstallProgress("extracting", 85, "")
|
||||
log.Debugf("picoclaw install: extracting binary from %s", archivePath)
|
||||
extractedPath, err := extractPicoclawBinary(archivePath, picoclawCacheDir)
|
||||
if err != nil {
|
||||
log.Errorf("picoclaw install: extract failed: %v", err)
|
||||
s.finishInstallFailure(installFailureStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
log.Debugf("picoclaw install: extracted binary to %s", extractedPath)
|
||||
|
||||
s.setInstallProgress("installing", 95, "")
|
||||
log.Debugf("picoclaw install: installing binary to %s", picoclawBinaryPath)
|
||||
if err := installPicoclawBinary(extractedPath, picoclawBinaryPath); err != nil {
|
||||
log.Errorf("picoclaw install: install failed: %v", err)
|
||||
s.finishInstallFailure(installFailureStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
log.Debugf("picoclaw install: install completed successfully")
|
||||
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 100,
|
||||
InstallStage: "installed",
|
||||
InstallPath: picoclawBinaryPath,
|
||||
Status: "installed",
|
||||
CheckedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func downloadPicoclawArchive(ctx context.Context, destination string, onProgress func(downloaded int64, total int64)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, picoclawDownloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: picoclawDownloadTimeout,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download picoclaw: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download picoclaw: unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
file, err := os.Create(destination)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create archive file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := copyWithProgress(ctx, file, resp.Body, resp.ContentLength, onProgress); err != nil {
|
||||
return fmt.Errorf("failed to save archive: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadPicoclawChecksum(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, picoclawChecksumURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create checksum request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: picoclawDownloadTimeout,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to download picoclaw checksum: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("failed to download picoclaw checksum: unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 16*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read checksum file: %w", err)
|
||||
}
|
||||
|
||||
digest, err := parseSHA512Digest(string(data), filepath.Base(picoclawDownloadURL))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
func copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader, total int64, onProgress func(downloaded int64, total int64)) error {
|
||||
buffer := make([]byte, 32*1024)
|
||||
var downloaded int64
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
n, readErr := src.Read(buffer)
|
||||
if n > 0 {
|
||||
if _, writeErr := dst.Write(buffer[:n]); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
downloaded += int64(n)
|
||||
if onProgress != nil {
|
||||
onProgress(downloaded, total)
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
if onProgress != nil {
|
||||
onProgress(downloaded, total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) setInstallProgress(stage string, progress int, lastError string) {
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
if progress > 100 {
|
||||
progress = 100
|
||||
}
|
||||
|
||||
s.runtime.UpdateInstallStatus(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Installed = false
|
||||
status.Installing = true
|
||||
status.InstallProgress = progress
|
||||
status.InstallStage = stage
|
||||
status.Status = "installing"
|
||||
status.LastError = lastError
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) finishInstallFailure(status string, message string) {
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: false,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallStage: status,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
Status: status,
|
||||
LastError: message,
|
||||
CheckedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func installFailureStatus(err error) string {
|
||||
if err == nil {
|
||||
return "install_failed"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "install_timeout"
|
||||
}
|
||||
return "install_failed"
|
||||
}
|
||||
|
||||
func parseSHA512Digest(raw string, expectedName string) (string, error) {
|
||||
lines := strings.Split(raw, "\n")
|
||||
var fallback string
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
digest := fields[0]
|
||||
if !isValidSHA512Digest(digest) {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(fields) == 1 {
|
||||
if fallback == "" {
|
||||
fallback = strings.ToLower(digest)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(fields[len(fields)-1], "*")
|
||||
if expectedName == "" || name == expectedName {
|
||||
return strings.ToLower(digest), nil
|
||||
}
|
||||
}
|
||||
|
||||
if fallback != "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to parse sha512 digest from checksum file")
|
||||
}
|
||||
|
||||
func isValidSHA512Digest(value string) bool {
|
||||
if len(value) != sha512.Size*2 {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func verifyFileSHA512(filePath string, expectedDigest string) error {
|
||||
expectedDigest = strings.ToLower(strings.TrimSpace(expectedDigest))
|
||||
if !isValidSHA512Digest(expectedDigest) {
|
||||
return fmt.Errorf("invalid expected sha512 digest")
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file for sha512 verification: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha512.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return fmt.Errorf("failed to hash file for sha512 verification: %w", err)
|
||||
}
|
||||
|
||||
actualDigest := hex.EncodeToString(hasher.Sum(nil))
|
||||
if actualDigest != expectedDigest {
|
||||
return fmt.Errorf("sha512 mismatch: got %s", actualDigest)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractPicoclawBinary(archivePath string, destinationDir string) (string, error) {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open archive: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzipReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read archive: %w", err)
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to extract archive: %w", err)
|
||||
}
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
if filepath.Base(header.Name) != "picoclaw" {
|
||||
continue
|
||||
}
|
||||
|
||||
extractedPath := filepath.Join(destinationDir, "picoclaw")
|
||||
outFile, err := os.OpenFile(extractedPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create extracted binary: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||
_ = outFile.Close()
|
||||
return "", fmt.Errorf("failed to extract picoclaw binary: %w", err)
|
||||
}
|
||||
if err := outFile.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to finalize extracted binary: %w", err)
|
||||
}
|
||||
return extractedPath, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("picoclaw binary not found in archive")
|
||||
}
|
||||
|
||||
func installPicoclawBinary(source string, destination string) error {
|
||||
inFile, err := os.Open(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open extracted picoclaw binary: %w", err)
|
||||
}
|
||||
defer inFile.Close()
|
||||
|
||||
tempDestination := destination + ".tmp"
|
||||
outFile, err := os.OpenFile(tempDestination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create destination binary: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(outFile, inFile); err != nil {
|
||||
_ = outFile.Close()
|
||||
_ = os.Remove(tempDestination)
|
||||
return fmt.Errorf("failed to write destination binary: %w", err)
|
||||
}
|
||||
if err := outFile.Close(); err != nil {
|
||||
_ = os.Remove(tempDestination)
|
||||
return fmt.Errorf("failed to finalize destination binary: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tempDestination, 0o755); err != nil {
|
||||
_ = os.Remove(tempDestination)
|
||||
return fmt.Errorf("failed to set destination mode: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempDestination, destination); err != nil {
|
||||
_ = os.Remove(tempDestination)
|
||||
return fmt.Errorf("failed to install picoclaw binary: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
server/service/picoclaw/runtime_session.go
Normal file
31
server/service/picoclaw/runtime_session.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func (s *Service) ReleaseRuntimeSession(c *gin.Context) {
|
||||
sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader))
|
||||
if sessionID == "" {
|
||||
writePicoclawError(c, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if session, ok := GetSessionManager().Get(sessionID); ok {
|
||||
s.closeGatewaySession(session, websocket.CloseNormalClosure, "session released")
|
||||
} else {
|
||||
ReleaseSession(sessionID)
|
||||
}
|
||||
|
||||
status := s.runtime.Get()
|
||||
status.CurrentSession = s.lock.Owner()
|
||||
s.runtime.Set(status)
|
||||
|
||||
writeSuccess(c, gin.H{
|
||||
"released": true,
|
||||
"current_session": status.CurrentSession,
|
||||
})
|
||||
}
|
||||
228
server/service/picoclaw/runtime_start_stop.go
Normal file
228
server/service/picoclaw/runtime_start_stop.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) startRuntime() (string, string, *PicoclawError) {
|
||||
if installed, statErr := isPicoclawInstalled(); statErr != nil {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Installed = false
|
||||
status.ModelConfigured = false
|
||||
status.ModelName = ""
|
||||
status.Status = "not_installed"
|
||||
status.LastError = statErr.Error()
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return "", "", newPicoclawError(CodeRuntimeStartFailed, statErr.Error())
|
||||
} else if !installed {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Installed = false
|
||||
status.ModelConfigured = false
|
||||
status.ModelName = ""
|
||||
status.Status = "not_installed"
|
||||
status.LastError = "picoclaw is not installed"
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw is not installed")
|
||||
}
|
||||
|
||||
scriptPath, err := resolvePicoclawStartScript()
|
||||
if err != nil {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Installed = true
|
||||
status.Status = "unavailable"
|
||||
status.LastError = err.Error()
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
|
||||
}
|
||||
if err := ensurePicoclawStartupDefaults(); err != nil {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Installed = true
|
||||
status.Status = "config_error"
|
||||
status.ConfigError = err.Error()
|
||||
status.LastError = err.Error()
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
|
||||
}
|
||||
|
||||
command := scriptPath + " start"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
|
||||
trimmedOutput := strings.TrimSpace(string(output))
|
||||
if execErr != nil {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Installed = true
|
||||
status.Status = "unavailable"
|
||||
status.LastError = trimmedOutput
|
||||
if status.LastError == "" {
|
||||
status.LastError = execErr.Error()
|
||||
}
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to start picoclaw runtime")
|
||||
}
|
||||
|
||||
time.Sleep(picoclawStartWaitPeriod)
|
||||
if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil {
|
||||
startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message)
|
||||
return command, trimmedOutput, startErr
|
||||
}
|
||||
|
||||
return command, trimmedOutput, nil
|
||||
}
|
||||
|
||||
func (s *Service) stopRuntime() (string, string, *PicoclawError) {
|
||||
settings, _ := loadPicoclawGatewaySettings()
|
||||
scriptPath, err := resolvePicoclawStartScript()
|
||||
if err != nil {
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: settings.ModelConfigured,
|
||||
ModelName: settings.ModelName,
|
||||
Status: "unavailable",
|
||||
LastError: err.Error(),
|
||||
CheckedAt: time.Now(),
|
||||
})
|
||||
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
|
||||
}
|
||||
|
||||
command := scriptPath + " stop"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), picoclawStopTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
|
||||
trimmedOutput := strings.TrimSpace(string(output))
|
||||
if execErr != nil {
|
||||
status := RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: settings.ModelConfigured,
|
||||
ModelName: settings.ModelName,
|
||||
Status: "error",
|
||||
LastError: trimmedOutput,
|
||||
CheckedAt: time.Now(),
|
||||
}
|
||||
if trimmedOutput == "" {
|
||||
status.LastError = execErr.Error()
|
||||
}
|
||||
s.runtime.Set(status)
|
||||
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to stop picoclaw runtime")
|
||||
}
|
||||
|
||||
time.Sleep(picoclawStopWaitPeriod)
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: settings.ModelConfigured,
|
||||
ModelName: settings.ModelName,
|
||||
Status: "stopped",
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: "",
|
||||
})
|
||||
|
||||
return command, trimmedOutput, nil
|
||||
}
|
||||
|
||||
func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr *PicoclawError
|
||||
|
||||
for {
|
||||
if runtimeErr := s.ensureRuntimeReady(); runtimeErr == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = runtimeErr
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
|
||||
time.Sleep(picoclawReadyPollPeriod)
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
|
||||
}
|
||||
|
||||
func resolvePicoclawStartScript() (string, error) {
|
||||
if _, err := os.Stat(etcInitPicoclawScript); err == nil {
|
||||
return etcInitPicoclawScript, nil
|
||||
}
|
||||
if _, err := os.Stat(kvmappPicoclawScript); err == nil {
|
||||
return kvmappPicoclawScript, nil
|
||||
}
|
||||
return "", fmt.Errorf("picoclaw start script not found: %s or %s", etcInitPicoclawScript, kvmappPicoclawScript)
|
||||
}
|
||||
|
||||
func runPicoclawOnboard() (string, *PicoclawError) {
|
||||
scriptPath, err := resolvePicoclawStartScript()
|
||||
if err != nil {
|
||||
return "", newPicoclawError(CodeRuntimeUnavailable, err.Error())
|
||||
}
|
||||
|
||||
command := scriptPath + " onboard"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), picoclawOnboardTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
|
||||
trimmedOutput := strings.TrimSpace(string(output))
|
||||
if execErr != nil {
|
||||
if trimmedOutput == "" {
|
||||
trimmedOutput = execErr.Error()
|
||||
}
|
||||
return trimmedOutput, newPicoclawError(CodeRuntimeUnavailable, "failed to initialize picoclaw config")
|
||||
}
|
||||
|
||||
return trimmedOutput, nil
|
||||
}
|
||||
|
||||
func isPicoclawInstalled() (bool, error) {
|
||||
info, err := os.Stat(picoclawBinaryPath)
|
||||
if err == nil {
|
||||
return !info.IsDir(), nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func isRuntimeRunning() (bool, error) {
|
||||
binName := filepath.Base(picoclawBinaryPath)
|
||||
if binName == "" || binName == "." || binName == string(filepath.Separator) {
|
||||
return false, fmt.Errorf("invalid picoclaw binary path: %s", picoclawBinaryPath)
|
||||
}
|
||||
|
||||
command := exec.Command("pidof", binName)
|
||||
if err := command.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
173
server/service/picoclaw/screenshot.go
Normal file
173
server/service/picoclaw/screenshot.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/common"
|
||||
"NanoKVM-Server/service/stream/mjpeg"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var screenshotRetryDelay = 100 * time.Millisecond
|
||||
|
||||
const (
|
||||
screenshotRetryCount = 3
|
||||
cachedFrameMaxAge = 2 * time.Second
|
||||
defaultPicoclawScreenshotWidth = 960
|
||||
defaultPicoclawScreenshotHeight = 540
|
||||
defaultPicoclawScreenshotQuality = 60
|
||||
)
|
||||
|
||||
func (s *Service) Screenshot(c *gin.Context) {
|
||||
var query ScreenshotQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid screenshot query"))
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, sessionErr := s.requireSessionID(c)
|
||||
if sessionErr != nil {
|
||||
writePicoclawError(c, sessionErr)
|
||||
return
|
||||
}
|
||||
|
||||
releaseAfter, lockErr := s.lock.AcquireTemporary(sessionID)
|
||||
if lockErr != nil {
|
||||
writePicoclawError(c, lockErr)
|
||||
return
|
||||
}
|
||||
if releaseAfter {
|
||||
defer s.lock.Release(sessionID)
|
||||
}
|
||||
|
||||
data, meta, err := s.captureScreenshot(query)
|
||||
if err != nil {
|
||||
writePicoclawError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if query.Format == "base64" {
|
||||
meta.ImageBase64 = base64.StdEncoding.EncodeToString(data)
|
||||
writeSuccess(c, meta)
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "image/jpeg", data)
|
||||
}
|
||||
|
||||
func (s *Service) captureScreenshot(query ScreenshotQuery) ([]byte, ScreenshotMeta, *PicoclawError) {
|
||||
width, height, quality := resolveScreenshotRequest(query)
|
||||
|
||||
if canUseCachedFrame(query) {
|
||||
if frame, ok := mjpeg.GetLatestFrame(); ok && time.Since(frame.CapturedAt) <= cachedFrameMaxAge {
|
||||
return frame.Data, ScreenshotMeta{
|
||||
SourceWidth: frame.Width,
|
||||
SourceHeight: frame.Height,
|
||||
CaptureWidth: frame.Width,
|
||||
CaptureHeight: frame.Height,
|
||||
Format: "jpeg",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
|
||||
for attempt := 0; attempt < screenshotRetryCount; attempt++ {
|
||||
data, result := s.vision.ReadMjpeg(width, height, quality)
|
||||
switch {
|
||||
case result == 5:
|
||||
if attempt < screenshotRetryCount-1 {
|
||||
time.Sleep(screenshotRetryDelay)
|
||||
continue
|
||||
}
|
||||
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotNoSignal, "no HDMI signal or frame unavailable")
|
||||
case result < 0 || len(data) == 0:
|
||||
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "failed to capture screenshot")
|
||||
default:
|
||||
return data, ScreenshotMeta{
|
||||
SourceWidth: screen.Width,
|
||||
SourceHeight: screen.Height,
|
||||
CaptureWidth: width,
|
||||
CaptureHeight: height,
|
||||
Format: "jpeg",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "failed to capture screenshot")
|
||||
}
|
||||
|
||||
func canUseCachedFrame(query ScreenshotQuery) bool {
|
||||
return query.Width == 0 && query.Height == 0 && query.Quality == 0
|
||||
}
|
||||
|
||||
func resolveScreenshotRequest(query ScreenshotQuery) (uint16, uint16, uint16) {
|
||||
screen := common.GetScreen()
|
||||
width := screen.Width
|
||||
height := screen.Height
|
||||
quality := screen.Quality
|
||||
|
||||
if query.Format == "base64" {
|
||||
width, height = fitWithinBounds(width, height, defaultPicoclawScreenshotWidth, defaultPicoclawScreenshotHeight)
|
||||
if quality == 0 || quality > defaultPicoclawScreenshotQuality {
|
||||
quality = defaultPicoclawScreenshotQuality
|
||||
}
|
||||
}
|
||||
|
||||
width, height = applyRequestedDimensions(width, height, query.Width, query.Height)
|
||||
if query.Quality > 0 {
|
||||
quality = query.Quality
|
||||
}
|
||||
|
||||
return width, height, quality
|
||||
}
|
||||
|
||||
func applyRequestedDimensions(defaultWidth uint16, defaultHeight uint16, requestedWidth uint16, requestedHeight uint16) (uint16, uint16) {
|
||||
switch {
|
||||
case requestedWidth > 0 && requestedHeight > 0:
|
||||
return requestedWidth, requestedHeight
|
||||
case requestedWidth > 0:
|
||||
return fitWithinBounds(defaultWidth, defaultHeight, requestedWidth, 0)
|
||||
case requestedHeight > 0:
|
||||
return fitWithinBounds(defaultWidth, defaultHeight, 0, requestedHeight)
|
||||
default:
|
||||
return defaultWidth, defaultHeight
|
||||
}
|
||||
}
|
||||
|
||||
func fitWithinBounds(sourceWidth uint16, sourceHeight uint16, maxWidth uint16, maxHeight uint16) (uint16, uint16) {
|
||||
if sourceWidth == 0 || sourceHeight == 0 {
|
||||
return sourceWidth, sourceHeight
|
||||
}
|
||||
if maxWidth == 0 && maxHeight == 0 {
|
||||
return sourceWidth, sourceHeight
|
||||
}
|
||||
|
||||
width := int(sourceWidth)
|
||||
height := int(sourceHeight)
|
||||
limitedWidth := width
|
||||
limitedHeight := height
|
||||
|
||||
if maxWidth > 0 && limitedWidth > int(maxWidth) {
|
||||
limitedWidth = int(maxWidth)
|
||||
limitedHeight = height * limitedWidth / width
|
||||
}
|
||||
|
||||
if maxHeight > 0 && limitedHeight > int(maxHeight) {
|
||||
limitedHeight = int(maxHeight)
|
||||
limitedWidth = width * limitedHeight / height
|
||||
}
|
||||
|
||||
if limitedWidth <= 0 {
|
||||
limitedWidth = 1
|
||||
}
|
||||
if limitedHeight <= 0 {
|
||||
limitedHeight = 1
|
||||
}
|
||||
|
||||
return uint16(limitedWidth), uint16(limitedHeight)
|
||||
}
|
||||
385
server/service/picoclaw/service.go
Normal file
385
server/service/picoclaw/service.go
Normal file
@@ -0,0 +1,385 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/common"
|
||||
"NanoKVM-Server/service/hid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const sessionIDHeader = "X-PicoClaw-Session-ID"
|
||||
|
||||
var (
|
||||
configStoreOnce sync.Once
|
||||
configStore *ConfigStore
|
||||
runtimeStoreOnce sync.Once
|
||||
runtimeStore *RuntimeStore
|
||||
probeLoopOnce sync.Once
|
||||
)
|
||||
|
||||
const runtimeStatusRefreshInterval = 2 * time.Second
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
vision: common.GetKvmVision(),
|
||||
hid: hid.GetHid(),
|
||||
config: getConfigStore(),
|
||||
lock: GetSessionLock(),
|
||||
runtime: getRuntimeStore(),
|
||||
}
|
||||
}
|
||||
|
||||
func getConfigStore() *ConfigStore {
|
||||
configStoreOnce.Do(func() {
|
||||
configStore = &ConfigStore{
|
||||
config: defaultConfig(),
|
||||
}
|
||||
})
|
||||
|
||||
return configStore
|
||||
}
|
||||
|
||||
func getRuntimeStore() *RuntimeStore {
|
||||
runtimeStoreOnce.Do(func() {
|
||||
runtimeStore = &RuntimeStore{
|
||||
status: RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: false,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: false,
|
||||
Status: "checking",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return runtimeStore
|
||||
}
|
||||
|
||||
func (s *Service) GetRuntimeStatus(c *gin.Context) {
|
||||
s.startRuntimeProbeLoop()
|
||||
status := s.runtime.Get()
|
||||
if shouldRefreshRuntimeStatus(status) {
|
||||
_ = s.ensureRuntimeReady()
|
||||
status = s.runtime.Get()
|
||||
}
|
||||
|
||||
if installed, ok := picoclawInstalledState(); ok {
|
||||
status.Installed = installed
|
||||
s.runtime.Update(func(current *RuntimeStatus) {
|
||||
current.Installed = installed
|
||||
})
|
||||
}
|
||||
|
||||
writeSuccess(c, withAgentProfile(status))
|
||||
}
|
||||
|
||||
func (s *Service) GetRuntimeSession(c *gin.Context) {
|
||||
writeSuccess(c, gin.H{
|
||||
"current_session": s.lock.Owner(),
|
||||
"checked_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) requireSessionID(c *gin.Context) (string, *PicoclawError) {
|
||||
sessionID := c.GetHeader(sessionIDHeader)
|
||||
if sessionID == "" {
|
||||
return "", newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID")
|
||||
}
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
func (s *ConfigStore) Get() Config {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.config
|
||||
}
|
||||
|
||||
func (s *ConfigStore) Set(cfg Config) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.config = cfg
|
||||
}
|
||||
|
||||
func (s *RuntimeStore) Get() RuntimeStatus {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.status
|
||||
}
|
||||
|
||||
func (s *RuntimeStore) Set(status RuntimeStatus) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
status.InstallPath = picoclawBinaryPath
|
||||
s.status = status
|
||||
}
|
||||
|
||||
func (s *RuntimeStore) Update(update func(*RuntimeStatus)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
update(&s.status)
|
||||
s.status.InstallPath = picoclawBinaryPath
|
||||
}
|
||||
|
||||
func (s *RuntimeStore) UpdateInstallStatus(update func(*RuntimeStatus)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
update(&s.status)
|
||||
s.status.InstallPath = picoclawBinaryPath
|
||||
}
|
||||
|
||||
func (s *Service) ensureRuntimeReady() *PicoclawError {
|
||||
currentStatus := s.runtime.Get()
|
||||
if currentStatus.Installing {
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress")
|
||||
}
|
||||
|
||||
installed, statErr := isPicoclawInstalled()
|
||||
if statErr != nil {
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: false,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
Status: "not_installed",
|
||||
LastError: statErr.Error(),
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw installation")
|
||||
}
|
||||
if !installed {
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: false,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: false,
|
||||
Status: "not_installed",
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw is not installed")
|
||||
}
|
||||
|
||||
configPath, pathErr := resolvePicoclawConfigPath()
|
||||
if pathErr != nil {
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: false,
|
||||
Status: "model_not_configured",
|
||||
LastError: pathErr.Error(),
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
|
||||
}
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if _, onboardErr := runPicoclawOnboard(); onboardErr != nil {
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: false,
|
||||
Status: "model_not_configured",
|
||||
LastError: onboardErr.Message,
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
|
||||
}
|
||||
if _, statErr := os.Stat(configPath); statErr == nil {
|
||||
goto configReady
|
||||
}
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: false,
|
||||
Status: "model_not_configured",
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
|
||||
}
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: false,
|
||||
Status: "config_error",
|
||||
ConfigError: err.Error(),
|
||||
LastError: err.Error(),
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
|
||||
}
|
||||
|
||||
configReady:
|
||||
if syncErr := s.syncConfigFromPicoclaw(); syncErr != nil {
|
||||
return syncErr
|
||||
}
|
||||
settings, settingsErr := loadPicoclawGatewaySettings()
|
||||
if settingsErr != nil {
|
||||
return newPicoclawError(CodeRuntimeUnavailable, settingsErr.Error())
|
||||
}
|
||||
if !settings.ModelConfigured {
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: false,
|
||||
ModelName: settings.TargetModelName,
|
||||
Status: "model_not_configured",
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
|
||||
}
|
||||
|
||||
cfg := s.config.Get()
|
||||
|
||||
parsed, err := url.Parse(cfg.GatewayURL)
|
||||
if err != nil {
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: true,
|
||||
ModelName: settings.ModelName,
|
||||
Status: "config_error",
|
||||
ConfigError: "invalid gateway url",
|
||||
LastError: err.Error(),
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
|
||||
}
|
||||
if parsed.Scheme != "ws" && parsed.Scheme != "wss" {
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: true,
|
||||
ModelName: settings.ModelName,
|
||||
Status: "config_error",
|
||||
ConfigError: "invalid gateway url scheme",
|
||||
LastError: parsed.Scheme,
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
|
||||
}
|
||||
|
||||
timeout := time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond
|
||||
hostPort, err := gatewayHostPort(parsed)
|
||||
if err != nil {
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: true,
|
||||
ModelName: settings.ModelName,
|
||||
Status: "config_error",
|
||||
ConfigError: err.Error(),
|
||||
LastError: err.Error(),
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", hostPort, timeout)
|
||||
if err != nil {
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 0,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: true,
|
||||
ModelName: settings.ModelName,
|
||||
Status: "unavailable",
|
||||
LastError: err.Error(),
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
|
||||
}
|
||||
_ = conn.Close()
|
||||
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: true,
|
||||
Installed: true,
|
||||
Installing: false,
|
||||
InstallProgress: 100,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
ModelConfigured: true,
|
||||
ModelName: settings.ModelName,
|
||||
Status: "ready",
|
||||
CheckedAt: time.Now(),
|
||||
CurrentSession: s.lock.Owner(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) startRuntimeProbeLoop() {
|
||||
probeLoopOnce.Do(func() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
_ = s.ensureRuntimeReady()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func shouldRefreshRuntimeStatus(status RuntimeStatus) bool {
|
||||
if status.Installing {
|
||||
return false
|
||||
}
|
||||
if status.CheckedAt.IsZero() {
|
||||
return true
|
||||
}
|
||||
return time.Since(status.CheckedAt) >= runtimeStatusRefreshInterval
|
||||
}
|
||||
|
||||
func picoclawInstalledState() (bool, bool) {
|
||||
installed, err := isPicoclawInstalled()
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
return installed, true
|
||||
}
|
||||
52
server/service/picoclaw/session_cleanup.go
Normal file
52
server/service/picoclaw/session_cleanup.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"NanoKVM-Server/service/hid"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const picoclawMediaTempDirName = "picoclaw_media"
|
||||
|
||||
func ReleaseSession(sessionID string) {
|
||||
GetSessionLock().Release(sessionID)
|
||||
releaseAllHIDState()
|
||||
}
|
||||
|
||||
func releaseAllHIDState() {
|
||||
h := hid.GetHid()
|
||||
h.WriteHid0([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
|
||||
h.WriteHid1([]byte{0x00, 0x00, 0x00, 0x00})
|
||||
h.WriteHid2([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
|
||||
}
|
||||
|
||||
func (s *Service) releaseAllHIDState() {
|
||||
s.hid.WriteHid0([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
|
||||
s.hid.WriteHid1([]byte{0x00, 0x00, 0x00, 0x00})
|
||||
s.hid.WriteHid2([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
|
||||
}
|
||||
|
||||
func picoclawMediaTempDir() string {
|
||||
return filepath.Join(os.TempDir(), picoclawMediaTempDirName)
|
||||
}
|
||||
|
||||
func cleanupPicoclawMediaTempDir() {
|
||||
mediaDir := picoclawMediaTempDir()
|
||||
|
||||
if _, err := os.Stat(mediaDir); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Warnf("failed to stat picoclaw media directory %s: %v", mediaDir, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(mediaDir); err != nil {
|
||||
log.Warnf("failed to remove picoclaw media directory %s: %v", mediaDir, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("removed picoclaw media directory %s", mediaDir)
|
||||
}
|
||||
474
server/service/picoclaw/session_history.go
Normal file
474
server/service/picoclaw/session_history.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
picoSessionPrefix = "agent:main:pico:direct:pico:"
|
||||
sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
|
||||
maxSessionJSONLLineSize = 10 * 1024 * 1024
|
||||
maxSessionPreviewRunes = 60
|
||||
defaultSessionLimit = 20
|
||||
)
|
||||
|
||||
type sessionStoredMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type sessionStoredFile struct {
|
||||
Key string `json:"key"`
|
||||
Messages []sessionStoredMessage `json:"messages"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
type sessionMetaFile struct {
|
||||
Key string `json:"key"`
|
||||
Summary string `json:"summary"`
|
||||
Skip int `json:"skip"`
|
||||
Count int `json:"count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type sessionListItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Preview string `json:"preview"`
|
||||
MessageCount int `json:"message_count"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
type sessionDetailMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type sessionDetail struct {
|
||||
ID string `json:"id"`
|
||||
Messages []sessionDetailMessage `json:"messages"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func resolvePicoclawWorkspacePath() (string, error) {
|
||||
configPath, err := resolvePicoclawConfigPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if doc, err := loadPicoclawConfigDocument(); err == nil {
|
||||
if workspace := expandPicoclawPath(doc.config.Agents.Defaults.Workspace); workspace != "" {
|
||||
return workspace, nil
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(configPath), "workspace"), nil
|
||||
}
|
||||
|
||||
func resolvePicoclawSessionsPath() (string, error) {
|
||||
workspacePath, err := resolvePicoclawWorkspacePath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(workspacePath, "sessions"), nil
|
||||
}
|
||||
|
||||
func sanitizeSessionKey(key string) string {
|
||||
return strings.ReplaceAll(key, ":", "_")
|
||||
}
|
||||
|
||||
func extractPicoSessionID(key string) (string, bool) {
|
||||
if strings.HasPrefix(key, picoSessionPrefix) {
|
||||
return strings.TrimPrefix(key, picoSessionPrefix), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
|
||||
if strings.HasPrefix(key, sanitizedPicoSessionPrefix) {
|
||||
return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (s *Service) ListSessions(c *gin.Context) {
|
||||
dir, err := resolvePicoclawSessionsPath()
|
||||
if err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
writeSuccess(c, []sessionListItem{})
|
||||
return
|
||||
}
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to read sessions directory"))
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]sessionListItem, 0)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
var (
|
||||
sessionID string
|
||||
sess sessionStoredFile
|
||||
loadErr error
|
||||
ok bool
|
||||
)
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".jsonl"):
|
||||
sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sess, loadErr = readJSONLSession(dir, sessionID)
|
||||
if loadErr == nil && isEmptySession(sess) {
|
||||
continue
|
||||
}
|
||||
case strings.HasSuffix(name, ".meta.json"):
|
||||
continue
|
||||
case strings.HasSuffix(name, ".json"):
|
||||
base := strings.TrimSuffix(name, ".json")
|
||||
if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
|
||||
if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found {
|
||||
if jsonlSess, jsonlErr := readJSONLSession(dir, jsonlSessionID); jsonlErr == nil && !isEmptySession(jsonlSess) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sess, loadErr = readLegacySession(dir, name)
|
||||
if loadErr == nil && isEmptySession(sess) {
|
||||
continue
|
||||
}
|
||||
if loadErr == nil {
|
||||
sessionID, ok = extractPicoSessionID(sess.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if loadErr != nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[sessionID]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[sessionID] = struct{}{}
|
||||
items = append(items, buildSessionListItem(sessionID, sess))
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].Updated > items[j].Updated
|
||||
})
|
||||
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", strconv.Itoa(defaultSessionLimit)))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultSessionLimit
|
||||
}
|
||||
|
||||
if offset >= len(items) {
|
||||
writeSuccess(c, []sessionListItem{})
|
||||
return
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
|
||||
writeSuccess(c, items[offset:end])
|
||||
}
|
||||
|
||||
func (s *Service) GetSession(c *gin.Context) {
|
||||
sessionID := strings.TrimSpace(c.Param("id"))
|
||||
if sessionID == "" {
|
||||
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "missing session id"))
|
||||
return
|
||||
}
|
||||
|
||||
dir, err := resolvePicoclawSessionsPath()
|
||||
if err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := readJSONLSession(dir, sessionID)
|
||||
if err == nil && isEmptySession(sess) {
|
||||
err = os.ErrNotExist
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
sess, err = readLegacySession(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json")
|
||||
if err == nil && isEmptySession(sess) {
|
||||
err = os.ErrNotExist
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
sessionErr := newPicoclawError(CodeRuntimeUnavailable, "session not found")
|
||||
sessionErr.StatusCode = http.StatusNotFound
|
||||
writePicoclawError(c, sessionErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
messages := make([]sessionDetailMessage, 0, len(sess.Messages))
|
||||
for _, msg := range sess.Messages {
|
||||
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
|
||||
messages = append(messages, sessionDetailMessage{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeSuccess(c, sessionDetail{
|
||||
ID: sessionID,
|
||||
Messages: messages,
|
||||
Summary: sess.Summary,
|
||||
Created: sess.Created.Format(time.RFC3339),
|
||||
Updated: sess.Updated.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) DeleteSession(c *gin.Context) {
|
||||
sessionID := strings.TrimSpace(c.Param("id"))
|
||||
if sessionID == "" {
|
||||
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "missing session id"))
|
||||
return
|
||||
}
|
||||
|
||||
dir, err := resolvePicoclawSessionsPath()
|
||||
if err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
|
||||
return
|
||||
}
|
||||
|
||||
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
|
||||
paths := []string{base + ".jsonl", base + ".meta.json", base + ".json"}
|
||||
removed := false
|
||||
|
||||
for _, path := range paths {
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to delete session"))
|
||||
return
|
||||
}
|
||||
removed = true
|
||||
}
|
||||
|
||||
if !removed {
|
||||
sessionErr := newPicoclawError(CodeRuntimeUnavailable, "session not found")
|
||||
sessionErr.StatusCode = http.StatusNotFound
|
||||
writePicoclawError(c, sessionErr)
|
||||
return
|
||||
}
|
||||
|
||||
writeSuccess(c, gin.H{
|
||||
"id": sessionID,
|
||||
"deleted": true,
|
||||
})
|
||||
}
|
||||
|
||||
func readLegacySession(dir, fileName string) (sessionStoredFile, error) {
|
||||
data, err := os.ReadFile(filepath.Join(dir, fileName))
|
||||
if err != nil {
|
||||
return sessionStoredFile{}, err
|
||||
}
|
||||
|
||||
var sess sessionStoredFile
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
return sessionStoredFile{}, err
|
||||
}
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func readSessionMeta(path, sessionKey string) (sessionMetaFile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return sessionMetaFile{Key: sessionKey}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return sessionMetaFile{}, err
|
||||
}
|
||||
|
||||
var meta sessionMetaFile
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return sessionMetaFile{}, err
|
||||
}
|
||||
if meta.Key == "" {
|
||||
meta.Key = sessionKey
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func readSessionMessages(path string, skip int) ([]sessionStoredMessage, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
messages := make([]sessionStoredMessage, 0)
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize)
|
||||
|
||||
seen := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
seen++
|
||||
if seen <= skip {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg sessionStoredMessage
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func readJSONLSession(dir, sessionID string) (sessionStoredFile, error) {
|
||||
sessionKey := picoSessionPrefix + sessionID
|
||||
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
|
||||
jsonlPath := base + ".jsonl"
|
||||
metaPath := base + ".meta.json"
|
||||
|
||||
meta, err := readSessionMeta(metaPath, sessionKey)
|
||||
if err != nil {
|
||||
return sessionStoredFile{}, err
|
||||
}
|
||||
|
||||
messages, err := readSessionMessages(jsonlPath, meta.Skip)
|
||||
if err != nil {
|
||||
return sessionStoredFile{}, err
|
||||
}
|
||||
|
||||
created := meta.CreatedAt
|
||||
updated := meta.UpdatedAt
|
||||
if created.IsZero() || updated.IsZero() {
|
||||
if info, statErr := os.Stat(jsonlPath); statErr == nil {
|
||||
if created.IsZero() {
|
||||
created = info.ModTime()
|
||||
}
|
||||
if updated.IsZero() {
|
||||
updated = info.ModTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sessionStoredFile{
|
||||
Key: meta.Key,
|
||||
Messages: messages,
|
||||
Summary: meta.Summary,
|
||||
Created: created,
|
||||
Updated: updated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildSessionListItem(sessionID string, sess sessionStoredFile) sessionListItem {
|
||||
preview := ""
|
||||
for _, msg := range sess.Messages {
|
||||
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
|
||||
preview = msg.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(sess.Summary)
|
||||
if title == "" {
|
||||
title = preview
|
||||
}
|
||||
|
||||
title = truncateRunes(title, maxSessionPreviewRunes)
|
||||
preview = truncateRunes(preview, maxSessionPreviewRunes)
|
||||
if preview == "" {
|
||||
preview = "(empty)"
|
||||
}
|
||||
if title == "" {
|
||||
title = preview
|
||||
}
|
||||
|
||||
messageCount := 0
|
||||
for _, msg := range sess.Messages {
|
||||
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
|
||||
messageCount++
|
||||
}
|
||||
}
|
||||
|
||||
return sessionListItem{
|
||||
ID: sessionID,
|
||||
Title: title,
|
||||
Preview: preview,
|
||||
MessageCount: messageCount,
|
||||
Created: sess.Created.Format(time.RFC3339),
|
||||
Updated: sess.Updated.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func isEmptySession(sess sessionStoredFile) bool {
|
||||
return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == ""
|
||||
}
|
||||
|
||||
func truncateRunes(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(strings.TrimSpace(s))
|
||||
if len(runes) <= maxLen {
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
return string(runes[:maxLen]) + "..."
|
||||
}
|
||||
135
server/service/picoclaw/session_lock.go
Normal file
135
server/service/picoclaw/session_lock.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
sessionLockOnce sync.Once
|
||||
sessionLock *SessionLock
|
||||
)
|
||||
|
||||
const sessionLockDuration = 30 * time.Minute
|
||||
|
||||
type SessionLock struct {
|
||||
mu sync.Mutex
|
||||
ownerSessionID string
|
||||
acquiredAt time.Time
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func GetSessionLock() *SessionLock {
|
||||
sessionLockOnce.Do(func() {
|
||||
sessionLock = &SessionLock{}
|
||||
})
|
||||
|
||||
return sessionLock
|
||||
}
|
||||
|
||||
func (l *SessionLock) Ensure(sessionID string) *PicoclawError {
|
||||
_, err := l.acquire(sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (l *SessionLock) AcquireTemporary(sessionID string) (bool, *PicoclawError) {
|
||||
return l.acquire(sessionID)
|
||||
}
|
||||
|
||||
func (l *SessionLock) Renew(sessionID string) bool {
|
||||
if sessionID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.ownerSessionID != sessionID {
|
||||
return false
|
||||
}
|
||||
|
||||
l.expiresAt = time.Now().Add(sessionLockDuration)
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *SessionLock) clearExpiredLocked(now time.Time) {
|
||||
if l.ownerSessionID == "" {
|
||||
return
|
||||
}
|
||||
if l.expiresAt.IsZero() || now.Before(l.expiresAt) {
|
||||
return
|
||||
}
|
||||
|
||||
l.ownerSessionID = ""
|
||||
l.acquiredAt = time.Time{}
|
||||
l.expiresAt = time.Time{}
|
||||
}
|
||||
|
||||
func (l *SessionLock) acquire(sessionID string) (bool, *PicoclawError) {
|
||||
if sessionID == "" {
|
||||
return false, newPicoclawError(CodeSessionIDInvalid, "invalid PicoClaw session")
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
l.clearExpiredLocked(now)
|
||||
|
||||
if l.ownerSessionID == "" || l.ownerSessionID == sessionID {
|
||||
acquired := l.ownerSessionID == ""
|
||||
l.ownerSessionID = sessionID
|
||||
if l.acquiredAt.IsZero() {
|
||||
l.acquiredAt = now
|
||||
}
|
||||
l.expiresAt = now.Add(sessionLockDuration)
|
||||
return acquired, nil
|
||||
}
|
||||
|
||||
err := newPicoclawError(CodePicoclawLockHeld, "another PicoClaw session is running")
|
||||
err.SessionID = l.ownerSessionID
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (l *SessionLock) Release(sessionID string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.ownerSessionID == "" {
|
||||
return true
|
||||
}
|
||||
if sessionID != "" && l.ownerSessionID != sessionID {
|
||||
return false
|
||||
}
|
||||
|
||||
l.ownerSessionID = ""
|
||||
l.acquiredAt = time.Time{}
|
||||
l.expiresAt = time.Time{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *SessionLock) ForceTakeover(sessionID string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
l.ownerSessionID = sessionID
|
||||
l.acquiredAt = now
|
||||
l.expiresAt = now.Add(sessionLockDuration)
|
||||
}
|
||||
|
||||
func (l *SessionLock) Owner() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.clearExpiredLocked(time.Now())
|
||||
return l.ownerSessionID
|
||||
}
|
||||
|
||||
func (l *SessionLock) BlocksManualInput() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.clearExpiredLocked(time.Now())
|
||||
return l.ownerSessionID != ""
|
||||
}
|
||||
94
server/service/picoclaw/session_manager.go
Normal file
94
server/service/picoclaw/session_manager.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var (
|
||||
sessionManagerOnce sync.Once
|
||||
sessionManager *SessionManager
|
||||
)
|
||||
|
||||
func GetSessionManager() *SessionManager {
|
||||
sessionManagerOnce.Do(func() {
|
||||
sessionManager = &SessionManager{
|
||||
sessions: make(map[string]*GatewaySession),
|
||||
}
|
||||
})
|
||||
|
||||
return sessionManager
|
||||
}
|
||||
|
||||
func (m *SessionManager) Register(sessionID string, downstream *websocket.Conn) (*GatewaySession, *PicoclawError) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if existing, ok := m.sessions[sessionID]; ok {
|
||||
switch existing.State {
|
||||
case SessionStateClosing, SessionStateClosed:
|
||||
delete(m.sessions, sessionID)
|
||||
default:
|
||||
err := newPicoclawError(CodePicoclawLockHeld, "session is already connected")
|
||||
err.SessionID = sessionID
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
session := &GatewaySession{
|
||||
SessionID: sessionID,
|
||||
State: SessionStateCreated,
|
||||
Downstream: downstream,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
m.sessions[sessionID] = session
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) Get(sessionID string) (*GatewaySession, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
session, ok := m.sessions[sessionID]
|
||||
return session, ok
|
||||
}
|
||||
|
||||
func (m *SessionManager) SetState(sessionID string, state SessionState) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if session, ok := m.sessions[sessionID]; ok {
|
||||
session.State = state
|
||||
session.UpdatedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) AttachUpstream(sessionID string, upstream *websocket.Conn) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if session, ok := m.sessions[sessionID]; ok {
|
||||
session.Upstream = upstream
|
||||
session.UpdatedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) AttachDownstream(sessionID string, downstream *websocket.Conn) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if session, ok := m.sessions[sessionID]; ok {
|
||||
session.Downstream = downstream
|
||||
session.UpdatedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) Remove(sessionID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.sessions, sessionID)
|
||||
}
|
||||
217
server/service/picoclaw/types.go
Normal file
217
server/service/picoclaw/types.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type VisionReader interface {
|
||||
ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int)
|
||||
}
|
||||
|
||||
type HIDWriter interface {
|
||||
WriteHid0(data []byte)
|
||||
WriteHid1(data []byte)
|
||||
WriteHid2(data []byte)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
vision VisionReader
|
||||
hid HIDWriter
|
||||
config *ConfigStore
|
||||
lock *SessionLock
|
||||
runtime *RuntimeStore
|
||||
}
|
||||
|
||||
type ConfigStore struct {
|
||||
mu sync.RWMutex
|
||||
config Config
|
||||
}
|
||||
|
||||
type RuntimeStore struct {
|
||||
mu sync.RWMutex
|
||||
status RuntimeStatus
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
GatewayURL string `json:"gateway_url"`
|
||||
ConnectTimeoutMs int `json:"connect_timeout_ms"`
|
||||
ReadTimeoutMs int `json:"read_timeout_ms"`
|
||||
WriteTimeoutMs int `json:"write_timeout_ms"`
|
||||
PingIntervalMs int `json:"ping_interval_ms"`
|
||||
MaxMessageBytes int `json:"max_message_bytes"`
|
||||
AllowTokenQuery bool `json:"allow_token_query"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeStatus struct {
|
||||
Ready bool `json:"ready"`
|
||||
Installed bool `json:"installed"`
|
||||
Installing bool `json:"installing"`
|
||||
InstallProgress int `json:"install_progress,omitempty"`
|
||||
InstallStage string `json:"install_stage,omitempty"`
|
||||
InstallPath string `json:"install_path,omitempty"`
|
||||
AgentProfile string `json:"agent_profile,omitempty"`
|
||||
ModelConfigured bool `json:"model_configured"`
|
||||
ModelName string `json:"model_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ConfigError string `json:"config_error,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
CurrentSession string `json:"current_session,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeStartResult struct {
|
||||
Started bool `json:"started"`
|
||||
Command string `json:"command"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Status RuntimeStatus `json:"status"`
|
||||
}
|
||||
|
||||
type RuntimeInstallResult struct {
|
||||
Installed bool `json:"installed"`
|
||||
Binary string `json:"binary"`
|
||||
Download string `json:"download"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Status RuntimeStatus `json:"status"`
|
||||
}
|
||||
|
||||
type ScreenshotMeta struct {
|
||||
ImageBase64 string `json:"image_base64,omitempty"`
|
||||
SourceWidth uint16 `json:"source_width"`
|
||||
SourceHeight uint16 `json:"source_height"`
|
||||
CaptureWidth uint16 `json:"capture_width"`
|
||||
CaptureHeight uint16 `json:"capture_height"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
type ScreenshotQuery struct {
|
||||
Format string `form:"format"`
|
||||
Width uint16 `form:"width"`
|
||||
Height uint16 `form:"height"`
|
||||
Quality uint16 `form:"quality"`
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
X *float64 `json:"x"`
|
||||
Y *float64 `json:"y"`
|
||||
}
|
||||
|
||||
type HotkeyKeys []string
|
||||
|
||||
func (k *HotkeyKeys) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*k = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
var list []string
|
||||
if err := json.Unmarshal(data, &list); err == nil {
|
||||
*k = normalizeHotkeyKeys(list)
|
||||
return nil
|
||||
}
|
||||
|
||||
var csv string
|
||||
if err := json.Unmarshal(data, &csv); err == nil {
|
||||
if strings.TrimSpace(csv) == "" {
|
||||
*k = nil
|
||||
return nil
|
||||
}
|
||||
*k = normalizeHotkeyKeys(strings.Split(csv, ","))
|
||||
return nil
|
||||
}
|
||||
|
||||
return &json.UnmarshalTypeError{Value: "keys", Type: hotkeyKeysType}
|
||||
}
|
||||
|
||||
var hotkeyKeysType = reflect.TypeOf("")
|
||||
|
||||
func normalizeHotkeyKeys(input []string) HotkeyKeys {
|
||||
keys := make([]string, 0, len(input))
|
||||
for _, item := range input {
|
||||
trimmed := strings.TrimSpace(item)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, trimmed)
|
||||
}
|
||||
return HotkeyKeys(keys)
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Action string `json:"action"`
|
||||
X *float64 `json:"x"`
|
||||
Y *float64 `json:"y"`
|
||||
From *Point `json:"from"`
|
||||
To *Point `json:"to"`
|
||||
Button string `json:"button"`
|
||||
Text string `json:"text"`
|
||||
Keys HotkeyKeys `json:"keys"`
|
||||
Direction string `json:"direction"`
|
||||
Amount int `json:"amount"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
}
|
||||
|
||||
type ActionBatch struct {
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
|
||||
type ActionResult struct {
|
||||
Action string `json:"action"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
HIDWrites int `json:"hid_writes"`
|
||||
ExecutedActions int `json:"executed_actions,omitempty"`
|
||||
}
|
||||
|
||||
type cachedFrame struct {
|
||||
data []byte
|
||||
width uint16
|
||||
height uint16
|
||||
capturedAt time.Time
|
||||
}
|
||||
|
||||
type SessionState string
|
||||
|
||||
const (
|
||||
SessionStateCreated SessionState = "created"
|
||||
SessionStateConnecting SessionState = "connecting"
|
||||
SessionStateActive SessionState = "active"
|
||||
SessionStateClosing SessionState = "closing"
|
||||
SessionStateClosed SessionState = "closed"
|
||||
)
|
||||
|
||||
const (
|
||||
CloseCodePicoclawLockHeld = 4001
|
||||
CloseCodeRuntimeUnavailable = 4002
|
||||
CloseCodeAuthFailed = 4003
|
||||
CloseCodePicoclawTakenOver = 4004
|
||||
CloseCodeUpstreamClosed = 4005
|
||||
)
|
||||
|
||||
type GatewaySession struct {
|
||||
SessionID string
|
||||
State SessionState
|
||||
Downstream *websocket.Conn
|
||||
Upstream *websocket.Conn
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
closeOnce sync.Once
|
||||
upstreamWriteMu sync.Mutex
|
||||
downstreamWriteMu sync.Mutex
|
||||
}
|
||||
|
||||
type SessionManager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*GatewaySession
|
||||
}
|
||||
|
||||
type LoadImageRequest struct {
|
||||
Path string `json:"path"`
|
||||
Prompt string `json:"prompt"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
@@ -8,6 +8,13 @@ import (
|
||||
|
||||
var streamer = NewStreamer()
|
||||
|
||||
type LatestFrame struct {
|
||||
Data []byte
|
||||
Width uint16
|
||||
Height uint16
|
||||
CapturedAt time.Time
|
||||
}
|
||||
|
||||
func Connect(c *gin.Context) {
|
||||
c.Header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
@@ -20,3 +27,15 @@ func Connect(c *gin.Context) {
|
||||
|
||||
<-c.Request.Context().Done()
|
||||
}
|
||||
|
||||
func GetLatestFrame() (LatestFrame, bool) {
|
||||
return streamer.getLatestFrame()
|
||||
}
|
||||
|
||||
func EnableLatestFrameCache() {
|
||||
streamer.enableLatestFrameCache()
|
||||
}
|
||||
|
||||
func DisableLatestFrameCache() {
|
||||
streamer.disableLatestFrameCache()
|
||||
}
|
||||
|
||||
@@ -14,9 +14,12 @@ import (
|
||||
)
|
||||
|
||||
type Streamer struct {
|
||||
mutex sync.RWMutex
|
||||
clients map[*gin.Context]bool
|
||||
running int32
|
||||
mutex sync.RWMutex
|
||||
clients map[*gin.Context]bool
|
||||
running int32
|
||||
frameMutex sync.RWMutex
|
||||
latestFrame LatestFrame
|
||||
cacheRefs int32
|
||||
}
|
||||
|
||||
func NewStreamer() *Streamer {
|
||||
@@ -86,6 +89,10 @@ func (s *Streamer) run() {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.frameCacheEnabled() {
|
||||
s.setLatestFrame(data, screen.Width, screen.Height)
|
||||
}
|
||||
|
||||
clients := s.getClients()
|
||||
for _, client := range clients {
|
||||
if err := writeFrame(client, data); err != nil {
|
||||
@@ -103,6 +110,71 @@ func (s *Streamer) run() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Streamer) setLatestFrame(data []byte, width uint16, height uint16) {
|
||||
frameCopy := append([]byte(nil), data...)
|
||||
|
||||
s.frameMutex.Lock()
|
||||
defer s.frameMutex.Unlock()
|
||||
|
||||
s.latestFrame = LatestFrame{
|
||||
Data: frameCopy,
|
||||
Width: width,
|
||||
Height: height,
|
||||
CapturedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Streamer) clearLatestFrame() {
|
||||
s.frameMutex.Lock()
|
||||
defer s.frameMutex.Unlock()
|
||||
|
||||
s.latestFrame = LatestFrame{}
|
||||
}
|
||||
|
||||
func (s *Streamer) enableLatestFrameCache() {
|
||||
atomic.AddInt32(&s.cacheRefs, 1)
|
||||
}
|
||||
|
||||
func (s *Streamer) disableLatestFrameCache() {
|
||||
for {
|
||||
current := atomic.LoadInt32(&s.cacheRefs)
|
||||
if current <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if atomic.CompareAndSwapInt32(&s.cacheRefs, current, current-1) {
|
||||
if current == 1 {
|
||||
s.clearLatestFrame()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Streamer) frameCacheEnabled() bool {
|
||||
return atomic.LoadInt32(&s.cacheRefs) > 0
|
||||
}
|
||||
|
||||
func (s *Streamer) getLatestFrame() (LatestFrame, bool) {
|
||||
if !s.frameCacheEnabled() {
|
||||
return LatestFrame{}, false
|
||||
}
|
||||
|
||||
s.frameMutex.RLock()
|
||||
defer s.frameMutex.RUnlock()
|
||||
|
||||
if len(s.latestFrame.Data) == 0 {
|
||||
return LatestFrame{}, false
|
||||
}
|
||||
|
||||
return LatestFrame{
|
||||
Data: append([]byte(nil), s.latestFrame.Data...),
|
||||
Width: s.latestFrame.Width,
|
||||
Height: s.latestFrame.Height,
|
||||
CapturedAt: s.latestFrame.CapturedAt,
|
||||
}, true
|
||||
}
|
||||
|
||||
func writeFrame(c *gin.Context, data []byte) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/config"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"NanoKVM-Server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -19,6 +20,7 @@ var imageVersionMap = map[string]string{
|
||||
"2024-11-13-09-59-9c961a.img": "v1.3.0",
|
||||
"2025-02-17-19-08-3649fe.img": "v1.4.0",
|
||||
"2025-04-17-14-21-98d17d.img": "v1.4.1",
|
||||
"2026-01-05-1_4_1.img": "v1.4.2",
|
||||
}
|
||||
|
||||
func (s *Service) GetInfo(c *gin.Context) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
"NanoKVM-Server/service/vm/jiggler"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -50,14 +51,26 @@ func (c *Client) Read() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debugf("received message %d: %v", messageType, data)
|
||||
|
||||
switch data[0] {
|
||||
case Heartbeat:
|
||||
c.UpdateHeartbeat()
|
||||
case KeyboardEvent:
|
||||
if picoclaw.GetSessionLock().BlocksManualInput() {
|
||||
log.Debug("manual keyboard input dropped while AI session holds control")
|
||||
continue
|
||||
}
|
||||
writeQueue(c.keyboard, data[1:])
|
||||
case MouseEvent:
|
||||
if picoclaw.GetSessionLock().BlocksManualInput() {
|
||||
log.Debug("manual mouse input dropped while AI session holds control")
|
||||
continue
|
||||
}
|
||||
writeQueue(c.mouse, data[1:])
|
||||
}
|
||||
}
|
||||
@@ -89,21 +102,42 @@ func (c *Client) UpdateHeartbeat() {
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
_ = c.ws.Close()
|
||||
c.closeOnce.Do(func() {
|
||||
if c.ws != nil {
|
||||
_ = c.ws.Close()
|
||||
}
|
||||
|
||||
closeQueue(c.keyboard)
|
||||
closeQueue(c.mouse)
|
||||
if c.keyboard != nil {
|
||||
close(c.keyboard)
|
||||
}
|
||||
if c.mouse != nil {
|
||||
close(c.mouse)
|
||||
}
|
||||
|
||||
log.Debug("websocket disconnected")
|
||||
log.Debug("websocket disconnected")
|
||||
})
|
||||
}
|
||||
|
||||
func writeQueue(queue chan []byte, data []byte) {
|
||||
queue <- data
|
||||
if !sendQueue(queue, data) {
|
||||
log.Debug("hid event dropped because websocket queue is closed")
|
||||
return
|
||||
}
|
||||
|
||||
jiggler.GetJiggler().Update()
|
||||
}
|
||||
|
||||
func closeQueue(queue chan []byte) {
|
||||
for range queue {
|
||||
func sendQueue(queue chan []byte, data []byte) (ok bool) {
|
||||
if queue == nil {
|
||||
return false
|
||||
}
|
||||
close(queue)
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
ok = false
|
||||
}
|
||||
}()
|
||||
|
||||
queue <- data
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type Client struct {
|
||||
mouse chan []byte
|
||||
lastHeartbeat time.Time
|
||||
mutex sync.Mutex
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
|
||||
28
server/utils/listener.go
Normal file
28
server/utils/listener.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ListenAddr(host string, port string) string {
|
||||
if host == "" {
|
||||
return fmt.Sprintf(":%s", port)
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
func NeedsDedicatedLoopbackListener(host string) bool {
|
||||
host = strings.TrimSpace(strings.Trim(host, "[]"))
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return !strings.EqualFold(host, "localhost")
|
||||
}
|
||||
|
||||
return !ip.IsLoopback() && !ip.IsUnspecified()
|
||||
}
|
||||
@@ -164,6 +164,7 @@ uint16_t hdmi_res_list[][2] = {
|
||||
{1152, 864},
|
||||
{1024, 768},
|
||||
{800, 600},
|
||||
{640, 480},
|
||||
};
|
||||
|
||||
uint16_t hdmi_unsupported_res_list[][2] = {
|
||||
|
||||
@@ -58,7 +58,7 @@ case "$1" in
|
||||
kvm_vision)
|
||||
Project_Name="kvm_vision"
|
||||
Project_PATH="$NanoKVM_PATH/support/sg2002/kvm_vision_test"
|
||||
echo -e "\e[36m[build] \e[32mProject: kvm_system \e[0m"
|
||||
echo -e "\e[36m[build] \e[32mProject: kvm_vision \e[0m"
|
||||
;;
|
||||
*)
|
||||
echo -e "\e[36m[build] \e[31mInvalid project name \e[0m"
|
||||
@@ -162,7 +162,13 @@ then
|
||||
if [ -e $SOURCE_PATH ]
|
||||
then
|
||||
cp -rf $SOURCE_PATH $TARGET_PATH
|
||||
echo -e "\e[36m[build] \e[32mkvm_system has been moved to kvmapp.\e[0m"
|
||||
if [ "$Project_Name" = "kvm_system" ]
|
||||
then
|
||||
echo -e "\e[36m[build] \e[32mkvm_system has been moved to kvmapp.\e[0m"
|
||||
else
|
||||
echo -e "\e[36m[build] \e[32mkvm_vision has been moved to kvmapp.\e[0m"
|
||||
fi
|
||||
|
||||
else
|
||||
echo -e "\e[36m[build] \e[31mThe source file does not exist. Please compile using \e[34m./build kvm_system \e[31mfirst.\e[0m"
|
||||
fi
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -356,8 +356,8 @@ void OLED_ShowStringTurn(uint8_t x, uint8_t y, char *chr, uint8_t sizey);
|
||||
void OLED_ShowStringtoend(uint8_t x, uint8_t y, char *chr, uint8_t sizey, uint8_t end);
|
||||
void OLED_ShowString_AlignRight(uint8_t x_end, uint8_t y, char *chr, uint8_t size);
|
||||
void OLED_ROW(uint8_t _EN);
|
||||
void OLED_ShowKVMLogo();
|
||||
void OLED_ShowLogo();
|
||||
void OLED_ShowSipeedLogo();
|
||||
void OLED_Showline();
|
||||
void OLED_Showline_1();
|
||||
void OLED_ShowIMG(uint8_t x,uint8_t y,char *chr,uint8_t width,uint8_t height);
|
||||
|
||||
@@ -13,8 +13,8 @@ void kvm_init_cube_ui(void)
|
||||
|
||||
OLED_Clear();
|
||||
// OLED_Revolve();
|
||||
OLED_ShowKVMLogo();
|
||||
OLED_ShowLogo();
|
||||
OLED_ShowSipeedLogo();
|
||||
OLED_ShowKVMState(HDMI_STATE, 0);
|
||||
OLED_ShowKVMState(HID_STATE, 0);
|
||||
OLED_ShowKVMState(ETH_STATE, 0);
|
||||
@@ -37,7 +37,7 @@ void kvm_init_pcie_ui(void)
|
||||
{
|
||||
OLED_Revolve();
|
||||
OLED_Showline_1();
|
||||
OLED_ShowSipeedLogo();
|
||||
OLED_ShowLogo();
|
||||
OLED_ShowKVMState(HDMI_STATE, 0);
|
||||
OLED_ShowKVMState(HID_STATE, 0);
|
||||
OLED_ShowKVMState(ETH_STATE, 0);
|
||||
@@ -209,7 +209,14 @@ void kvm_eth_state_disp(ip_addr_t _ip_type, uint8_t first_disp)
|
||||
(_ip_type_old != _ip_type) ||
|
||||
first_disp || ip_changed(ETH_IP))
|
||||
{
|
||||
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
|
||||
// ensure eth_addr is updated
|
||||
if (kvm_sys_state.eth_state >= 2) {
|
||||
if (kvm_sys_state.eth_addr[0] != 0)
|
||||
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
|
||||
} else {
|
||||
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
|
||||
}
|
||||
|
||||
_ip_type_old = _ip_type;
|
||||
switch(kvm_oled_state.eth_state){
|
||||
case -1:
|
||||
@@ -412,8 +419,15 @@ void show_wifi_config_ip(void)
|
||||
{
|
||||
OLED_Clear();
|
||||
get_ip_addr(WiFi_IP);
|
||||
OLED_ShowString(0, 1, "Config IP:", 8);
|
||||
OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
|
||||
OLED_ShowString(1, 0, "Config URL", 8);
|
||||
OLED_ShowString_AlignRight(63, 1, "----------------", 4);
|
||||
// OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
|
||||
static char wifi_addr_with_path[30];
|
||||
static char wifi_addr_with_key[30];
|
||||
sprintf(wifi_addr_with_path, "%s/#/", kvm_sys_state.wifi_addr);
|
||||
sprintf(wifi_addr_with_key, "WIFI?P=%s", kvm_sys_state.wifi_ap_pass);
|
||||
OLED_ShowString_AlignRight(63, 2, wifi_addr_with_path, 4);
|
||||
OLED_ShowString_AlignRight(63, 3, wifi_addr_with_key, 4);
|
||||
}
|
||||
|
||||
void show_wifi_config_QR(void)
|
||||
@@ -421,8 +435,7 @@ void show_wifi_config_QR(void)
|
||||
static char cmd[70];
|
||||
OLED_Clear();
|
||||
get_ip_addr(WiFi_IP);
|
||||
printf("http://%s/#/wifi\n", kvm_sys_state.wifi_addr);
|
||||
sprintf(cmd, "http://%s/#/wifi", kvm_sys_state.wifi_addr);
|
||||
sprintf(cmd, "http://%s/#/WIFI?P=%s", kvm_sys_state.wifi_addr, kvm_sys_state.wifi_ap_pass);
|
||||
qrencode(cmd);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ void Production_testing_patch(void)
|
||||
void new_app_init(void)
|
||||
{
|
||||
// Update the necessary scripts
|
||||
system("rm -f /boot/logo.jpeg");
|
||||
system("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/");
|
||||
system("rm -f /etc/init.d/S02udisk");
|
||||
system("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/");
|
||||
@@ -66,6 +67,11 @@ void new_app_init(void)
|
||||
} else {
|
||||
system("rm -f /etc/init.d/S30wifi");
|
||||
}
|
||||
|
||||
// if exit /etc/init.d/S98tailscaled then cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/
|
||||
if(access("/etc/init.d/S98tailscaled", F_OK) == 0){
|
||||
system("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/");
|
||||
}
|
||||
|
||||
// rmmod soph_saradc
|
||||
system("rmmod soph_saradc");
|
||||
|
||||
605
tools/logo_generator/logo_generator.py
Normal file
605
tools/logo_generator/logo_generator.py
Normal file
@@ -0,0 +1,605 @@
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
import sys
|
||||
import os
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Header, Footer, Button, Input, Label, Static
|
||||
from textual.containers import Container, Horizontal, Vertical, Center, Middle
|
||||
from textual.binding import Binding
|
||||
from textual import events
|
||||
from textual.message import Message
|
||||
|
||||
|
||||
# Language dictionary for i18n support
|
||||
LANGUAGES = {
|
||||
'zh': {
|
||||
'app_title': '🖼️ [ NanoKVM Logo 生成器 ]',
|
||||
'app_title_editor': '✏️ NanoKVM Logo 编辑器',
|
||||
'contrast_minus': '对比度-',
|
||||
'contrast_plus': '对比度+',
|
||||
'current_contrast': '当前对比度:{}',
|
||||
'click_hint': '💡 点击图片以修改像素',
|
||||
'btn_invert': '🔃 反转',
|
||||
'btn_reset': '🔄 重置',
|
||||
'btn_export': '💾 导出',
|
||||
'btn_quit': '❌ 退出',
|
||||
'help_text_with_image': '快捷键:↑↓ 调整±10 | ←→ 调整±1 | I 反转 | R 重置 | D 导出 | Q 退出',
|
||||
'help_text_editor': '快捷键:I 反转 | R 清空 | D 导出 | Q 退出',
|
||||
'status_blank_canvas': '空白画布 - 点击像素开始绘制',
|
||||
'status_loaded': '已加载:{}',
|
||||
'status_load_failed': '无法加载图片:{}',
|
||||
'status_contrast': '对比度:{}',
|
||||
'status_inverted': '已反转所有像素',
|
||||
'status_reset_image': '已重置为原始图片',
|
||||
'status_reset_canvas': '已清空画布',
|
||||
'status_export_error': '错误:没有可导出的数据',
|
||||
'status_exported': '✅ 已导出 logo 文件',
|
||||
'status_pixel_toggled': '切换像素 ({}, {})',
|
||||
'lang_select_title': '选择语言 / Select Language',
|
||||
'btn_chinese': '中文 (Chinese)',
|
||||
'btn_english': 'English',
|
||||
'binding_quit': '退出',
|
||||
'binding_export': '导出',
|
||||
'binding_reset': '重置',
|
||||
'binding_invert': '反转',
|
||||
'binding_threshold_up': '阈值 +10',
|
||||
'binding_threshold_down': '阈值 -10',
|
||||
'binding_threshold_up_small': '阈值 +1',
|
||||
'binding_threshold_down_small': '阈值 -1',
|
||||
},
|
||||
'en': {
|
||||
'app_title': '🖼️ [ NanoKVM Logo Generator ]',
|
||||
'app_title_editor': '✏️ NanoKVM Logo Editor',
|
||||
'contrast_minus': 'Contrast-',
|
||||
'contrast_plus': 'Contrast+',
|
||||
'current_contrast': 'Contrast: {}',
|
||||
'click_hint': '💡 Click image to toggle pixels',
|
||||
'btn_invert': '🔃 Invert',
|
||||
'btn_reset': '🔄 Reset',
|
||||
'btn_export': '💾 Export',
|
||||
'btn_quit': '❌ Quit',
|
||||
'help_text_with_image': 'Shortcuts: ↑↓ ±10 | ←→ ±1 | I Invert | R Reset | D Export | Q Quit',
|
||||
'help_text_editor': 'Shortcuts: I Invert | R Clear | D Export | Q Quit',
|
||||
'status_blank_canvas': 'Blank canvas - Click pixels to draw',
|
||||
'status_loaded': 'Loaded: {}',
|
||||
'status_load_failed': 'Failed to load image: {}',
|
||||
'status_contrast': 'Contrast: {}',
|
||||
'status_inverted': 'All pixels inverted',
|
||||
'status_reset_image': 'Reset to original image',
|
||||
'status_reset_canvas': 'Canvas cleared',
|
||||
'status_export_error': 'Error: No data to export',
|
||||
'status_exported': '✅ Exported logo file',
|
||||
'status_pixel_toggled': 'Toggled pixel ({}, {})',
|
||||
'lang_select_title': '选择语言 / Select Language',
|
||||
'btn_chinese': '中文 (Chinese)',
|
||||
'btn_english': 'English',
|
||||
'binding_quit': 'Quit',
|
||||
'binding_export': 'Export',
|
||||
'binding_reset': 'Reset',
|
||||
'binding_invert': 'Invert',
|
||||
'binding_threshold_up': 'Threshold +10',
|
||||
'binding_threshold_down': 'Threshold -10',
|
||||
'binding_threshold_up_small': 'Threshold +1',
|
||||
'binding_threshold_down_small': 'Threshold -1',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def reverse_byte(byte):
|
||||
"""Reverse the bits in a byte"""
|
||||
result = 0
|
||||
for i in range(8):
|
||||
if (byte >> i) & 1:
|
||||
result |= (1 << (7 - i))
|
||||
return result
|
||||
|
||||
|
||||
def process_image(image_path, threshold=128):
|
||||
"""
|
||||
Process image to 16x16 binary data
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file
|
||||
threshold: Binarization threshold (0-255)
|
||||
|
||||
Returns:
|
||||
16x16 numpy array of binary values
|
||||
"""
|
||||
if not os.path.exists(image_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
img = Image.open(image_path).convert('L')
|
||||
except Exception as e:
|
||||
print(f"Error: Failed to open image - {e}")
|
||||
return None
|
||||
|
||||
# If not square, crop the center region
|
||||
if img.width != img.height:
|
||||
size = min(img.width, img.height)
|
||||
left = (img.width - size) // 2
|
||||
top = (img.height - size) // 2
|
||||
img = img.crop((left, top, left + size, top + size))
|
||||
|
||||
# Resize to 16x16 and binarize
|
||||
img = img.resize((16, 16), Image.Resampling.LANCZOS)
|
||||
binary = (np.array(img) < threshold).astype(np.uint8)
|
||||
|
||||
return binary
|
||||
|
||||
|
||||
def create_blank_image():
|
||||
"""Create a blank 16x16 image (all black)"""
|
||||
return np.zeros((16, 16), dtype=np.uint8)
|
||||
|
||||
|
||||
def binary_to_bytes(binary):
|
||||
"""Convert 16x16 binary data to 32 bytes"""
|
||||
data = []
|
||||
|
||||
for x in range(16):
|
||||
for page in range(2):
|
||||
byte = 0
|
||||
start_row = page * 8
|
||||
for bit in range(8):
|
||||
row = start_row + bit
|
||||
if row < 16 and binary[row][x] == 1:
|
||||
byte |= (1 << (7 - bit))
|
||||
data.append(reverse_byte(byte))
|
||||
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def binary_to_image(binary_data, size=64):
|
||||
"""
|
||||
Convert 16x16 binary data to 64x64 RGBA image
|
||||
|
||||
Args:
|
||||
binary_data: 16x16 numpy array of binary values
|
||||
size: Size to scale the image to (default 64)
|
||||
|
||||
Returns:
|
||||
PIL Image in RGBA mode
|
||||
"""
|
||||
# Create 16x16 RGBA image from binary data
|
||||
img_16 = Image.new('RGBA', (16, 16), (0, 0, 0, 255)) # Black for 1
|
||||
pixels = img_16.load()
|
||||
|
||||
for y in range(16):
|
||||
for x in range(16):
|
||||
if binary_data[y][x] == 1:
|
||||
pixels[x, y] = (255, 255, 255, 255) # White
|
||||
else:
|
||||
pixels[x, y] = (0, 0, 0, 255) # Black
|
||||
|
||||
# Scale to 64x64 using NEAREST for pixel-perfect scaling
|
||||
img_64 = img_16.resize((size, size), Image.Resampling.NEAREST)
|
||||
return img_64
|
||||
|
||||
|
||||
def image_to_ico(image_path=None, binary_data=None, output_path="logo.ico", size=64):
|
||||
"""
|
||||
Convert image or binary data to 64x64 32-bit ICO file
|
||||
|
||||
Args:
|
||||
image_path: Path to the source image (optional if binary_data is provided)
|
||||
binary_data: 16x16 numpy array of binary values (optional if image_path is provided)
|
||||
output_path: Output ICO file path
|
||||
size: Size to scale the icon to (default 64)
|
||||
"""
|
||||
try:
|
||||
if image_path and os.path.exists(image_path):
|
||||
img = Image.open(image_path).convert('RGBA')
|
||||
|
||||
# If not square, crop the center region
|
||||
if img.width != img.height:
|
||||
min_size = min(img.width, img.height)
|
||||
left = (img.width - min_size) // 2
|
||||
top = (img.height - min_size) // 2
|
||||
img = img.crop((left, top, left + min_size, top + min_size))
|
||||
|
||||
# Resize to 64x64 using LANCZOS for high quality
|
||||
img_64 = img.resize((size, size), Image.Resampling.LANCZOS)
|
||||
elif binary_data is not None:
|
||||
# Convert binary data to image and scale
|
||||
img_64 = binary_to_image(binary_data, size)
|
||||
else:
|
||||
print("Error: No image source provided")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: Failed to create ICO - {e}")
|
||||
return False
|
||||
|
||||
# Save as ICO with 32-bit color depth
|
||||
img_64.save(output_path, format='ICO', sizes=[(size, size)])
|
||||
return True
|
||||
|
||||
|
||||
class PixelGrid(Static):
|
||||
"""16x16 pixel grid component"""
|
||||
|
||||
class PixelClick(Message):
|
||||
def __init__(self, x: int, y: int):
|
||||
super().__init__()
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
DEFAULT_CSS = """
|
||||
PixelGrid {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
background: $surface;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, binary_data=None, id=None):
|
||||
super().__init__(id=id)
|
||||
self.binary_data = binary_data if binary_data is not None else np.zeros((16, 16), dtype=np.uint8)
|
||||
self.can_focus = True
|
||||
|
||||
def update_data(self, binary_data):
|
||||
self.binary_data = binary_data
|
||||
self.refresh()
|
||||
|
||||
def render(self):
|
||||
"""Render the pixel grid"""
|
||||
lines = []
|
||||
# Top border
|
||||
lines.append("┌" + "─" * 32 + "┐")
|
||||
|
||||
for y in range(16):
|
||||
line = "│"
|
||||
for x in range(16):
|
||||
if self.binary_data[y][x] == 1:
|
||||
line += "██"
|
||||
else:
|
||||
line += " "
|
||||
line += "│"
|
||||
lines.append(line)
|
||||
|
||||
# Bottom border
|
||||
lines.append("└" + "─" * 32 + "┘")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def on_click(self, event: events.Click):
|
||||
"""Handle click event"""
|
||||
# Get coordinates relative to the component
|
||||
# Use event.offset to get coordinates relative to the component
|
||||
try:
|
||||
local_x = event.offset_x
|
||||
local_y = event.offset_y
|
||||
except AttributeError:
|
||||
# If offset attribute doesn't exist, use event coordinates
|
||||
local_x = event.x if hasattr(event, 'x') else 0
|
||||
local_y = event.y if hasattr(event, 'y') else 0
|
||||
|
||||
# Render format:
|
||||
# Row 0: ┌────────────────────────────────┐ (top border)
|
||||
# Rows 1-16: │██ ██ ... │ (pixel rows, 16 pixels per row, 2 chars per pixel)
|
||||
# Row 17: └────────────────────────────────┘ (bottom border)
|
||||
# Column positions: 0 is left border │, 1-32 is pixel area (2 chars per pixel), 33 is right border │
|
||||
|
||||
# Check if within pixel grid area (y from 1 to 16, since row 0 is top border)
|
||||
if local_y >= 1 and local_y <= 16:
|
||||
grid_y = local_y - 1
|
||||
# Check if x is within valid range (1 to 32, since there are borders on both sides)
|
||||
if local_x >= 1 and local_x <= 32:
|
||||
# Each pixel takes 2 chars, (1,2) is column 0, (3,4) is column 1, etc.
|
||||
grid_x = (local_x - 1) // 2
|
||||
if 0 <= grid_x < 16 and 0 <= grid_y < 16:
|
||||
self.post_message(self.PixelClick(grid_x, grid_y))
|
||||
|
||||
|
||||
class LanguageSelectApp(App):
|
||||
"""Language selection screen"""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#lang-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#lang-title {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
padding: 2 0;
|
||||
}
|
||||
|
||||
#lang-buttons {
|
||||
width: auto;
|
||||
height: auto;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
Button {
|
||||
margin: 1 2;
|
||||
min-width: 20;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.selected_language = 'zh' # Default to Chinese
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="lang-container"):
|
||||
yield Label("选择语言 / Select Language", id="lang-title")
|
||||
with Horizontal(id="lang-buttons"):
|
||||
yield Button("中文 (Chinese)", id="btn-chinese", variant="primary")
|
||||
yield Button("English", id="btn-english", variant="success")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
if event.button.id == "btn-chinese":
|
||||
self.selected_language = 'zh'
|
||||
self.exit('zh')
|
||||
elif event.button.id == "btn-english":
|
||||
self.selected_language = 'en'
|
||||
self.exit('en')
|
||||
|
||||
|
||||
class LogoApp(App):
|
||||
"""Logo conversion TUI application"""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#title {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
padding: 1 0;
|
||||
}
|
||||
|
||||
#threshold-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#threshold-value {
|
||||
text-align: center;
|
||||
padding: 0 1;
|
||||
width: 20;
|
||||
}
|
||||
|
||||
#button-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
content-align: center middle;
|
||||
padding: 1 0;
|
||||
}
|
||||
|
||||
Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
#status {
|
||||
text-align: center;
|
||||
padding: 1 0;
|
||||
text-style: italic;
|
||||
}
|
||||
|
||||
#help-text {
|
||||
text-align: center;
|
||||
padding: 1 0;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("q", "quit", "Quit"),
|
||||
Binding("d", "export", "Export"),
|
||||
Binding("r", "reset", "Reset"),
|
||||
Binding("i", "invert", "Invert"),
|
||||
Binding("up", "threshold_up", "Threshold +10"),
|
||||
Binding("down", "threshold_down", "Threshold -10"),
|
||||
Binding("left", "threshold_down_small", "Threshold -1"),
|
||||
Binding("right", "threshold_up_small", "Threshold +1"),
|
||||
]
|
||||
|
||||
def __init__(self, image_path=None, language='zh'):
|
||||
super().__init__()
|
||||
self.image_path = image_path
|
||||
self.language = language
|
||||
self.lang = LANGUAGES[language]
|
||||
self.original_binary = None
|
||||
self.current_binary = None
|
||||
self.threshold = 128
|
||||
self.has_image = image_path is not None
|
||||
|
||||
# Update bindings with selected language
|
||||
self.BINDINGS = [
|
||||
Binding("q", "quit", self.lang['binding_quit']),
|
||||
Binding("d", "export", self.lang['binding_export']),
|
||||
Binding("r", "reset", self.lang['binding_reset']),
|
||||
Binding("i", "invert", self.lang['binding_invert']),
|
||||
Binding("up", "threshold_up", self.lang['binding_threshold_up']),
|
||||
Binding("down", "threshold_down", self.lang['binding_threshold_down']),
|
||||
Binding("left", "threshold_down_small", self.lang['binding_threshold_down_small']),
|
||||
Binding("right", "threshold_up_small", self.lang['binding_threshold_up_small']),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Container(id="main-container"):
|
||||
if self.has_image:
|
||||
yield Label(self.lang['app_title'], id="title")
|
||||
# Contrast control area: [Contrast-] Current value [Contrast+]
|
||||
with Horizontal(id="threshold-container"):
|
||||
yield Button(self.lang['contrast_minus'], id="threshold-minus-btn", variant="primary")
|
||||
yield Label(self.lang['current_contrast'].format(self.threshold), id="threshold-value-label")
|
||||
yield Button(self.lang['contrast_plus'], id="threshold-plus-btn", variant="primary")
|
||||
else:
|
||||
yield Label(self.lang['app_title_editor'], id="title")
|
||||
yield PixelGrid(id="pixel-grid")
|
||||
yield Label(self.lang['click_hint'], id="click-hint")
|
||||
# Action button area: Invert | Reset | Export | Quit
|
||||
with Horizontal(id="button-container"):
|
||||
yield Button(self.lang['btn_invert'], id="invert-btn", variant="default")
|
||||
yield Button(self.lang['btn_reset'], id="reset-btn", variant="warning")
|
||||
yield Button(self.lang['btn_export'], id="export-btn", variant="success")
|
||||
yield Button(self.lang['btn_quit'], id="quit-btn", variant="error")
|
||||
yield Label("", id="status")
|
||||
if self.has_image:
|
||||
yield Label(self.lang['help_text_with_image'], id="help-text")
|
||||
else:
|
||||
yield Label(self.lang['help_text_editor'], id="help-text")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
"""When app starts"""
|
||||
self.pixel_grid = self.query_one("#pixel-grid", PixelGrid)
|
||||
self.threshold_value_label = self.query_one("#threshold-value-label", Label) if self.has_image else None
|
||||
self.status = self.query_one("#status", Label)
|
||||
|
||||
if self.has_image:
|
||||
self.load_image(self.image_path)
|
||||
else:
|
||||
self.current_binary = create_blank_image()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_blank_canvas'])
|
||||
|
||||
def load_image(self, path):
|
||||
"""Load image"""
|
||||
self.original_binary = process_image(path, self.threshold)
|
||||
if self.original_binary is not None:
|
||||
self.current_binary = self.original_binary.copy()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_loaded'].format(path))
|
||||
else:
|
||||
self.status.update(self.lang['status_load_failed'].format(path))
|
||||
|
||||
def update_threshold(self, new_value: int):
|
||||
"""Update threshold"""
|
||||
new_value = max(0, min(255, new_value))
|
||||
if new_value != self.threshold:
|
||||
self.threshold = new_value
|
||||
if self.threshold_value_label:
|
||||
self.threshold_value_label.update(self.lang['current_contrast'].format(self.threshold))
|
||||
if self.original_binary is not None:
|
||||
self.current_binary = process_image(self.image_path, self.threshold)
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_contrast'].format(self.threshold))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
"""Button press event"""
|
||||
button_id = event.button.id
|
||||
|
||||
if button_id == "invert-btn":
|
||||
self.action_invert()
|
||||
elif button_id == "reset-btn":
|
||||
self.action_reset()
|
||||
elif button_id == "export-btn":
|
||||
self.action_export()
|
||||
elif button_id == "quit-btn":
|
||||
self.action_quit()
|
||||
elif button_id == "threshold-plus-btn":
|
||||
self.action_threshold_up()
|
||||
elif button_id == "threshold-minus-btn":
|
||||
self.action_threshold_down()
|
||||
|
||||
def on_pixel_grid_pixel_click(self, event: PixelGrid.PixelClick):
|
||||
"""Pixel click event"""
|
||||
if self.current_binary is not None:
|
||||
# Toggle pixel value
|
||||
self.current_binary[event.y][event.x] = 1 - self.current_binary[event.y][event.x]
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_pixel_toggled'].format(event.x, event.y))
|
||||
|
||||
def action_invert(self):
|
||||
"""Invert all pixels"""
|
||||
if self.current_binary is not None:
|
||||
self.current_binary = 1 - self.current_binary
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_inverted'])
|
||||
|
||||
def action_reset(self):
|
||||
"""Reset to original image or clear"""
|
||||
if self.has_image and self.original_binary is not None:
|
||||
self.current_binary = self.original_binary.copy()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_reset_image'])
|
||||
else:
|
||||
self.current_binary = create_blank_image()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_reset_canvas'])
|
||||
|
||||
def action_export(self):
|
||||
"""Export logo.bin and logo.ico"""
|
||||
if self.current_binary is None:
|
||||
self.status.update(self.lang['status_export_error'])
|
||||
return
|
||||
|
||||
data = binary_to_bytes(self.current_binary)
|
||||
|
||||
with open("logo.bin", "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
# Export 64x64 32-bit ICO file
|
||||
if self.has_image and self.image_path:
|
||||
image_to_ico(self.image_path, "logo.ico", size=64)
|
||||
else:
|
||||
# Export from current canvas (16x16 -> 64x64)
|
||||
image_to_ico(binary_data=self.current_binary, output_path="logo.ico", size=64)
|
||||
|
||||
self.status.update(self.lang['status_exported'])
|
||||
|
||||
def action_threshold_up(self):
|
||||
"""Increase threshold by 10"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold + 10)
|
||||
|
||||
def action_threshold_down(self):
|
||||
"""Decrease threshold by 10"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold - 10)
|
||||
|
||||
def action_threshold_up_small(self):
|
||||
"""Increase threshold by 1"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold + 1)
|
||||
|
||||
def action_threshold_down_small(self):
|
||||
"""Decrease threshold by 1"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold - 1)
|
||||
|
||||
def action_quit(self):
|
||||
self.exit()
|
||||
|
||||
|
||||
def main():
|
||||
image_path = None
|
||||
|
||||
# First, show language selection screen
|
||||
lang_app = LanguageSelectApp()
|
||||
selected_language = lang_app.run()
|
||||
|
||||
if len(sys.argv) >= 2:
|
||||
image_path = sys.argv[1]
|
||||
if not os.path.exists(image_path):
|
||||
print(f"Error: File not found - {image_path}")
|
||||
return
|
||||
|
||||
app = LogoApp(image_path, language=selected_language)
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
39
tools/logo_generator/readme.md
Normal file
39
tools/logo_generator/readme.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# NanoKVM Logo Generator
|
||||
|
||||
**Compatible Hardware:** NanoKVM-Cube, NanoKVM-PCIe
|
||||
|
||||
🛠️ **Tool Description:**
|
||||
This is a logo generator for the NanoKVM OLED display and Web. You can use it to create a `logo.bin` and `logo.ico` file and place them in the `/boot` directory of your NanoKVM system.
|
||||
|
||||
📋 **Instructions:**
|
||||
|
||||
1. **Install required libraries:**
|
||||
```bash
|
||||
pip install Pillow numpy textual
|
||||
```
|
||||
|
||||
2. **Prepare your logo image:**
|
||||
Make sure it's as square as possible for best results 🖼️
|
||||
|
||||
3. **Run the script:**
|
||||
```bash
|
||||
python logo_generator.py path/to/your_logo.png
|
||||
```
|
||||
|
||||
4. **Choose your language:**
|
||||
Select either English or Chinese
|
||||
|
||||
5. **Adjust the contrast:**
|
||||
Fine-tune the contrast based on the preview below until you're happy with how it looks ✨
|
||||
|
||||
6. **Fine-tune pixel by pixel:**
|
||||
Click on individual pixels to adjust details. If you prefer an inverted display, just click the invert button 🔄
|
||||
|
||||
7. **Export and deploy:**
|
||||
Export the `logo.bin` & `logo.ico`, then copy it to the `/boot` directory of your NanoKVM system. You can do this by:
|
||||
* Enabling SSH and using SCP to copy the file 💻
|
||||
* Or directly placing it in the boot partition of your TF card 💾
|
||||
|
||||
8. **Reboot and enjoy:**
|
||||
Make sure your NanoKVM application version is greater than 2.3.6 ✅
|
||||
Reboot your NanoKVM, and you should see your custom logo on the OLED display! 🎉
|
||||
@@ -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,7 +7,8 @@
|
||||
"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",
|
||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -16,53 +17,59 @@
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"antd": "^5.29.1",
|
||||
"axios": "1.12.0",
|
||||
"antd": "^5.29.3",
|
||||
"axios": "^1.15.2",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"i18next": "^23.16.4",
|
||||
"jotai": "^2.10.1",
|
||||
"i18next": "^23.16.8",
|
||||
"jotai": "^2.18.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^14.1.3",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-simple-keyboard": "^3.8.19",
|
||||
"semver": "^7.6.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-simple-keyboard": "^3.8.164",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.4",
|
||||
"vaul": "^0.9.9",
|
||||
"websocket": "^1.0.35",
|
||||
"yocto-queue": "^1.2.1"
|
||||
"yocto-queue": "^1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/websocket": "^1.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.0",
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"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",
|
||||
"vite": "7.1.11",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^17.3.0",
|
||||
"msw": "^2.12.10",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.59.0",
|
||||
"vite": "7.3.2",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
|
||||
4753
web/pnpm-lock.yaml
generated
4753
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -43,3 +43,13 @@ export function deleteShortcut(id: string) {
|
||||
};
|
||||
return http.delete('/api/hid/shortcut', data);
|
||||
}
|
||||
|
||||
// get shortcut leader key
|
||||
export function getLeaderKey() {
|
||||
return http.get('/api/hid/shortcut/leader-key');
|
||||
}
|
||||
|
||||
// set shortcut leader key
|
||||
export function setLeaderKey(key: string) {
|
||||
return http.post('/api/hid/shortcut/leader-key', { key });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
|
||||
export type DNSMode = 'manual' | 'dhcp';
|
||||
|
||||
// wake on lan
|
||||
export function wol(mac: string) {
|
||||
const data = {
|
||||
@@ -31,12 +33,29 @@ export function getWiFi() {
|
||||
}
|
||||
|
||||
// connect wifi without auth (only available in wifi configuration mode)
|
||||
export function connectWifiNoAuth(ssid: string, password: string) {
|
||||
export function connectWifiNoAuth(ssid: string, password: string, apPassword?: string) {
|
||||
const data = {
|
||||
ssid,
|
||||
password
|
||||
};
|
||||
return http.post('/api/network/wifi', data);
|
||||
return http.post('/api/network/wifi', data, {
|
||||
headers: {
|
||||
'X-AP-Key': apPassword || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// verify ap login
|
||||
export function verifyApLogin(apPassword: string) {
|
||||
return http.post(
|
||||
'/api/network/wifi/verify',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-AP-Key': apPassword || ''
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// connect wifi
|
||||
@@ -52,3 +71,11 @@ export function connectWifi(ssid: string, password: string) {
|
||||
export function disconnectWifi() {
|
||||
return http.post('/api/network/wifi/disconnect');
|
||||
}
|
||||
|
||||
export function getDNS() {
|
||||
return http.get('/api/network/dns');
|
||||
}
|
||||
|
||||
export function setDNS(mode: DNSMode, servers: string[]) {
|
||||
return http.post('/api/network/dns', { mode, servers });
|
||||
}
|
||||
|
||||
130
web/src/api/picoclaw.ts
Normal file
130
web/src/api/picoclaw.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
import {
|
||||
picoclawGateway,
|
||||
type GatewayRunState,
|
||||
type GatewayTransportState
|
||||
} from '@/lib/picoclaw-gateway.ts';
|
||||
|
||||
const sessionIDHeader = 'X-PicoClaw-Session-ID';
|
||||
|
||||
type ModelConfigRequest = {
|
||||
model: string;
|
||||
api_base: string;
|
||||
api_key: string;
|
||||
};
|
||||
|
||||
type AgentProfileRequest = {
|
||||
profile: string;
|
||||
};
|
||||
|
||||
export type PicoclawSessionListItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
preview: string;
|
||||
message_count: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
};
|
||||
|
||||
export type PicoclawSessionDetailMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type PicoclawSessionDetail = {
|
||||
id: string;
|
||||
messages: PicoclawSessionDetailMessage[];
|
||||
summary?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
};
|
||||
|
||||
export function setPicoclawModelConfig(data: ModelConfigRequest) {
|
||||
return http.post('/api/picoclaw/model/config', data);
|
||||
}
|
||||
|
||||
export function setPicoclawAgentProfile(data: AgentProfileRequest) {
|
||||
return http.post('/api/picoclaw/agent/profile', data);
|
||||
}
|
||||
|
||||
export function listPicoclawSessions(params?: { offset?: number; limit?: number }) {
|
||||
return http.get('/api/picoclaw/sessions', { params });
|
||||
}
|
||||
|
||||
export function getPicoclawSession(id: string) {
|
||||
return http.get(`/api/picoclaw/sessions/${id}`);
|
||||
}
|
||||
|
||||
export function deletePicoclawSession(id: string) {
|
||||
return http.delete(`/api/picoclaw/sessions/${id}`);
|
||||
}
|
||||
|
||||
export function getRuntimeStatus() {
|
||||
return http.get('/api/picoclaw/runtime/status');
|
||||
}
|
||||
|
||||
export function startRuntime() {
|
||||
return http.post('/api/picoclaw/runtime/start');
|
||||
}
|
||||
|
||||
export function stopRuntime() {
|
||||
return http.post('/api/picoclaw/runtime/stop');
|
||||
}
|
||||
|
||||
export function installRuntime() {
|
||||
return http.post('/api/picoclaw/runtime/install');
|
||||
}
|
||||
|
||||
export function uninstallRuntime() {
|
||||
return http.post('/api/picoclaw/runtime/uninstall');
|
||||
}
|
||||
|
||||
export function connectGateway(sessionId?: string) {
|
||||
return picoclawGateway.connect({ sessionId });
|
||||
}
|
||||
|
||||
export function releaseRuntimeSession(sessionId?: string) {
|
||||
const activeSessionId = sessionId || picoclawGateway.getSessionId();
|
||||
if (!activeSessionId) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return http.request({
|
||||
method: 'delete',
|
||||
url: '/api/picoclaw/runtime/session',
|
||||
headers: {
|
||||
[sessionIDHeader]: activeSessionId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function sendChatMessage(
|
||||
content: string,
|
||||
options?: { id?: string; maxSteps?: number; maxRuntimeMs?: number; trackState?: boolean }
|
||||
) {
|
||||
return picoclawGateway.sendChatMessage(content, options);
|
||||
}
|
||||
|
||||
export function sendStopMessage() {
|
||||
return picoclawGateway.sendStopMessage();
|
||||
}
|
||||
|
||||
export function closeGateway() {
|
||||
const activeSessionId = picoclawGateway.getSessionId();
|
||||
picoclawGateway.close();
|
||||
if (!activeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void releaseRuntimeSession(activeSessionId).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function onGatewayConnectionState(listener: (state: GatewayTransportState) => void) {
|
||||
return picoclawGateway.on('transport_state', listener);
|
||||
}
|
||||
|
||||
export function onGatewayRunState(listener: (state: GatewayRunState) => void) {
|
||||
return picoclawGateway.on('run_state', listener);
|
||||
}
|
||||
|
||||
export { picoclawGateway };
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#737373" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-monitor-x"><path d="m14.5 12.5-5-5"/><path d="m9.5 12.5 5-5"/><rect width="20" height="14" x="2" y="3" rx="2"/><path d="M12 17v4"/><path d="M8 21h8"/></svg>
|
||||
|
Before Width: | Height: | Size: 354 B |
@@ -2,7 +2,8 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #000;
|
||||
@@ -23,7 +24,7 @@ html, body {
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear;
|
||||
animation: spin 1s linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@@ -34,3 +35,32 @@ animation: spin 1s linear;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* PicoClaw Chat Custom Scrollbar */
|
||||
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* PicoClaw Chat Loading Animation */
|
||||
@keyframes picoclaw-loading-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0.5px);
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-1.5px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
43
web/src/components/icons/icon-base.tsx
Normal file
43
web/src/components/icons/icon-base.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode, SVGProps } from 'react';
|
||||
|
||||
export type IconProps = Omit<SVGProps<SVGSVGElement>, 'color'> & {
|
||||
size?: number | string;
|
||||
color?: string;
|
||||
strokeWidth?: number | string;
|
||||
};
|
||||
|
||||
type IconBaseProps = IconProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const IconBase = ({
|
||||
children,
|
||||
className,
|
||||
size = 24,
|
||||
color = 'currentColor',
|
||||
strokeWidth = 2,
|
||||
viewBox = '0 0 24 24',
|
||||
fill = 'none',
|
||||
strokeLinecap = 'round',
|
||||
strokeLinejoin = 'round',
|
||||
...props
|
||||
}: IconBaseProps) => {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden={props['aria-label'] ? undefined : true}
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={viewBox}
|
||||
fill={fill}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap={strokeLinecap}
|
||||
strokeLinejoin={strokeLinejoin}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
12
web/src/components/icons/layout-sidebar-right-collapse.tsx
Normal file
12
web/src/components/icons/layout-sidebar-right-collapse.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IconBase, type IconProps } from './icon-base.tsx';
|
||||
|
||||
export const LayoutSidebarRightCollapse = (props: IconProps) => {
|
||||
return (
|
||||
<IconBase {...props}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12" />
|
||||
<path d="M15 4v16" />
|
||||
<path d="M9 10l2 2l-2 2" />
|
||||
</IconBase>
|
||||
);
|
||||
};
|
||||
18
web/src/components/icons/robot.tsx
Normal file
18
web/src/components/icons/robot.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IconBase, type IconProps } from './icon-base.tsx';
|
||||
|
||||
export const Robot = (props: IconProps) => {
|
||||
return (
|
||||
<IconBase {...props}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M6 6a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M9 12v9" />
|
||||
<path d="M15 12v9" />
|
||||
<path d="M5 16l4 -2" />
|
||||
<path d="M15 14l4 2" />
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 8v.01" />
|
||||
<path d="M14 8v.01" />
|
||||
</IconBase>
|
||||
);
|
||||
};
|
||||
@@ -4,9 +4,12 @@ import clsx from 'clsx';
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
viewportRef,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||
viewportRef?: React.Ref<HTMLDivElement>;
|
||||
}) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
@@ -14,6 +17,7 @@ function ScrollArea({
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
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]"
|
||||
>
|
||||
|
||||
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;
|
||||
}
|
||||
119
web/src/hooks/useMenuVisibility.ts
Normal file
119
web/src/hooks/useMenuVisibility.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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;
|
||||
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(() => {
|
||||
setIsMenuHidden(false);
|
||||
|
||||
if (menuDisplayMode === 'auto') {
|
||||
startCountdown();
|
||||
} else {
|
||||
stopCountdown();
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
return {
|
||||
isMenuExpanded,
|
||||
isMenuHidden,
|
||||
isMenuMoved,
|
||||
isInitialized,
|
||||
handleHovered,
|
||||
handleMoved,
|
||||
setIsMenuExpanded
|
||||
};
|
||||
}
|
||||
@@ -1,347 +1,613 @@
|
||||
const ca = {
|
||||
translation: {
|
||||
head: {
|
||||
desktop: "Escriptori remot",
|
||||
login: "Inici de sessió",
|
||||
changePassword: "Canviar contrasenya",
|
||||
terminal: "Terminal",
|
||||
wifi: "Xarxa Sense fils"
|
||||
desktop: 'Escriptori remot',
|
||||
login: 'Inici de sessió',
|
||||
changePassword: 'Canviar contrasenya',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
},
|
||||
auth: {
|
||||
login: "Inici de sessió",
|
||||
login: 'Inici de sessió',
|
||||
placeholderUsername: "Nom d'usuari",
|
||||
placeholderPassword: "Contrasenya",
|
||||
placeholderPassword2: "Torna a introduir la contrasenya",
|
||||
placeholderPassword: 'Contrasenya',
|
||||
placeholderPassword2: 'Torna a introduir la contrasenya',
|
||||
noEmptyUsername: "Cal introduir el nom d'usuari",
|
||||
noEmptyPassword: "Cal introduir la contrasenya",
|
||||
noAccount: "No s'ha pogut obtenir la informació de l'usuari, actualitza la pàgina web o restableix la contrasenya",
|
||||
noEmptyPassword: 'Cal introduir la contrasenya',
|
||||
noAccount:
|
||||
"No s'ha pogut obtenir la informació de l'usuari, actualitza la pàgina web o restableix la contrasenya",
|
||||
invalidUser: "Nom d'usuari o contrasenya invàlids",
|
||||
error: "Error inesperat",
|
||||
changePassword: "Canviar la contrasenya",
|
||||
changePasswordDesc: "Per a la seguretat del dispositiu, canvia la contrasenya!",
|
||||
differentPassword: "Les contrasenyes no coincideixen",
|
||||
locked: 'Massa inicis de sessió, si us plau, torna-ho a provar més tard',
|
||||
globalLocked: 'Sistema sota protecció, torneu-ho a provar més tard',
|
||||
error: 'Error inesperat',
|
||||
changePassword: 'Canviar la contrasenya',
|
||||
changePasswordDesc: 'Per a la seguretat del dispositiu, canvia la contrasenya!',
|
||||
differentPassword: 'Les contrasenyes no coincideixen',
|
||||
illegalUsername: "El nom d'usuari conté caràcters no permesos",
|
||||
illegalPassword: "La contrasenya conté caràcters no permesos",
|
||||
forgetPassword: "Has oblidat la contrasenya",
|
||||
illegalPassword: 'La contrasenya conté caràcters no permesos',
|
||||
forgetPassword: 'Has oblidat la contrasenya',
|
||||
ok: "D'acord",
|
||||
cancel: "Cancel·la",
|
||||
loginButtonText: "Inicia sessió",
|
||||
cancel: 'Cancel·la',
|
||||
loginButtonText: 'Inicia sessió',
|
||||
tips: {
|
||||
reset1: "Per restablir les contrasenyes, mantingues premut el botó BOOT del NanoKVM durant 10 segons.",
|
||||
reset2: "Per veure els passos detallats, consulta aquest document:",
|
||||
reset3: "Compte web per defecte:",
|
||||
reset4: "Compte SSH per defecte:",
|
||||
change1: "Aquesta acció canviarà les següents contrasenyes:",
|
||||
reset1:
|
||||
'Per restablir les contrasenyes, mantingues premut el botó BOOT del NanoKVM durant 10 segons.',
|
||||
reset2: 'Per veure els passos detallats, consulta aquest document:',
|
||||
reset3: 'Compte web per defecte:',
|
||||
reset4: 'Compte SSH per defecte:',
|
||||
change1: 'Aquesta acció canviarà les següents contrasenyes:',
|
||||
change2: "Contrasenya d'inici de sessió web",
|
||||
change3: "Contrasenya root del sistema (inici de sessió SSH)",
|
||||
change4: "Per restablir les contrasenyes, mantingues premut el botó BOOT del NanoKVM."
|
||||
change3: 'Contrasenya root del sistema (inici de sessió SSH)',
|
||||
change4: 'Per restablir les contrasenyes, mantingues premut el botó BOOT del NanoKVM.'
|
||||
}
|
||||
},
|
||||
wifi: {
|
||||
title: "Xarxa Sense fils",
|
||||
description: "Configura la xarxa sense fils per al NanoKVM",
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configura Wi-Fi per al NanoKVM',
|
||||
success: "Comprova l'estat de la xarxa del NanoKVM i visita la nova adreça IP.",
|
||||
failed: "L'operació ha fallat, torna-ho a intentar.",
|
||||
invalidMode:
|
||||
'El mode actual no admet la configuració de xarxa. Aneu al vostre dispositiu i activeu el mode de configuració Wi-Fi.',
|
||||
confirmBtn: "D'acord",
|
||||
finishBtn: "Fet"
|
||||
finishBtn: 'Fet',
|
||||
ap: {
|
||||
authTitle: 'Es requereix autenticació',
|
||||
authDescription: 'Introduïu la contrasenya AP per continuar',
|
||||
authFailed: 'Contrasenya AP no vàlida',
|
||||
passPlaceholder: 'AP contrasenya',
|
||||
verifyBtn: 'Verificar'
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
title: "Pantalla",
|
||||
video: "Mode de vídeo",
|
||||
scale: 'Escala',
|
||||
title: 'Pantalla',
|
||||
video: 'Mode de vídeo',
|
||||
videoDirectTips: "Activa HTTPS a 'Configuració > Dispositiu' per utilitzar aquest mode",
|
||||
resolution: "Resolució",
|
||||
auto: "Automàtic",
|
||||
autoTips: "Poden aparèixer talls o desajustos del ratolí en certes resolucions. Prova a canviar la resolució de l'amfitrió remot o desactiva el mode automàtic.",
|
||||
fps: "FPS",
|
||||
customizeFps: "Personalitzat",
|
||||
quality: "Qualitat",
|
||||
qualityLossless: "Sense pèrdua",
|
||||
qualityHigh: "Alta",
|
||||
qualityMedium: "Mitjana",
|
||||
qualityLow: "Baixa",
|
||||
frameDetect: "Detecció de fotogrames",
|
||||
frameDetectTip: "Calcula la diferència entre fotogrames. S'atura la transmissió si no hi ha canvis a la pantalla de l'amfitrió remot.",
|
||||
resetHdmi: "Restablir HDMI"
|
||||
resolution: 'Resolució',
|
||||
auto: 'Automàtic',
|
||||
autoTips:
|
||||
"Poden aparèixer talls o desajustos del ratolí en certes resolucions. Prova a canviar la resolució de l'amfitrió remot o desactiva el mode automàtic.",
|
||||
fps: 'FPS',
|
||||
customizeFps: 'Personalitzat',
|
||||
quality: 'Qualitat',
|
||||
qualityLossless: 'Sense pèrdua',
|
||||
qualityHigh: 'Alta',
|
||||
qualityMedium: 'Mitjana',
|
||||
qualityLow: 'Baixa',
|
||||
frameDetect: 'Detecció de fotogrames',
|
||||
frameDetectTip:
|
||||
"Calcula la diferència entre fotogrames. S'atura la transmissió si no hi ha canvis a la pantalla de l'amfitrió remot.",
|
||||
resetHdmi: 'Restablir HDMI'
|
||||
},
|
||||
keyboard: {
|
||||
title: "Teclat",
|
||||
paste: "Enganxa",
|
||||
tips: "Només es permeten lletres i símbols estàndard",
|
||||
placeholder: "Escriu aquí",
|
||||
submit: "Envia",
|
||||
virtual: "Teclat",
|
||||
ctrlaltdel: "Ctrl+Alt+Del"
|
||||
title: 'Teclat',
|
||||
paste: 'Enganxa',
|
||||
tips: 'Només es permeten lletres i símbols estàndard',
|
||||
placeholder: 'Escriu aquí',
|
||||
submit: 'Envia',
|
||||
virtual: 'Teclat',
|
||||
readClipboard: 'Llegir des del porta-retalls',
|
||||
clipboardPermissionDenied:
|
||||
"S'ha denegat el permís del porta-retalls. Permet l'accés al porta-retalls al teu navegador.",
|
||||
clipboardReadError: "No s'ha pogut llegir el porta-retalls",
|
||||
dropdownEnglish: 'Anglès',
|
||||
dropdownGerman: 'alemany',
|
||||
dropdownFrench: 'francès',
|
||||
dropdownRussian: 'rus',
|
||||
shortcut: {
|
||||
title: 'Dreceres',
|
||||
custom: 'Personalitzat',
|
||||
capture: 'Feu clic aquí per capturar la drecera',
|
||||
clear: 'Clar',
|
||||
save: 'Desa',
|
||||
captureTips:
|
||||
'Capturar tecles del sistema (com la tecla Windows) requereix permís de pantalla completa.',
|
||||
enterFullScreen: 'Canvia el mode de pantalla completa.'
|
||||
},
|
||||
leaderKey: {
|
||||
title: 'Tecla líder',
|
||||
desc: "Evita les restriccions del navegador i envia dreceres del sistema directament a l'amfitrió remot.",
|
||||
howToUse: "Com s'utilitza",
|
||||
simultaneous: {
|
||||
title: 'Mode simultània',
|
||||
desc1: 'Manteniu premuda la tecla líder i premeu la drecera.',
|
||||
desc2: 'Intuïtiu, però pot entrar en conflicte amb les dreceres del sistema.'
|
||||
},
|
||||
sequential: {
|
||||
title: 'Mode seqüencial',
|
||||
desc1:
|
||||
'Premeu la tecla líder → premeu la drecera en seqüència → torneu a prémer la tecla líder.',
|
||||
desc2: 'Requereix més passos, però evita completament els conflictes del sistema.'
|
||||
},
|
||||
enable: 'Activa la tecla líder',
|
||||
tip: "Quan s'assigna com a tecla líder, aquesta tecla només funciona com a activador de dreceres i perd el seu comportament predeterminat.",
|
||||
placeholder: 'Premeu la tecla líder',
|
||||
shiftRight: 'Maj dreta',
|
||||
ctrlRight: 'Ctrl dret',
|
||||
metaRight: 'Win dret',
|
||||
submit: 'Envia',
|
||||
recorder: {
|
||||
rec: 'REC',
|
||||
activate: 'Activa les tecles',
|
||||
input: 'Premeu la drecera...'
|
||||
}
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: "Ratolí",
|
||||
cursor: "Estil del cursor",
|
||||
default: "Cursor per defecte",
|
||||
pointer: "Cursor punter",
|
||||
cell: "Cursor de cel·la",
|
||||
text: "Cursor de text",
|
||||
grab: "Cursor de mà",
|
||||
hide: "Amaga el cursor",
|
||||
mode: "Mode de ratolí",
|
||||
absolute: "Mode absolut",
|
||||
relative: "Mode relatiu",
|
||||
speed: "Velocitat de la roda",
|
||||
fast: "Ràpida",
|
||||
slow: "Lenta",
|
||||
title: 'Ratolí',
|
||||
cursor: 'Estil del cursor',
|
||||
default: 'Cursor per defecte',
|
||||
pointer: 'Cursor punter',
|
||||
cell: 'Cursor de cel·la',
|
||||
text: 'Cursor de text',
|
||||
grab: 'Cursor de mà',
|
||||
hide: 'Amaga el cursor',
|
||||
mode: 'Mode de ratolí',
|
||||
absolute: 'Mode absolut',
|
||||
relative: 'Mode relatiu',
|
||||
direction: 'Direcció de la roda de desplaçament',
|
||||
scrollUp: "Desplaça't cap amunt",
|
||||
scrollDown: "Desplaça't cap avall",
|
||||
speed: 'Velocitat de desplaçament',
|
||||
fast: 'Ràpida',
|
||||
slow: 'Lenta',
|
||||
requestPointer: "Estàs usant el mode relatiu. Fes clic a l'escriptori per obtenir el punter.",
|
||||
resetHid: "Restablir HID",
|
||||
resetHid: 'Restablir HID',
|
||||
hidOnly: {
|
||||
title: "Mode només HID",
|
||||
desc: "Si el teclat o ratolí deixen de funcionar i restablir HID no ajuda, pot ser un problema de compatibilitat. Prova d'activar el mode només HID.",
|
||||
tip1: "Activar aquest mode desmuntarà el disc virtual i la xarxa virtual",
|
||||
tip2: "En mode només HID, no es pot muntar imatges",
|
||||
tip3: "El NanoKVM es reiniciarà automàticament en canviar de mode",
|
||||
enable: "Activa mode només HID",
|
||||
disable: "Desactiva mode només HID"
|
||||
title: 'Mode només HID',
|
||||
desc: 'Si el ratolí i el teclat deixen de respondre i restablir HID no ajuda, pot ser un problema de compatibilitat entre el NanoKVM i el dispositiu. Proveu d’activar el mode només HID per millorar la compatibilitat.',
|
||||
tip1: 'Activar el mode només HID desmuntarà el disc virtual i la xarxa virtual',
|
||||
tip2: 'En mode només HID, no es pot muntar imatges',
|
||||
tip3: 'El NanoKVM es reiniciarà automàticament en canviar de mode',
|
||||
enable: 'Activa mode només HID',
|
||||
disable: 'Desactiva mode només HID'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: "Imatges",
|
||||
loading: "Carregant...",
|
||||
title: 'Imatges',
|
||||
loading: 'Carregant...',
|
||||
empty: "No s'ha trobat res",
|
||||
cdrom: "Munta la imatge en mode CD-ROM",
|
||||
mountFailed: "Error en muntar",
|
||||
mountDesc: "En alguns sistemes cal expulsar el disc virtual abans de muntar la imatge.",
|
||||
refresh: "Actualitza la llista",
|
||||
mountMode: 'Mode de muntatge',
|
||||
mountFailed: 'Error en muntar',
|
||||
mountDesc: 'En alguns sistemes cal expulsar el disc virtual abans de muntar la imatge.',
|
||||
unmountFailed: "No s'ha pogut desmuntar",
|
||||
unmountDesc:
|
||||
"En alguns sistemes, cal expulsar manualment de l'amfitrió remot abans de desmuntar la imatge.",
|
||||
refresh: 'Actualitza la llista',
|
||||
attention: 'Atenció',
|
||||
deleteConfirm: 'Esteu segur que voleu suprimir aquesta imatge?',
|
||||
okBtn: 'Sí',
|
||||
cancelBtn: 'No',
|
||||
tips: {
|
||||
title: "Com pujar imatges",
|
||||
usb1: "Connecta el NanoKVM al teu ordinador via USB.",
|
||||
title: 'Com pujar imatges',
|
||||
usb1: 'Connecta el NanoKVM al teu ordinador via USB.',
|
||||
usb2: "Assegura't que el disc virtual està muntat (Configuració - Disc Virtual).",
|
||||
usb3: "Obre el disc virtual i copia la imatge a l'arrel.",
|
||||
scp1: "Comprova que el NanoKVM i el teu ordinador estan a la mateixa xarxa.",
|
||||
scp2: "Obre un terminal i usa SCP per pujar la imatge al directori /data del NanoKVM.",
|
||||
scp3: "Exemple: scp ruta-de-la-imatge root@ip-del-nanokvm:/data",
|
||||
tfCard: "Targeta TF",
|
||||
tf1: "Mètode disponible a sistemes GNU/Linux",
|
||||
tf2: "Extreu la targeta TF del NanoKVM (versió FULL, cal obrir la carcassa).",
|
||||
scp1: 'Comprova que el NanoKVM i el teu ordinador estan a la mateixa xarxa.',
|
||||
scp2: 'Obre un terminal i usa SCP per pujar la imatge al directori /data del NanoKVM.',
|
||||
scp3: 'Exemple: scp ruta-de-la-imatge root@ip-del-nanokvm:/data',
|
||||
tfCard: 'Targeta TF',
|
||||
tf1: 'Mètode disponible a sistemes GNU/Linux',
|
||||
tf2: 'Extreu la targeta TF del NanoKVM (versió FULL, cal obrir la carcassa).',
|
||||
tf3: "Introdueix la targeta en un lector i connecta'l a l'ordinador.",
|
||||
tf4: "Copia la imatge al directori /data de la targeta.",
|
||||
tf5: "Reintrodueix la targeta al NanoKVM."
|
||||
tf4: 'Copia la imatge al directori /data de la targeta.',
|
||||
tf5: 'Reintrodueix la targeta al NanoKVM.'
|
||||
}
|
||||
},
|
||||
script: {
|
||||
title: "Scripts",
|
||||
upload: "Puja",
|
||||
run: "Executa",
|
||||
runBackground: "Executa en segon pla",
|
||||
title: 'Scripts',
|
||||
upload: 'Puja',
|
||||
run: 'Executa',
|
||||
runBackground: 'Executa en segon pla',
|
||||
runFailed: "Error en l'execució",
|
||||
attention: "Atenció",
|
||||
delDesc: "Estàs segur que vols eliminar aquest fitxer?",
|
||||
confirm: "Sí",
|
||||
cancel: "No",
|
||||
delete: "Esborra",
|
||||
close: "Tanca"
|
||||
attention: 'Atenció',
|
||||
delDesc: 'Estàs segur que vols eliminar aquest fitxer?',
|
||||
confirm: 'Sí',
|
||||
cancel: 'No',
|
||||
delete: 'Esborra',
|
||||
close: 'Tanca'
|
||||
},
|
||||
terminal: {
|
||||
title: "Terminal",
|
||||
nanokvm: "Terminal NanoKVM",
|
||||
serial: "Terminal de port sèrie",
|
||||
serialPort: "Port sèrie",
|
||||
serialPortPlaceholder: "Introdueix el port sèrie",
|
||||
baudrate: "Velocitat (baudrate)",
|
||||
parity: "Paritat",
|
||||
parityNone: "Cap",
|
||||
parityEven: "Parell",
|
||||
parityOdd: "Senar",
|
||||
flowControl: "Control de flux",
|
||||
flowControlNone: "Cap",
|
||||
flowControlSoft: "Suau",
|
||||
flowControlHard: "Dur",
|
||||
dataBits: "Bits de dades",
|
||||
stopBits: "Bits de parada",
|
||||
title: 'Terminal',
|
||||
nanokvm: 'Terminal NanoKVM',
|
||||
serial: 'Terminal de port sèrie',
|
||||
serialPort: 'Port sèrie',
|
||||
serialPortPlaceholder: 'Introdueix el port sèrie',
|
||||
baudrate: 'Velocitat (baudrate)',
|
||||
parity: 'Paritat',
|
||||
parityNone: 'Cap',
|
||||
parityEven: 'Parell',
|
||||
parityOdd: 'Senar',
|
||||
flowControl: 'Control de flux',
|
||||
flowControlNone: 'Cap',
|
||||
flowControlSoft: 'Programari',
|
||||
flowControlHard: 'Maquinari',
|
||||
dataBits: 'Bits de dades',
|
||||
stopBits: 'Bits de parada',
|
||||
confirm: "D'acord"
|
||||
},
|
||||
wol: {
|
||||
title: "Wake-on-LAN",
|
||||
sending: "Enviant comanda...",
|
||||
sent: "Comanda enviada",
|
||||
input: "Introdueix la MAC",
|
||||
title: 'Wake-on-LAN',
|
||||
sending: 'Enviant comanda...',
|
||||
sent: 'Comanda enviada',
|
||||
input: 'Introdueix la MAC',
|
||||
ok: "D'acord"
|
||||
},
|
||||
download: {
|
||||
title: "Descarregador d'imatges",
|
||||
input: "Introdueix la URL de la imatge",
|
||||
input: 'Introdueix la URL de la imatge',
|
||||
ok: "D'acord",
|
||||
disabled: "La partició /data és només lectura. No es pot descarregar la imatge."
|
||||
disabled: 'La partició /data és només lectura. No es pot descarregar la imatge.',
|
||||
uploadbox: 'Deixeu anar el fitxer aquí o feu clic per seleccionar-lo',
|
||||
inputfile: "Introduïu el fitxer d'imatge",
|
||||
NoISO: 'Cap ISO'
|
||||
},
|
||||
power: {
|
||||
title: "Alimentació",
|
||||
showConfirm: "Confirmació",
|
||||
title: 'Alimentació',
|
||||
showConfirm: 'Confirmació',
|
||||
showConfirmTip: "Les operacions d'alimentació requereixen confirmació",
|
||||
reset: "Reinicia",
|
||||
power: "Encén",
|
||||
powerShort: "Clic curt",
|
||||
powerLong: "Clic llarg",
|
||||
resetConfirm: "Vols realment reiniciar?",
|
||||
powerConfirm: "Vols realment encendre/apagar?",
|
||||
okBtn: "Sí",
|
||||
cancelBtn: "No"
|
||||
reset: 'Reinicia',
|
||||
power: 'Encén',
|
||||
powerShort: 'Clic curt',
|
||||
powerLong: 'Clic llarg',
|
||||
resetConfirm: 'Vols realment reiniciar?',
|
||||
powerConfirm: 'Vols realment encendre/apagar?',
|
||||
okBtn: 'Sí',
|
||||
cancelBtn: 'No'
|
||||
},
|
||||
settings: {
|
||||
title: "Configuració",
|
||||
title: 'Configuració',
|
||||
about: {
|
||||
title: "Sobre NanoKVM",
|
||||
information: "Informació",
|
||||
ip: "IP",
|
||||
mdns: "mDNS",
|
||||
application: "Versió aplicació",
|
||||
applicationTip: "Versió de la interfície web de NanoKVM",
|
||||
image: "Versió de la imatge",
|
||||
imageTip: "Versió del sistema NanoKVM",
|
||||
deviceKey: "Clau del dispositiu",
|
||||
community: "Comunitat",
|
||||
hostname: "Nom del dispositiu",
|
||||
hostnameUpdated: "Nom actualitzat. Reinicia per aplicar.",
|
||||
title: 'Sobre NanoKVM',
|
||||
information: 'Informació',
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: 'Versió aplicació',
|
||||
applicationTip: 'Versió de la interfície web de NanoKVM',
|
||||
image: 'Versió de la imatge',
|
||||
imageTip: 'Versió del sistema NanoKVM',
|
||||
deviceKey: 'Clau del dispositiu',
|
||||
community: 'Comunitat',
|
||||
hostname: 'Nom del dispositiu',
|
||||
hostnameUpdated: 'Nom actualitzat. Reinicia per aplicar.',
|
||||
ipType: {
|
||||
Wired: "Cablejada",
|
||||
Wireless: "Sense fil",
|
||||
Other: "Altra"
|
||||
Wired: 'Cablejada',
|
||||
Wireless: 'Sense fil',
|
||||
Other: 'Altra'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
title: "Aparença",
|
||||
display: "Pantalla",
|
||||
language: "Idioma",
|
||||
menuBar: "Barra de menú",
|
||||
menuBarDesc: "Mostra icones a la barra de menú",
|
||||
webTitle: "Títol web",
|
||||
webTitleDesc: "Personalitza el títol de la pàgina"
|
||||
title: 'Aparença',
|
||||
display: 'Pantalla',
|
||||
language: 'Idioma',
|
||||
languageDesc: "Seleccioneu l'idioma per a la interfície",
|
||||
webTitle: 'Títol web',
|
||||
webTitleDesc: 'Personalitza el títol de la pàgina',
|
||||
menuBar: {
|
||||
title: 'Barra de menús',
|
||||
mode: 'Mode de visualització',
|
||||
modeDesc: 'Mostra la barra de menús a la pantalla',
|
||||
modeOff: 'Apagat',
|
||||
modeAuto: 'Ocultació automàtica',
|
||||
modeAlways: 'Sempre visible',
|
||||
icons: 'Icones del submenú',
|
||||
iconsDesc: 'Mostra les icones del submenú a la barra de menús'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: "Dispositiu",
|
||||
title: 'Dispositiu',
|
||||
oled: {
|
||||
title: "OLED",
|
||||
description: "Apagar pantalla OLED després de",
|
||||
0: "Mai",
|
||||
15: "15 s",
|
||||
30: "30 s",
|
||||
60: "1 min",
|
||||
180: "3 min",
|
||||
300: "5 min",
|
||||
600: "10 min",
|
||||
1800: "30 min",
|
||||
3600: "1 h"
|
||||
},
|
||||
wifi: {
|
||||
title: "Xarxa sense fils",
|
||||
description: "Configura la xarxa sense fils",
|
||||
setBtn: "Configura"
|
||||
title: 'OLED',
|
||||
description: 'Apagar pantalla OLED després de',
|
||||
0: 'Mai',
|
||||
15: '15 s',
|
||||
30: '30 s',
|
||||
60: '1 min',
|
||||
180: '3 min',
|
||||
300: '5 min',
|
||||
600: '10 min',
|
||||
1800: '30 min',
|
||||
3600: '1 h'
|
||||
},
|
||||
ssh: {
|
||||
description: "Activa accés remot per SSH",
|
||||
tip: "Configura una contrasenya segura abans (Compte - Canvia contrasenya)"
|
||||
description: 'Activa accés remot per SSH',
|
||||
tip: 'Configura una contrasenya segura abans (Compte - Canvia contrasenya)'
|
||||
},
|
||||
tls: {
|
||||
description: "Activa el protocol HTTPS",
|
||||
tip: "Atenció: Usar HTTPS pot augmentar la latència, sobretot amb vídeo MJPEG."
|
||||
},
|
||||
advanced: "Configuració avançada",
|
||||
advanced: 'Configuració avançada',
|
||||
swap: {
|
||||
title: "Swap",
|
||||
disable: "Desactiva",
|
||||
description: "Defineix la mida del fitxer swap",
|
||||
tip: "Pot reduir la vida útil de la targeta SD!"
|
||||
title: 'Swap',
|
||||
disable: 'Desactiva',
|
||||
description: 'Defineix la mida del fitxer swap',
|
||||
tip: 'Pot reduir la vida útil de la targeta SD!'
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: "Mou-ratolí automàtic",
|
||||
description: "Evita que el dispositiu remot entri en repòs",
|
||||
disable: "Desactiva",
|
||||
absolute: "Mode absolut",
|
||||
relative: "Mode relatiu"
|
||||
title: 'Mou-ratolí automàtic',
|
||||
description: 'Evita que el dispositiu remot entri en repòs',
|
||||
disable: 'Desactiva',
|
||||
absolute: 'Mode absolut',
|
||||
relative: 'Mode relatiu'
|
||||
},
|
||||
mdns: {
|
||||
description: "Activa descobriment mDNS",
|
||||
tip: "Desactiva-ho si no és necessari"
|
||||
description: 'Activa descobriment mDNS',
|
||||
tip: 'Desactiva-ho si no és necessari'
|
||||
},
|
||||
hdmi: {
|
||||
description: "Activa la sortida HDMI"
|
||||
description: 'Activa la sortida HDMI'
|
||||
},
|
||||
hidOnly: "Mode només HID",
|
||||
disk: "Disc virtual",
|
||||
diskDesc: "Munta un disc U virtual al dispositiu remot",
|
||||
network: "Xarxa virtual",
|
||||
networkDesc: "Munta una targeta de xarxa virtual al dispositiu remot",
|
||||
reboot: "Reinicia",
|
||||
rebootDesc: "Segur que vols reiniciar el NanoKVM?",
|
||||
okBtn: "Sí",
|
||||
cancelBtn: "No"
|
||||
autostart: {
|
||||
title: "Configuració dels scripts d'inici automàtic",
|
||||
description: "Gestioneu els scripts que s'executen automàticament a l'inici del sistema",
|
||||
new: 'Nou',
|
||||
deleteConfirm: 'Estàs segur que vols eliminar aquest fitxer?',
|
||||
yes: 'Sí',
|
||||
no: 'No',
|
||||
scriptName: "Nom de l'script d'inici automàtic",
|
||||
scriptContent: "Contingut de l'script d'inici automàtic",
|
||||
settings: 'Configuració'
|
||||
},
|
||||
hidOnly: 'Mode només HID',
|
||||
hidOnlyDesc: "Deixeu d'emular dispositius virtuals, conservant només el control bàsic HID",
|
||||
disk: 'Disc virtual',
|
||||
diskDesc: 'Munta un disc U virtual al dispositiu remot',
|
||||
network: 'Xarxa virtual',
|
||||
networkDesc: 'Munta una targeta de xarxa virtual al dispositiu remot',
|
||||
reboot: 'Reinicia',
|
||||
rebootDesc: 'Segur que vols reiniciar el NanoKVM?',
|
||||
okBtn: 'Sí',
|
||||
cancelBtn: 'No'
|
||||
},
|
||||
network: {
|
||||
title: 'Xarxa',
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configura la xarxa Wi-Fi',
|
||||
apMode: "El mode AP està activat; connecta't al Wi-Fi escanejant el codi QR",
|
||||
connect: "Connecta't a Wi-Fi",
|
||||
connectDesc1: 'Introdueix el SSID i la contrasenya de la xarxa',
|
||||
connectDesc2: 'Introdueix la contrasenya per connectar-te a aquesta xarxa',
|
||||
disconnect: 'Segur que vols desconnectar la xarxa?',
|
||||
failed: 'La connexió ha fallat, torna-ho a provar.',
|
||||
ssid: 'Nom',
|
||||
password: 'Contrasenya',
|
||||
joinBtn: 'Connecta',
|
||||
confirmBtn: "D'acord",
|
||||
cancelBtn: 'Cancel·la'
|
||||
},
|
||||
tls: {
|
||||
description: 'Activa el protocol HTTPS',
|
||||
tip: 'Atenció: Usar HTTPS pot augmentar la latència, sobretot amb vídeo MJPEG.'
|
||||
},
|
||||
dns: {
|
||||
title: 'DNS',
|
||||
description: 'Configura els servidors DNS per a NanoKVM',
|
||||
mode: 'Mode',
|
||||
dhcp: 'DHCP',
|
||||
manual: 'Manual',
|
||||
add: 'Afegeix DNS',
|
||||
save: 'Desa',
|
||||
invalid: 'Introdueix una adreça IP vàlida',
|
||||
noDhcp: 'No hi ha cap DNS DHCP disponible actualment',
|
||||
saved: 'Configuració DNS desada',
|
||||
saveFailed: "No s'ha pogut desar la configuració DNS",
|
||||
unsaved: 'Canvis no desats',
|
||||
maxServers: 'Es permeten com a màxim {{count}} servidors DNS',
|
||||
dnsServers: 'Servidors DNS',
|
||||
dhcpServersDescription: "Els servidors DNS s'obtenen automàticament via DHCP",
|
||||
manualServersDescription: 'Els servidors DNS es poden editar manualment',
|
||||
networkDetails: 'Detalls de xarxa',
|
||||
interface: 'Interfície',
|
||||
ipAddress: 'Adreça IP',
|
||||
subnetMask: 'Màscara de subxarxa',
|
||||
router: 'Encaminador',
|
||||
none: 'Cap'
|
||||
}
|
||||
},
|
||||
tailscale: {
|
||||
title: "Tailscale",
|
||||
title: 'Tailscale',
|
||||
memory: {
|
||||
title: "Optimització de memòria",
|
||||
tip: "Quan es supera el límit de memòria, es fa una neteja més agressiva. Recomanat: 75MB si uses Tailscale. Requereix reiniciar Tailscale.",
|
||||
disable: "Desactiva"
|
||||
title: 'Optimització de memòria',
|
||||
tip: 'Quan es supera el límit de memòria, es fa una neteja més agressiva. Recomanat: 75MB si uses Tailscale. Requereix reiniciar Tailscale.'
|
||||
},
|
||||
restart: "Reiniciar Tailscale?",
|
||||
stop: "Aturar Tailscale?",
|
||||
stopDesc: "Tanca la sessió i desactiva l'inici automàtic",
|
||||
loading: "Carregant...",
|
||||
notInstall: "Tailscale no instal·lat! Instal·la-ho.",
|
||||
install: "Instal·la",
|
||||
installing: "Instal·lant",
|
||||
failed: "Error en la instal·lació",
|
||||
retry: "Actualitza i torna-ho a provar. O instal·la manualment",
|
||||
download: "Descarrega el",
|
||||
swap: {
|
||||
title: 'Intercanvi de memòria',
|
||||
tip: "Si els problemes persisteixen després d'activar l'optimització de memòria, proveu d'habilitar la memòria d'intercanvi. Això estableix la mida del fitxer d'intercanvi a 256MB per defecte, que es pot ajustar a \"Configuració > Dispositiu\"."
|
||||
},
|
||||
restart: 'Reiniciar Tailscale?',
|
||||
stop: 'Aturar Tailscale?',
|
||||
stopDesc: 'Tanca la sessió de Tailscale i desactiva l’inici automàtic en arrencar.',
|
||||
loading: 'Carregant...',
|
||||
notInstall: 'Tailscale no instal·lat! Instal·la-ho.',
|
||||
install: 'Instal·la',
|
||||
installing: 'Instal·lant',
|
||||
failed: 'Error en la instal·lació',
|
||||
retry: 'Actualitza i torna-ho a provar. O instal·la manualment',
|
||||
download: 'Descarrega el',
|
||||
package: "paquet d'instal·lació",
|
||||
unzip: "i descomprimeix-lo",
|
||||
upTailscale: "Puja tailscale a /usr/bin/",
|
||||
upTailscaled: "Puja tailscaled a /usr/sbin/",
|
||||
refresh: "Actualitza la pàgina",
|
||||
notLogin: "El dispositiu no està vinculat. Inicia sessió per vincular-lo.",
|
||||
urlPeriod: "Aquesta URL és vàlida durant 10 minuts",
|
||||
login: "Inicia sessió",
|
||||
loginSuccess: "Sessió iniciada correctament",
|
||||
enable: "Activa Tailscale",
|
||||
deviceName: "Nom del dispositiu",
|
||||
deviceIP: "IP del dispositiu",
|
||||
account: "Compte",
|
||||
logout: "Tanca sessió",
|
||||
logoutDesc: "Segur que vols tancar sessió?",
|
||||
uninstall: "Desinstal·la Tailscale",
|
||||
okBtn: "Sí",
|
||||
cancelBtn: "No"
|
||||
unzip: 'i descomprimeix-lo',
|
||||
upTailscale: 'Puja tailscale al directori /usr/bin/ del NanoKVM',
|
||||
upTailscaled: 'Puja tailscaled al directori /usr/sbin/ del NanoKVM',
|
||||
refresh: 'Actualitza la pàgina',
|
||||
notRunning: "Tailscale no s'està executant. Si us plau, inicieu-lo per continuar.",
|
||||
run: 'Comença',
|
||||
notLogin: 'El dispositiu no està vinculat. Inicia sessió per vincular-lo.',
|
||||
urlPeriod: 'Aquesta URL és vàlida durant 10 minuts',
|
||||
login: 'Inicia sessió',
|
||||
loginSuccess: 'Sessió iniciada correctament',
|
||||
enable: 'Activa Tailscale',
|
||||
deviceName: 'Nom del dispositiu',
|
||||
deviceIP: 'IP del dispositiu',
|
||||
account: 'Compte',
|
||||
logout: 'Tanca sessió',
|
||||
logoutDesc: 'Segur que vols tancar sessió?',
|
||||
uninstall: 'Desinstal·la Tailscale',
|
||||
uninstallDesc: 'Esteu segur que voleu desinstal·lar Tailscale?',
|
||||
okBtn: 'Sí',
|
||||
cancelBtn: 'No'
|
||||
},
|
||||
update: {
|
||||
title: "Comprova actualitzacions",
|
||||
queryFailed: "Error en obtenir la versió",
|
||||
updateFailed: "Error en actualitzar. Torna-ho a intentar.",
|
||||
isLatest: "Ja tens la darrera versió.",
|
||||
available: "Hi ha una actualització disponible. Vols actualitzar ara?",
|
||||
updating: "Actualitzant... espera",
|
||||
confirm: "Confirma",
|
||||
cancel: "Cancel·la",
|
||||
preview: "Versió de prova",
|
||||
previewDesc: "Prova noves funcions abans que ningú",
|
||||
previewTip: "Compte: aquestes versions poden tenir errors o funcions inacabades!"
|
||||
title: 'Comprova actualitzacions',
|
||||
queryFailed: 'Error en obtenir la versió',
|
||||
updateFailed: 'Error en actualitzar. Torna-ho a intentar.',
|
||||
isLatest: 'Ja tens la darrera versió.',
|
||||
available: 'Hi ha una actualització disponible. Vols actualitzar ara?',
|
||||
updating: 'Actualitzant... espera',
|
||||
confirm: 'Confirma',
|
||||
cancel: 'Cancel·la',
|
||||
preview: 'Versió de prova',
|
||||
previewDesc: 'Prova noves funcions abans que ningú',
|
||||
previewTip: 'Compte: aquestes versions poden tenir errors o funcions inacabades!',
|
||||
offline: {
|
||||
title: 'Actualitzacions fora de línia',
|
||||
desc: "Actualització mitjançant el paquet d'instal·lació local",
|
||||
upload: 'Puja',
|
||||
invalidName: 'Format de nom de fitxer no vàlid. Baixeu-lo des de les versions de GitHub.',
|
||||
updateFailed: 'Error en actualitzar. Torna-ho a intentar.'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
title: "Compte",
|
||||
webAccount: "Nom del compte web",
|
||||
password: "Contrasenya",
|
||||
updateBtn: "Canvia",
|
||||
logoutBtn: "Tanca sessió",
|
||||
logoutDesc: "Segur que vols tancar sessió?",
|
||||
okBtn: "Sí",
|
||||
cancelBtn: "No"
|
||||
title: 'Compte',
|
||||
webAccount: 'Nom del compte web',
|
||||
password: 'Contrasenya',
|
||||
updateBtn: 'Canvia',
|
||||
logoutBtn: 'Tanca sessió',
|
||||
logoutDesc: 'Segur que vols tancar sessió?',
|
||||
okBtn: 'Sí',
|
||||
cancelBtn: 'No'
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
title: 'PicoClaw Assistent',
|
||||
empty: 'Obriu el tauler i inicieu una tasca per començar.',
|
||||
inputPlaceholder: 'Descriu què vols que faci el PicoClaw',
|
||||
newConversation: 'Nova conversa',
|
||||
processing: "S'està processant...",
|
||||
agent: {
|
||||
defaultTitle: 'Assistent general',
|
||||
defaultDescription: 'Ajuda general de xat, cerca i espai de treball.',
|
||||
kvmTitle: 'Control remot',
|
||||
kvmDescription: "Opera l'amfitrió remot mitjançant NanoKVM.",
|
||||
switched: "Rol d'agent canviat",
|
||||
switchFailed: "No s'ha pogut canviar la funció d'agent"
|
||||
},
|
||||
send: 'Envia',
|
||||
cancel: 'Cancel·la',
|
||||
status: {
|
||||
connecting: "S'està connectant a la passarel·la...",
|
||||
connected: 'Sessió de PicoClaw connectada',
|
||||
disconnected: 'Sessió de PicoClaw tancada',
|
||||
stopped: "S'ha enviat la sol·licitud d'aturada",
|
||||
runtimeStarted: "Temps d'execució de PicoClaw iniciat",
|
||||
runtimeStartFailed: "No s'ha pogut iniciar el temps d'execució de PicoClaw",
|
||||
runtimeStopped: "Temps d'execució de PicoClaw aturat",
|
||||
runtimeStopFailed: "No s'ha pogut aturar el temps d'execució de PicoClaw"
|
||||
},
|
||||
connection: {
|
||||
runtime: {
|
||||
checking: 'Comprovació',
|
||||
ready: "Temps d'execució a punt",
|
||||
stopped: "El temps d'execució s'ha aturat",
|
||||
unavailable: "Temps d'execució no disponible",
|
||||
configError: 'Error de configuració'
|
||||
},
|
||||
transport: {
|
||||
connecting: 'En connexió',
|
||||
connected: 'Connectat'
|
||||
},
|
||||
run: {
|
||||
idle: 'Inactiu',
|
||||
busy: 'Ocupat'
|
||||
}
|
||||
},
|
||||
message: {
|
||||
toolAction: 'Acció',
|
||||
observation: 'Observació',
|
||||
screenshot: 'Captura de pantalla'
|
||||
},
|
||||
overlay: {
|
||||
locked: "PicoClaw està controlant el dispositiu. L'entrada manual està en pausa."
|
||||
},
|
||||
install: {
|
||||
install: 'Instal·la PicoClaw',
|
||||
installing: 'Instal·lant PicoClaw',
|
||||
success: 'PicoClaw instal·lat correctament',
|
||||
failed: "No s'ha pogut instal·lar PicoClaw",
|
||||
uninstalling: "S'està desinstal·lant el temps d'execució...",
|
||||
uninstalled: "El temps d'execució s'ha desinstal·lat correctament.",
|
||||
uninstallFailed: 'La desinstal·lació ha fallat.',
|
||||
requiredTitle: 'PicoClaw no està instal·lat',
|
||||
requiredDescription: "Instal·leu PicoClaw abans d'iniciar el temps d'execució de PicoClaw.",
|
||||
progressDescription: "PicoClaw s'està baixant i instal·lant.",
|
||||
stages: {
|
||||
preparing: 'Preparant',
|
||||
downloading: "S'està baixant",
|
||||
extracting: 'Extracció',
|
||||
installing: 'Instal·lant',
|
||||
installed: 'Instal·lat',
|
||||
install_timeout: 'Temps esgotat',
|
||||
install_failed: 'Ha fallat'
|
||||
}
|
||||
},
|
||||
model: {
|
||||
requiredTitle: 'La configuració del model és necessària',
|
||||
requiredDescription: "Configura el model PicoClaw abans d'utilitzar el xat PicoClaw.",
|
||||
docsTitle: 'Guia de configuració',
|
||||
docsDesc: 'Models i protocols compatibles',
|
||||
menuLabel: 'Configura el model',
|
||||
modelIdentifier: 'Identificador del model',
|
||||
modelIdentifierPlaceholder: 'openai/gpt-5.4',
|
||||
apiBase: 'API Base URL',
|
||||
apiBasePlaceholder: 'https://api.example.com/v1',
|
||||
apiKey: 'Clau API',
|
||||
apiKeyPlaceholder: 'Introduïu la clau API del model',
|
||||
save: 'Desa',
|
||||
saving: 'Desa',
|
||||
saved: "S'ha desat la configuració del model",
|
||||
saveFailed: "No s'ha pogut desar la configuració del model",
|
||||
invalid: "Cal indicar l'identificador del model, l'API Base URL i la clau API"
|
||||
},
|
||||
uninstall: {
|
||||
menuLabel: 'Desinstal·la',
|
||||
confirmTitle: 'Desinstal·la PicoClaw',
|
||||
confirmContent:
|
||||
"Esteu segur que voleu desinstal·lar PicoClaw? Això suprimirà l'executable i tots els fitxers de configuració.",
|
||||
confirmOk: 'Desinstal·la',
|
||||
confirmCancel: 'Cancel·la'
|
||||
},
|
||||
history: {
|
||||
title: 'Historial',
|
||||
loading: 'Carregant sessions...',
|
||||
emptyTitle: 'Encara no hi ha historial',
|
||||
emptyDescription: 'Les sessions anteriors de PicoClaw apareixeran aquí.',
|
||||
loadFailed: "No s'ha pogut carregar l'historial de sessions",
|
||||
deleteFailed: "No s'ha pogut suprimir la sessió",
|
||||
deleteConfirmTitle: 'Suprimeix la sessió',
|
||||
deleteConfirmContent: 'Esteu segur que voleu suprimir "{{title}}"?',
|
||||
deleteConfirmOk: 'Esborra',
|
||||
deleteConfirmCancel: 'Cancel·la',
|
||||
messageCount_one: '{{count}} missatge',
|
||||
messageCount_other: '{{count}} missatges'
|
||||
},
|
||||
config: {
|
||||
startRuntime: 'Inici PicoClaw',
|
||||
stopRuntime: 'Atura PicoClaw'
|
||||
},
|
||||
start: {
|
||||
title: 'Inici PicoClaw',
|
||||
description: "Inicieu el temps d'execució per començar a utilitzar l'assistent PicoClaw."
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: "Hi ha hagut un error",
|
||||
refresh: "Actualitza"
|
||||
title: 'Hi ha hagut un error',
|
||||
refresh: 'Actualitza'
|
||||
},
|
||||
fullscreen: {
|
||||
toggle: "Pantalla completa"
|
||||
toggle: 'Pantalla completa'
|
||||
},
|
||||
menu: {
|
||||
collapse: "Amaga menú",
|
||||
expand: "Mostra menú"
|
||||
collapse: 'Amaga menú',
|
||||
expand: 'Mostra menú'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ca;
|
||||
export default ca;
|
||||
|
||||
@@ -17,6 +17,8 @@ const cz = {
|
||||
noAccount:
|
||||
'Nepodařilo se získat informace o uživateli, prosím obnovte stránku nebo resetujte heslo',
|
||||
invalidUser: 'Neplatné uživatelské jméno nebo heslo',
|
||||
locked: 'Příliš mnoho přihlášení, zkuste to znovu později',
|
||||
globalLocked: 'Systém je chráněn, zkuste to znovu později',
|
||||
error: 'Neočekávaná chyba',
|
||||
changePassword: 'Změnit heslo',
|
||||
changePasswordDesc:
|
||||
@@ -31,25 +33,37 @@ const cz = {
|
||||
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.'
|
||||
reset2: 'Podrobné kroky najdete v tomto dokumentu:',
|
||||
reset3: 'Výchozí webový účet:',
|
||||
reset4: 'Výchozí účet SSH:',
|
||||
change1: 'Upozorňujeme, že tato akce změní následující hesla:',
|
||||
change2: 'Heslo pro webové přihlášení',
|
||||
change3: 'Heslo systémového uživatele root (heslo pro přihlášení SSH)',
|
||||
change4: 'Chcete-li hesla resetovat, podržte tlačítko BOOT na NanoKVM.'
|
||||
}
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi for NanoKVM',
|
||||
description: 'Nastavit Wi-Fi pro NanoKVM',
|
||||
success: 'Please check the network status of NanoKVM and visit the new IP address.',
|
||||
failed: 'Operation failed, please try again.',
|
||||
failed: 'Operace selhala, zkuste to znovu.',
|
||||
invalidMode:
|
||||
'Aktuální režim nepodporuje nastavení sítě. Přejděte do svého zařízení a povolte konfigurační režim Wi-Fi.',
|
||||
confirmBtn: 'Ok',
|
||||
finishBtn: 'Finished'
|
||||
finishBtn: 'Dokončeno',
|
||||
ap: {
|
||||
authTitle: 'Vyžaduje se ověření',
|
||||
authDescription: 'Pokračujte zadáním hesla AP',
|
||||
authFailed: 'Neplatné heslo AP',
|
||||
passPlaceholder: 'AP heslo',
|
||||
verifyBtn: 'Ověřte'
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
scale: 'Měřítko',
|
||||
title: 'Obrazovka',
|
||||
video: 'Režim videa',
|
||||
videoDirectTips: 'Chcete-li používat tento režim, povolte HTTPS v "Nastavení > Zařízení"',
|
||||
resolution: 'Rozlišení',
|
||||
auto: 'Automatické',
|
||||
autoTips:
|
||||
@@ -64,17 +78,65 @@ const cz = {
|
||||
frameDetect: 'Detekce snímků',
|
||||
frameDetectTip:
|
||||
'Vypočítá rozdíl mezi snímky. Přenos video streamu se zastaví, pokud nejsou detekovány změny na obrazovce vzdáleného hostitele.',
|
||||
resetHdmi: 'Reset HDMI'
|
||||
resetHdmi: 'Resetovat HDMI'
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Klávesnice',
|
||||
paste: 'Vložit',
|
||||
tips: 'Podporovány jsou pouze standardní písmena a symboly klávesnice',
|
||||
placeholder: 'Zadejte text',
|
||||
submit: 'Odeslat',
|
||||
virtual: 'Klávesnice',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
readClipboard: 'Přečíst ze schránky',
|
||||
clipboardPermissionDenied:
|
||||
'Oprávnění ke schránce odepřeno. Povolte prosím přístup do schránky ve svém prohlížeči.',
|
||||
clipboardReadError: 'Nepodařilo se přečíst schránku',
|
||||
dropdownEnglish: 'anglicky',
|
||||
dropdownGerman: 'německy',
|
||||
dropdownFrench: 'francouzsky',
|
||||
dropdownRussian: 'rusky',
|
||||
shortcut: {
|
||||
title: 'Zkratky',
|
||||
custom: 'Vlastní',
|
||||
capture: 'Kliknutím sem zachytíte zástupce',
|
||||
clear: 'Jasno',
|
||||
save: 'Uložit',
|
||||
captureTips:
|
||||
'Zachycení systémových kláves (například klávesy Windows) vyžaduje oprávnění pro celou obrazovku.',
|
||||
enterFullScreen: 'Přepnout režim celé obrazovky.'
|
||||
},
|
||||
leaderKey: {
|
||||
title: 'Klávesa Leader',
|
||||
desc: 'Obejít omezení prohlížeče a odeslat systémové zkratky přímo vzdálenému hostiteli.',
|
||||
howToUse: 'Jak používat',
|
||||
simultaneous: {
|
||||
title: 'Simultánní režim',
|
||||
desc1: 'Stiskněte a podržte klávesu Leader a poté stiskněte zkratku.',
|
||||
desc2: 'Intuitivní, ale může být v rozporu se systémovými zkratkami.'
|
||||
},
|
||||
sequential: {
|
||||
title: 'Sekvenční režim',
|
||||
desc1:
|
||||
'Stiskněte klávesu Leader → postupně stiskněte zkratku → znovu stiskněte klávesu Leader.',
|
||||
desc2: 'Vyžaduje více kroků, ale zcela se vyhne systémovým konfliktům.'
|
||||
},
|
||||
enable: 'Povolit klávesu Leader',
|
||||
tip: 'Když je tato klávesa nastavena jako klávesa Leader, slouží pouze jako spouštěč zkratek a ztrácí své výchozí chování.',
|
||||
placeholder: 'Stiskněte klávesu Leader',
|
||||
shiftRight: 'Pravý Shift',
|
||||
ctrlRight: 'Pravý Ctrl',
|
||||
metaRight: 'Pravý Win',
|
||||
submit: 'Odeslat',
|
||||
recorder: {
|
||||
rec: 'REC',
|
||||
activate: 'Aktivovat klávesy',
|
||||
input: 'Stiskněte prosím zkratku...'
|
||||
}
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: 'Myš',
|
||||
cursor: 'Styl kurzoru',
|
||||
default: 'Výchozí kurzor',
|
||||
pointer: 'Ukazovací kurzor',
|
||||
cell: 'Kurzor buňky',
|
||||
@@ -84,17 +146,41 @@ const cz = {
|
||||
mode: 'Režim myši',
|
||||
absolute: 'Absolutní režim',
|
||||
relative: 'Relativní režim',
|
||||
direction: 'Směr kolečka',
|
||||
scrollUp: 'Přejděte nahoru',
|
||||
scrollDown: 'Přejděte dolů',
|
||||
speed: 'Rychlost kolečka',
|
||||
fast: 'Rychle',
|
||||
slow: 'Pomalu',
|
||||
requestPointer:
|
||||
'Používá se relativní režim. Klikněte prosím na plochu pro získání kurzoru myši.',
|
||||
resetHid: 'Resetovat HID'
|
||||
resetHid: 'Resetovat HID',
|
||||
hidOnly: {
|
||||
title: 'Režim pouze HID',
|
||||
desc: 'Pokud vaše myš a klávesnice přestanou reagovat a resetování HID nepomůže, může jít o problém s kompatibilitou mezi NanoKVM a zařízením. Zkuste povolit režim HID-Only pro lepší kompatibilitu.',
|
||||
tip1: 'Povolení režimu HID-Only odpojí virtuální U-disk a virtuální síť',
|
||||
tip2: 'V režimu HID-Only je připojení obrazu zakázáno',
|
||||
tip3: 'NanoKVM se po přepnutí režimů automaticky restartuje',
|
||||
enable: 'Povolit režim HID-Only',
|
||||
disable: 'Zakázat režim HID-Only'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Obrázky',
|
||||
loading: 'Načítání...',
|
||||
empty: 'Nic nenalezeno',
|
||||
mountMode: 'Režim připojení',
|
||||
mountFailed: 'Připojení se nezdařilo',
|
||||
mountDesc:
|
||||
'V některých systémech je nutné před připojením obrazu vysunout virtuální disk na vzdáleném hostiteli.',
|
||||
unmountFailed: 'Odpojení se nezdařilo',
|
||||
unmountDesc:
|
||||
'Na některých systémech se musíte před odpojením obrazu ručně vysunout ze vzdáleného hostitele.',
|
||||
refresh: 'Obnovte seznam obrázků',
|
||||
attention: 'Pozor',
|
||||
deleteConfirm: 'Opravdu chcete smazat tento obrázek?',
|
||||
okBtn: 'Ano',
|
||||
cancelBtn: 'Ne',
|
||||
tips: {
|
||||
title: 'Jak nahrát',
|
||||
usb1: 'Připojte NanoKVM k vašemu počítači přes USB.',
|
||||
@@ -131,6 +217,16 @@ const cz = {
|
||||
serialPort: 'Sériový port',
|
||||
serialPortPlaceholder: 'Zadejte prosím sériový port',
|
||||
baudrate: 'Přenosová rychlost',
|
||||
parity: 'Parita',
|
||||
parityNone: 'Žádné',
|
||||
parityEven: 'Sudá',
|
||||
parityOdd: 'Lichá',
|
||||
flowControl: 'Řízení toku',
|
||||
flowControlNone: 'Žádné',
|
||||
flowControlSoft: 'Softwarové',
|
||||
flowControlHard: 'Hardwarové',
|
||||
dataBits: 'Datové bity',
|
||||
stopBits: 'Stop bity',
|
||||
confirm: 'OK'
|
||||
},
|
||||
wol: {
|
||||
@@ -140,12 +236,27 @@ const cz = {
|
||||
input: 'Zadejte prosím MAC adresu',
|
||||
ok: 'OK'
|
||||
},
|
||||
download: {
|
||||
title: 'Stahovač obrazů',
|
||||
input: 'Zadejte prosím vzdálený obrázek URL',
|
||||
ok: 'OK',
|
||||
disabled: 'Oddíl /data je RO, takže obrázek nelze stáhnout',
|
||||
uploadbox: 'Přetáhněte soubor sem nebo kliknutím vyberte',
|
||||
inputfile: 'Zadejte soubor obrázku',
|
||||
NoISO: 'Žádné ISO'
|
||||
},
|
||||
power: {
|
||||
title: 'Napájení',
|
||||
showConfirm: 'Potvrzení',
|
||||
showConfirmTip: 'Výkonové operace vyžadují další potvrzení',
|
||||
reset: 'Resetovat',
|
||||
power: 'Napájení',
|
||||
powerShort: 'Napájení (krátký stisk)',
|
||||
powerLong: 'Napájení (dlouhý stisk)'
|
||||
powerLong: 'Napájení (dlouhý stisk)',
|
||||
resetConfirm: 'Pokračovat v operaci resetování?',
|
||||
powerConfirm: 'Pokračovat v napájení?',
|
||||
okBtn: 'Ano',
|
||||
cancelBtn: 'Ne'
|
||||
},
|
||||
settings: {
|
||||
title: 'Nastavení',
|
||||
@@ -155,25 +266,43 @@ const cz = {
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: 'Verze aplikace',
|
||||
applicationTip: 'NanoKVM web application version',
|
||||
applicationTip: 'Verze webové aplikace NanoKVM',
|
||||
image: 'Verze obrazu',
|
||||
imageTip: 'NanoKVM system image version',
|
||||
imageTip: 'Verze systémového obrazu NanoKVM',
|
||||
deviceKey: 'Klíč zařízení',
|
||||
community: 'Komunita'
|
||||
community: 'Komunita',
|
||||
hostname: 'Název hostitele',
|
||||
hostnameUpdated: 'Název hostitele byl aktualizován. Pro použití restartujte.',
|
||||
ipType: {
|
||||
Wired: 'Kabelové',
|
||||
Wireless: 'Bezdrátové',
|
||||
Other: 'Jiné'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
display: 'Display',
|
||||
title: 'Vzhled',
|
||||
display: 'Zobrazení',
|
||||
language: 'Jazyk',
|
||||
menuBar: 'Menu Bar',
|
||||
menuBarDesc: 'Display icons in the menu bar'
|
||||
languageDesc: 'Vyberte jazyk rozhraní',
|
||||
webTitle: 'Název webu',
|
||||
webTitleDesc: 'Přizpůsobte název webové stránky',
|
||||
menuBar: {
|
||||
title: 'Panel nabídek',
|
||||
mode: 'Režim zobrazení',
|
||||
modeDesc: 'Zobrazení panelu nabídek na obrazovce',
|
||||
modeOff: 'Vypnuto',
|
||||
modeAuto: 'Automatické skrytí',
|
||||
modeAlways: 'Vždy viditelné',
|
||||
icons: 'Ikony podnabídky',
|
||||
iconsDesc: 'Zobrazení ikon podnabídky na liště nabídek'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: 'Device',
|
||||
title: 'Zařízení',
|
||||
oled: {
|
||||
title: 'OLED',
|
||||
description: 'OLED screen automatically sleep',
|
||||
0: 'Never',
|
||||
0: 'Nikdy',
|
||||
15: '15 sec',
|
||||
30: '30 sec',
|
||||
60: '1 min',
|
||||
@@ -181,24 +310,110 @@ const cz = {
|
||||
300: '5 min',
|
||||
600: '10 min',
|
||||
1800: '30 min',
|
||||
3600: '1 hour'
|
||||
3600: '1 hodina'
|
||||
},
|
||||
ssh: {
|
||||
description: 'Povolit vzdálený přístup SSH',
|
||||
tip: 'Před povolením nastavte silné heslo (Účet – Změnit heslo)'
|
||||
},
|
||||
advanced: 'Pokročilá nastavení',
|
||||
swap: {
|
||||
title: 'Vyměnit',
|
||||
disable: 'Zakázat',
|
||||
description: 'Nastavte velikost odkládacího souboru',
|
||||
tip: 'Povolení této funkce může zkrátit životnost vaší SD karty!'
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: 'Mouse Jiggler',
|
||||
description: 'Zabraňte spánku vzdáleného hostitele',
|
||||
disable: 'Zakázat',
|
||||
absolute: 'Absolutní režim',
|
||||
relative: 'Relativní režim'
|
||||
},
|
||||
mdns: {
|
||||
description: 'Povolit službu zjišťování mDNS',
|
||||
tip: 'Vypnutí, pokud to není potřeba'
|
||||
},
|
||||
hdmi: {
|
||||
description: 'Povolit výstup HDMI/monitor'
|
||||
},
|
||||
autostart: {
|
||||
title: 'Nastavení automatického spuštění skriptů',
|
||||
description: 'Správa skriptů, které se spouštějí automaticky při spuštění systému',
|
||||
new: 'Nové',
|
||||
deleteConfirm: 'Opravdu chcete tento soubor smazat?',
|
||||
yes: 'Ano',
|
||||
no: 'Ne',
|
||||
scriptName: 'Název skriptu automatického spuštění',
|
||||
scriptContent: 'Obsah skriptu automatického spuštění',
|
||||
settings: 'Nastavení'
|
||||
},
|
||||
hidOnly: 'HID-Pouze režim',
|
||||
hidOnlyDesc: 'Zastavit emulaci virtuálních zařízení a zachovat pouze základní ovládání HID',
|
||||
disk: 'Virtuální disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
network: 'Virtuální síť',
|
||||
networkDesc: 'Připojit virtuální síťovou kartu na vzdáleném hostiteli',
|
||||
reboot: 'Restartujte',
|
||||
rebootDesc: 'Opravdu chcete restartovat NanoKVM?',
|
||||
okBtn: 'Ano',
|
||||
cancelBtn: 'Ne'
|
||||
},
|
||||
network: {
|
||||
title: 'Síť',
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi',
|
||||
setBtn: 'Config'
|
||||
description: 'Nastavit Wi-Fi',
|
||||
apMode: 'Režim AP je povolen, připojte se k Wi-Fi naskenováním QR kódu',
|
||||
connect: 'Připojit Wi-Fi',
|
||||
connectDesc1: 'Zadejte SSID sítě a heslo',
|
||||
connectDesc2: 'Zadejte heslo pro připojení k této síti',
|
||||
disconnect: 'Opravdu chcete síť odpojit?',
|
||||
failed: 'Připojení se nezdařilo, zkuste to znovu.',
|
||||
ssid: 'Název',
|
||||
password: 'Heslo',
|
||||
joinBtn: 'Připojit',
|
||||
confirmBtn: 'OK',
|
||||
cancelBtn: 'Zrušit'
|
||||
},
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
network: 'Virtual Network',
|
||||
networkDesc: 'Mount virtual network card on the remote host'
|
||||
tls: {
|
||||
description: 'Povolit protokol HTTPS',
|
||||
tip: 'Upozornění: Použití HTTPS může zvýšit latenci, zejména v režimu videa MJPEG.'
|
||||
},
|
||||
dns: {
|
||||
title: 'DNS',
|
||||
description: 'Nastavit DNS servery pro NanoKVM',
|
||||
mode: 'Režim',
|
||||
dhcp: 'DHCP',
|
||||
manual: 'Ručně',
|
||||
add: 'Přidat DNS',
|
||||
save: 'Uložit',
|
||||
invalid: 'Zadejte platnou IP adresu',
|
||||
noDhcp: 'Momentálně není k dispozici žádné DHCP DNS',
|
||||
saved: 'Nastavení DNS uloženo',
|
||||
saveFailed: 'Nastavení DNS se nepodařilo uložit',
|
||||
unsaved: 'Neuložené změny',
|
||||
maxServers: 'Je povoleno maximálně {{count}} DNS serverů',
|
||||
dnsServers: 'DNS servery',
|
||||
dhcpServersDescription: 'DNS servery jsou automaticky získávány z DHCP',
|
||||
manualServersDescription: 'DNS servery lze upravit ručně',
|
||||
networkDetails: 'Podrobnosti sítě',
|
||||
interface: 'Rozhraní',
|
||||
ipAddress: 'IP adresa',
|
||||
subnetMask: 'Maska podsítě',
|
||||
router: 'Router',
|
||||
none: 'Žádné'
|
||||
}
|
||||
},
|
||||
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: 'Optimalizace paměti',
|
||||
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."
|
||||
},
|
||||
swap: {
|
||||
title: 'Vyměňte paměť',
|
||||
tip: 'Pokud problémy přetrvávají i po povolení optimalizace paměti, zkuste povolit odkládací paměť. Tím se ve výchozím nastavení nastaví velikost odkládacího souboru na 256MB, kterou lze upravit v „Nastavení > Zařízení“.'
|
||||
},
|
||||
restart: 'Are you sure to restart Tailscale?',
|
||||
stop: 'Are you sure to stop Tailscale?',
|
||||
@@ -215,6 +430,8 @@ const cz = {
|
||||
upTailscale: 'Nahrajte Tailscale do adresáře NanoKVM /usr/bin/',
|
||||
upTailscaled: 'Nahrajte Tailscaled do adresáře NanoKVM /usr/sbin/',
|
||||
refresh: 'Obnovit stránku',
|
||||
notRunning: 'Tailscale neběží. Chcete-li pokračovat, spusťte jej.',
|
||||
run: 'Spustit',
|
||||
notLogin:
|
||||
'Zařízení nebylo dosud spárováno. Přihlaste se prosím a spárujte toto zařízení s vaším účtem.',
|
||||
urlPeriod: 'Tento odkaz je platný po dobu 10 minut',
|
||||
@@ -225,7 +442,9 @@ const cz = {
|
||||
deviceIP: 'IP zařízení',
|
||||
account: 'Účet',
|
||||
logout: 'Odhlásit se',
|
||||
logout2: 'Opravdu se chcete odhlásit?',
|
||||
logoutDesc: 'Opravdu se chcete odhlásit?',
|
||||
uninstall: 'Odinstalovat Tailscale',
|
||||
uninstallDesc: 'Opravdu chcete odinstalovat Tailscale?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
},
|
||||
@@ -237,15 +456,161 @@ const cz = {
|
||||
available: 'Je dostupná aktualizace. Opravdu chcete aktualizovat?',
|
||||
updating: 'Aktualizace zahájena. Prosím čekejte...',
|
||||
confirm: 'Potvrdit',
|
||||
cancel: 'Zrušit'
|
||||
cancel: 'Zrušit',
|
||||
preview: 'Náhled aktualizací',
|
||||
previewDesc: 'Získejte včasný přístup k novým funkcím a vylepšením',
|
||||
previewTip:
|
||||
'Uvědomte si prosím, že předběžné verze mohou obsahovat chyby nebo neúplné funkce!',
|
||||
offline: {
|
||||
title: 'Offline aktualizace',
|
||||
desc: 'Aktualizace prostřednictvím místního instalačního balíčku',
|
||||
upload: 'Nahrát',
|
||||
invalidName: 'Neplatný formát souboru. Stáhněte si prosím z vydání GitHubu.',
|
||||
updateFailed: 'Aktualizace se nezdařila. Zkuste to prosím znovu.'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
title: 'Account',
|
||||
webAccount: 'Web Account Name',
|
||||
password: 'Password',
|
||||
title: 'Účet',
|
||||
webAccount: 'Název webového účtu',
|
||||
password: 'Heslo',
|
||||
updateBtn: 'Update',
|
||||
logoutBtn: 'Logout'
|
||||
logoutBtn: 'Odhlásit',
|
||||
logoutDesc: 'Opravdu se chcete odhlásit?',
|
||||
okBtn: 'Ano',
|
||||
cancelBtn: 'Ne'
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
title: 'PicoClaw asistent',
|
||||
empty: 'Otevřete panel a spusťte úlohu.',
|
||||
inputPlaceholder: 'Popište, co chcete, aby PicoClaw dělal',
|
||||
newConversation: 'Nová konverzace',
|
||||
processing: 'Zpracovává se...',
|
||||
agent: {
|
||||
defaultTitle: 'Obecný asistent',
|
||||
defaultDescription: 'Obecná nápověda pro chat, vyhledávání a pracovní prostor.',
|
||||
kvmTitle: 'Vzdálené ovládání',
|
||||
kvmDescription: 'Ovládejte vzdáleného hostitele prostřednictvím NanoKVM.',
|
||||
switched: 'Role agenta změněna',
|
||||
switchFailed: 'Přepnutí role agenta se nezdařilo'
|
||||
},
|
||||
send: 'Odeslat',
|
||||
cancel: 'Zrušit',
|
||||
status: {
|
||||
connecting: 'Připojování k bráně...',
|
||||
connected: 'Relace PicoClaw připojena',
|
||||
disconnected: 'Relace PicoClaw uzavřena',
|
||||
stopped: 'Požadavek na zastavení byl odeslán',
|
||||
runtimeStarted: 'Spuštěno běhové prostředí PicoClaw',
|
||||
runtimeStartFailed: 'Selhalo spuštění běhového prostředí PicoClaw',
|
||||
runtimeStopped: 'Běhové prostředí PicoClaw zastaveno',
|
||||
runtimeStopFailed: 'Zastavení běhového prostředí PicoClaw se nezdařilo'
|
||||
},
|
||||
connection: {
|
||||
runtime: {
|
||||
checking: 'Kontrola',
|
||||
ready: 'Běhové prostředí připraveno',
|
||||
stopped: 'Běhové prostředí zastaveno',
|
||||
unavailable: 'Běhové prostředí není k dispozici',
|
||||
configError: 'Chyba konfigurace'
|
||||
},
|
||||
transport: {
|
||||
connecting: 'Připojování',
|
||||
connected: 'Připojeno'
|
||||
},
|
||||
run: {
|
||||
idle: 'Nečinný',
|
||||
busy: 'Zaneprázdněn'
|
||||
}
|
||||
},
|
||||
message: {
|
||||
toolAction: 'Akce',
|
||||
observation: 'Pozorování',
|
||||
screenshot: 'Snímek obrazovky'
|
||||
},
|
||||
overlay: {
|
||||
locked: 'PicoClaw ovládá zařízení. Ruční zadávání je pozastaveno.'
|
||||
},
|
||||
install: {
|
||||
install: 'Nainstalovat PicoClaw',
|
||||
installing: 'Instalace PicoClaw',
|
||||
success: 'PicoClaw úspěšně nainstalováno',
|
||||
failed: 'Nepodařilo se nainstalovat PicoClaw',
|
||||
uninstalling: 'Odinstalování běhového prostředí...',
|
||||
uninstalled: 'Běhové prostředí bylo úspěšně odinstalováno.',
|
||||
uninstallFailed: 'Odinstalace se nezdařila.',
|
||||
requiredTitle: 'PicoClaw není nainstalováno',
|
||||
requiredDescription: 'Nainstalujte PicoClaw před spuštěním běhového prostředí PicoClaw.',
|
||||
progressDescription: 'PicoClaw se stahuje a instaluje.',
|
||||
stages: {
|
||||
preparing: 'Příprava',
|
||||
downloading: 'Stahování',
|
||||
extracting: 'Extrakce',
|
||||
installing: 'Instalace probíhá',
|
||||
installed: 'Instalováno',
|
||||
install_timeout: 'Vypršel časový limit',
|
||||
install_failed: 'Selhalo'
|
||||
}
|
||||
},
|
||||
model: {
|
||||
requiredTitle: 'Je vyžadována konfigurace modelu',
|
||||
requiredDescription: 'Před použitím chatu PicoClaw nakonfigurujte model PicoClaw.',
|
||||
docsTitle: 'Průvodce konfigurací',
|
||||
docsDesc: 'Podporované modely a protokoly',
|
||||
menuLabel: 'Konfigurace modelu',
|
||||
modelIdentifier: 'Identifikátor modelu',
|
||||
modelIdentifierPlaceholder: 'openai/gpt-5.4',
|
||||
apiBase: 'API Base URL',
|
||||
apiBasePlaceholder: 'https://api.example.com/v1',
|
||||
apiKey: 'Klíč API',
|
||||
apiKeyPlaceholder: 'Zadejte klíč API modelu',
|
||||
save: 'Uložit',
|
||||
saving: 'Ukládání',
|
||||
saved: 'Konfigurace modelu uložena',
|
||||
saveFailed: 'Nepodařilo se uložit konfiguraci modelu',
|
||||
invalid: 'Identifikátor modelu, API Base URL a klíč API jsou povinné'
|
||||
},
|
||||
uninstall: {
|
||||
menuLabel: 'Odinstalovat',
|
||||
confirmTitle: 'Odinstalovat PicoClaw',
|
||||
confirmContent:
|
||||
'Opravdu chcete odinstalovat PicoClaw? Tím smažete spustitelný soubor a všechny konfigurační soubory.',
|
||||
confirmOk: 'Odinstalovat',
|
||||
confirmCancel: 'Zrušit'
|
||||
},
|
||||
history: {
|
||||
title: 'Historie',
|
||||
loading: 'Načítání relací...',
|
||||
emptyTitle: 'Zatím žádná historie',
|
||||
emptyDescription: 'Zde se zobrazí předchozí relace PicoClaw.',
|
||||
loadFailed: 'Nepodařilo se načíst historii relace',
|
||||
deleteFailed: 'Smazání relace se nezdařilo',
|
||||
deleteConfirmTitle: 'Smazat relaci',
|
||||
deleteConfirmContent: 'Opravdu chcete smazat "{{title}}"?',
|
||||
deleteConfirmOk: 'Smazat',
|
||||
deleteConfirmCancel: 'Zrušit',
|
||||
messageCount_one: '{{count}} zpráva',
|
||||
messageCount_other: '{{count}} zpráv'
|
||||
},
|
||||
config: {
|
||||
startRuntime: 'Spustit PicoClaw',
|
||||
stopRuntime: 'Zastavit PicoClaw'
|
||||
},
|
||||
start: {
|
||||
title: 'Spustit PicoClaw',
|
||||
description: 'Spusťte běhové prostředí a začněte používat asistenta PicoClaw.'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: 'Narazili jsme na problém',
|
||||
refresh: 'Obnovit'
|
||||
},
|
||||
fullscreen: {
|
||||
toggle: 'Přepnout na celou obrazovku'
|
||||
},
|
||||
menu: {
|
||||
collapse: 'Sbalit nabídku',
|
||||
expand: 'Rozbalte nabídku'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ const da = {
|
||||
noAccount:
|
||||
'Kunne ikke hente brugeroplysninger. Prøv at opdater siden eller nulstil adgangskoden',
|
||||
invalidUser: 'ugyldigt brugernavn eller adgangskode',
|
||||
locked: 'For mange logins, prøv venligst igen senere',
|
||||
globalLocked: 'System under beskyttelse, prøv venligst igen senere',
|
||||
error: 'uventet fejl',
|
||||
changePassword: 'Skift adgangskode',
|
||||
changePasswordDesc: 'For sikkerheden af din enhed, bedes du ændre web-login adgangskoden.',
|
||||
@@ -30,25 +32,37 @@ const da = {
|
||||
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.'
|
||||
reset2: 'Se detaljerede trin i dette dokument:',
|
||||
reset3: 'Standard webkonto:',
|
||||
reset4: 'Standard SSH-konto:',
|
||||
change1: 'Bemærk, at denne handling ændrer følgende adgangskoder:',
|
||||
change2: 'Adgangskode til weblogin',
|
||||
change3: 'Systemets root-adgangskode (SSH-loginadgangskode)',
|
||||
change4: 'For at nulstille adgangskoderne skal du holde BOOT-knappen på NanoKVM nede.'
|
||||
}
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi for NanoKVM',
|
||||
description: 'Konfigurer Wi-Fi for NanoKVM',
|
||||
success: 'Please check the network status of NanoKVM and visit the new IP address.',
|
||||
failed: 'Operation failed, please try again.',
|
||||
failed: 'Handlingen mislykkedes, prøv igen.',
|
||||
invalidMode:
|
||||
'Den aktuelle tilstand understøtter ikke netværksopsætning. Gå til din enhed og aktiver Wi-Fi konfigurationstilstand.',
|
||||
confirmBtn: 'Ok',
|
||||
finishBtn: 'Finished'
|
||||
finishBtn: 'Færdig',
|
||||
ap: {
|
||||
authTitle: 'Godkendelse påkrævet',
|
||||
authDescription: 'Indtast venligst AP adgangskoden for at fortsætte',
|
||||
authFailed: 'Ugyldig AP adgangskode',
|
||||
passPlaceholder: 'AP adgangskode',
|
||||
verifyBtn: 'Bekræft'
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
scale: 'Skala',
|
||||
title: 'Skærm',
|
||||
video: 'Videotilstand',
|
||||
videoDirectTips: 'Aktiver HTTPS i "Indstillinger > Enhed" for at bruge denne tilstand',
|
||||
resolution: 'Opløsning',
|
||||
auto: 'Automatisk',
|
||||
autoTips:
|
||||
@@ -63,17 +77,65 @@ const da = {
|
||||
frameDetect: 'Beregn frames',
|
||||
frameDetectTip:
|
||||
'Beregner forskellen mellem hver frame. Stopper med at sende et video stream hvis der ikke registreres ændringer på fjerncomputerens skærm.',
|
||||
resetHdmi: 'Reset HDMI'
|
||||
resetHdmi: 'Nulstil HDMI'
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Tastatur',
|
||||
paste: 'Indsæt',
|
||||
tips: 'Kun standard bogstaver og symboler er understøttet',
|
||||
placeholder: 'Indtast tekst',
|
||||
submit: 'Send',
|
||||
virtual: 'Tastatur',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
readClipboard: 'Læs fra udklipsholder',
|
||||
clipboardPermissionDenied:
|
||||
'Udklipsholdertilladelse nægtet. Tillad venligst udklipsholderadgang i din browser.',
|
||||
clipboardReadError: 'Kunne ikke læse udklipsholderen',
|
||||
dropdownEnglish: 'Engelsk',
|
||||
dropdownGerman: 'tysk',
|
||||
dropdownFrench: 'Fransk',
|
||||
dropdownRussian: 'russisk',
|
||||
shortcut: {
|
||||
title: 'Genveje',
|
||||
custom: 'Brugerdefineret',
|
||||
capture: 'Klik her for at fange genvej',
|
||||
clear: 'Ryd',
|
||||
save: 'Gem',
|
||||
captureTips:
|
||||
'Optagelse af systemtaster (såsom Windows-tasten) kræver fuldskærmstilladelse.',
|
||||
enterFullScreen: 'Skift fuldskærmstilstand.'
|
||||
},
|
||||
leaderKey: {
|
||||
title: 'Leader-tast',
|
||||
desc: 'Omgå browserbegrænsninger og send systemgenveje direkte til fjernværten.',
|
||||
howToUse: 'Sådan bruges',
|
||||
simultaneous: {
|
||||
title: 'Samtidig tilstand',
|
||||
desc1: 'Hold Leader-tasten nede, og tryk derefter på genvejen.',
|
||||
desc2: 'Intuitivt, men kan være i konflikt med systemgenveje.'
|
||||
},
|
||||
sequential: {
|
||||
title: 'Sekventiel tilstand',
|
||||
desc1:
|
||||
'Tryk på Leader-tasten → tryk på genvejen i rækkefølge → tryk på Leader-tasten igen.',
|
||||
desc2: 'Kræver flere trin, men undgår fuldstændig systemkonflikter.'
|
||||
},
|
||||
enable: 'Aktiver Leader-tast',
|
||||
tip: 'Når denne tast tildeles som Leader-tast, fungerer den kun som genvejsudløser og mister sin standardfunktion.',
|
||||
placeholder: 'Tryk på Leader-tasten',
|
||||
shiftRight: 'Højre Shift',
|
||||
ctrlRight: 'Højre Ctrl',
|
||||
metaRight: 'Højre Win',
|
||||
submit: 'Send',
|
||||
recorder: {
|
||||
rec: 'REC',
|
||||
activate: 'Aktiver taster',
|
||||
input: 'Tryk på genvejen...'
|
||||
}
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: 'Mus',
|
||||
cursor: 'Markørstil',
|
||||
default: 'Standard-markør',
|
||||
pointer: 'Peger-markør',
|
||||
cell: 'Celle-markør',
|
||||
@@ -83,16 +145,40 @@ const da = {
|
||||
mode: 'Tilstand for mus',
|
||||
absolute: 'Absolut tilstand',
|
||||
relative: 'Relativ tilstand',
|
||||
direction: 'Rullehjulsretning',
|
||||
scrollUp: 'Rul op',
|
||||
scrollDown: 'Rul ned',
|
||||
speed: 'Rullehjulshastighed',
|
||||
fast: 'Hurtigt',
|
||||
slow: 'Langsomt',
|
||||
requestPointer: 'Bruger relativ-tilstand. Klik på skrivebordet for at få musemarkør.',
|
||||
resetHid: 'Nulstil HID'
|
||||
resetHid: 'Nulstil HID',
|
||||
hidOnly: {
|
||||
title: 'Kun HID-tilstand',
|
||||
desc: 'Hvis din mus og tastatur holder op med at reagere, og nulstilling af HID ikke hjælper, kan det være et kompatibilitetsproblem mellem NanoKVM og enheden. Prøv at aktivere HID-Only-tilstand for bedre kompatibilitet.',
|
||||
tip1: 'Aktivering af HID-Only-tilstand vil afmontere den virtuelle U-disk og det virtuelle netværk',
|
||||
tip2: 'I HID-Only-tilstand er billedmontering deaktiveret',
|
||||
tip3: 'NanoKVM genstarter automatisk efter at have skiftet tilstand',
|
||||
enable: 'Aktiver HID-kun tilstand',
|
||||
disable: 'Deaktiver HID-kun tilstand'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Diskbilleder',
|
||||
loading: 'Kontrollerer...',
|
||||
empty: 'Ingen fundet',
|
||||
mountMode: 'Monteringstilstand',
|
||||
mountFailed: 'Montering af diskbillede mislykkedes',
|
||||
mountDesc:
|
||||
'På nogle systemer kan det være nødvendigt at skubbe den virtuelle disk ud på fjerncomputeren før du kan montere diskbilledet.',
|
||||
unmountFailed: 'Afmontering mislykkedes',
|
||||
unmountDesc:
|
||||
'På nogle systemer skal du manuelt skubbe ud fra fjernværten, før du afmonterer billedet.',
|
||||
refresh: 'Opdater billedlisten',
|
||||
attention: 'Opmærksomhed påkrævet',
|
||||
deleteConfirm: 'Er du sikker på, at du vil slette dette billede?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Annuller',
|
||||
tips: {
|
||||
title: 'Sådan uploader du',
|
||||
usb1: 'Forbind din NanoKVM til din computer via USB.',
|
||||
@@ -129,6 +215,16 @@ const da = {
|
||||
serialPort: 'Serial port',
|
||||
serialPortPlaceholder: 'Angiv seriel port',
|
||||
baudrate: 'Baud-hastighed',
|
||||
parity: 'Paritet',
|
||||
parityNone: 'Ingen',
|
||||
parityEven: 'Lige',
|
||||
parityOdd: 'Ulige',
|
||||
flowControl: 'Flowkontrol',
|
||||
flowControlNone: 'Ingen',
|
||||
flowControlSoft: 'Software',
|
||||
flowControlHard: 'Hardware',
|
||||
dataBits: 'Databits',
|
||||
stopBits: 'Stopbit',
|
||||
confirm: 'OK'
|
||||
},
|
||||
wol: {
|
||||
@@ -138,65 +234,185 @@ const da = {
|
||||
input: 'Angiv MAC-adresse',
|
||||
ok: 'OK'
|
||||
},
|
||||
download: {
|
||||
title: 'Billedhenter',
|
||||
input: 'Indtast venligst et fjernbillede URL',
|
||||
ok: 'OK',
|
||||
disabled: '/data partitionen er RO, så vi kan ikke downloade billedet',
|
||||
uploadbox: 'Slip filen her, eller klik for at vælge',
|
||||
inputfile: 'Indtast venligst billedfilen',
|
||||
NoISO: 'Ingen ISO'
|
||||
},
|
||||
power: {
|
||||
title: 'Tænd/sluk-knap',
|
||||
showConfirm: 'Bekræftelse',
|
||||
showConfirmTip: 'Strømdrift kræver en ekstra bekræftelse',
|
||||
reset: 'Nulstillingsknap',
|
||||
power: 'Tænd/sluk-knap',
|
||||
powerShort: 'Tænd/sluk-knap (kort tryk)',
|
||||
powerLong: 'Tænd/sluk-knap (langt tryk)'
|
||||
powerLong: 'Tænd/sluk-knap (langt tryk)',
|
||||
resetConfirm: 'Fortsæt med nulstilling?',
|
||||
powerConfirm: 'Fortsæt strømdrift?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Annuller'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
title: 'Indstillinger',
|
||||
about: {
|
||||
title: 'Om NanoKVM',
|
||||
information: 'Information',
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: 'Program version',
|
||||
applicationTip: 'NanoKVM web application version',
|
||||
applicationTip: 'Version af NanoKVM-webapplikationen',
|
||||
image: 'Firmware version',
|
||||
imageTip: 'NanoKVM system image version',
|
||||
imageTip: 'Version af NanoKVM-systemimaget',
|
||||
deviceKey: 'Enhedsnøgle',
|
||||
community: 'Fællesskab'
|
||||
community: 'Fællesskab',
|
||||
hostname: 'Værtsnavn',
|
||||
hostnameUpdated: 'Værtsnavn opdateret. Genstart for at anvende.',
|
||||
ipType: {
|
||||
Wired: 'Kablet',
|
||||
Wireless: 'Trådløs',
|
||||
Other: 'Andet'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
display: 'Display',
|
||||
language: 'Language',
|
||||
menuBar: 'Menu Bar',
|
||||
menuBarDesc: 'Display icons in the menu bar'
|
||||
title: 'Udseende',
|
||||
display: 'Visning',
|
||||
language: 'Sprog',
|
||||
languageDesc: 'Vælg sproget til grænsefladen',
|
||||
webTitle: 'Webtitel',
|
||||
webTitleDesc: 'Tilpas websidens titel',
|
||||
menuBar: {
|
||||
title: 'Menulinje',
|
||||
mode: 'Visningstilstand',
|
||||
modeDesc: 'Vis menulinje på skærmen',
|
||||
modeOff: 'Fra',
|
||||
modeAuto: 'Skjul automatisk',
|
||||
modeAlways: 'Altid synlig',
|
||||
icons: 'Undermenuikoner',
|
||||
iconsDesc: 'Vis undermenuikoner i menulinjen'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: 'Device',
|
||||
title: 'Enhed',
|
||||
oled: {
|
||||
title: 'OLED',
|
||||
description: 'OLED screen automatically sleep',
|
||||
0: 'Never',
|
||||
15: '15 sec',
|
||||
30: '30 sec',
|
||||
0: 'Aldrig',
|
||||
15: '15 sek.',
|
||||
30: '30 sek.',
|
||||
60: '1 min',
|
||||
180: '3 min',
|
||||
300: '5 min',
|
||||
600: '10 min',
|
||||
1800: '30 min',
|
||||
3600: '1 hour'
|
||||
3600: '1 time'
|
||||
},
|
||||
ssh: {
|
||||
description: 'Aktiver SSH fjernadgang',
|
||||
tip: 'Indstil en stærk adgangskode før aktivering (Konto - Skift adgangskode)'
|
||||
},
|
||||
advanced: 'Avancerede indstillinger',
|
||||
swap: {
|
||||
title: 'Byt',
|
||||
disable: 'Deaktiver',
|
||||
description: 'Indstil swap-filstørrelsen',
|
||||
tip: 'Aktivering af denne funktion kan forkorte dit SD-korts brugbare levetid!'
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: 'Mus Jiggler',
|
||||
description: 'Forhindrer fjernværten i at sove',
|
||||
disable: 'Deaktiver',
|
||||
absolute: 'Absolut tilstand',
|
||||
relative: 'Relativ tilstand'
|
||||
},
|
||||
mdns: {
|
||||
description: 'Aktiver mDNS opdagelsestjeneste',
|
||||
tip: 'Slukker den, hvis den ikke er nødvendig'
|
||||
},
|
||||
hdmi: {
|
||||
description: 'Aktiver HDMI/monitor output'
|
||||
},
|
||||
autostart: {
|
||||
title: 'Indstillinger for autostart scripts',
|
||||
description: 'Administrer scripts, der kører automatisk ved systemstart',
|
||||
new: 'Ny',
|
||||
deleteConfirm: 'Er du sikker på at du vil slette denne fil?',
|
||||
yes: 'Ja',
|
||||
no: 'Annuller',
|
||||
scriptName: 'Autostart scriptnavn',
|
||||
scriptContent: 'Autostart scriptindhold',
|
||||
settings: 'Indstillinger'
|
||||
},
|
||||
hidOnly: 'HID-Kun tilstand',
|
||||
hidOnlyDesc:
|
||||
'Stop med at emulere virtuelle enheder, og behold kun grundlæggende HID kontrol',
|
||||
disk: 'Virtuel disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
network: 'Virtuelt netværk',
|
||||
networkDesc: 'Monter det virtuelle netværkskort på den eksterne vært',
|
||||
reboot: 'Genstart',
|
||||
rebootDesc: 'Er du sikker på, at du vil genstarte NanoKVM?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Annuller'
|
||||
},
|
||||
network: {
|
||||
title: 'Netværk',
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi',
|
||||
setBtn: 'Config'
|
||||
description: 'Konfigurer Wi-Fi',
|
||||
apMode: 'AP-tilstand er aktiveret, opret forbindelse til Wi-Fi ved at scanne QR-koden',
|
||||
connect: 'Tilslut Wi-Fi',
|
||||
connectDesc1: 'Indtast netværkets SSID og adgangskode',
|
||||
connectDesc2: 'Indtast adgangskoden for at tilslutte dette netværk',
|
||||
disconnect: 'Er du sikker på, at du vil afbryde netværket?',
|
||||
failed: 'Forbindelsen mislykkedes, prøv igen.',
|
||||
ssid: 'Navn',
|
||||
password: 'Adgangskode',
|
||||
joinBtn: 'Tilslut',
|
||||
confirmBtn: 'OK',
|
||||
cancelBtn: 'Annuller'
|
||||
},
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
network: 'Virtual Network',
|
||||
networkDesc: 'Mount virtual network card on the remote host'
|
||||
tls: {
|
||||
description: 'Aktiver HTTPS-protokol',
|
||||
tip: 'Bemærk: Brug af HTTPS kan øge forsinkelsen, især med MJPEG-videotilstand.'
|
||||
},
|
||||
dns: {
|
||||
title: 'DNS',
|
||||
description: 'Konfigurer DNS-servere til NanoKVM',
|
||||
mode: 'Tilstand',
|
||||
dhcp: 'DHCP',
|
||||
manual: 'Manuel',
|
||||
add: 'Tilføj DNS',
|
||||
save: 'Gem',
|
||||
invalid: 'Indtast en gyldig IP-adresse',
|
||||
noDhcp: 'Ingen DHCP-DNS er tilgængelig i øjeblikket',
|
||||
saved: 'DNS-indstillinger gemt',
|
||||
saveFailed: 'DNS-indstillinger kunne ikke gemmes',
|
||||
unsaved: 'Ikke-gemte ændringer',
|
||||
maxServers: 'Maksimalt {{count}} DNS-servere er tilladt',
|
||||
dnsServers: 'DNS-servere',
|
||||
dhcpServersDescription: 'DNS-servere hentes automatisk fra DHCP',
|
||||
manualServersDescription: 'DNS-servere kan redigeres manuelt',
|
||||
networkDetails: 'Netværksdetaljer',
|
||||
interface: 'Grænseflade',
|
||||
ipAddress: 'IP-adresse',
|
||||
subnetMask: 'Undernetmaske',
|
||||
router: 'Router',
|
||||
none: 'Ingen'
|
||||
}
|
||||
},
|
||||
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: 'Hukommelsesoptimering',
|
||||
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."
|
||||
},
|
||||
swap: {
|
||||
title: 'Skift hukommelse',
|
||||
tip: 'Hvis problemerne fortsætter efter aktivering af hukommelsesoptimering, prøv at aktivere swap-hukommelse. Dette indstiller swap-filstørrelsen til 256MB som standard, som kan justeres i "Indstillinger > Enhed".'
|
||||
},
|
||||
restart: 'Are you sure to restart Tailscale?',
|
||||
stop: 'Are you sure to stop Tailscale?',
|
||||
@@ -210,9 +426,11 @@ const da = {
|
||||
download: 'Download',
|
||||
package: 'installationspakken',
|
||||
unzip: 'og udpak den',
|
||||
upTailscale: 'Upload tailscale til følgende mappe på enheden: /usr/bin/',
|
||||
upTailscaled: 'Upload tailscaled til følgende mappe på enheden: /usr/sbin/',
|
||||
upTailscale: 'Upload tailscale til NanoKVM-mappen /usr/bin/',
|
||||
upTailscaled: 'Upload tailscaled til NanoKVM-mappen /usr/sbin/',
|
||||
refresh: 'Opdater sides',
|
||||
notRunning: 'Tailscale kører ikke. Start det for at fortsætte.',
|
||||
run: 'Start',
|
||||
notLogin:
|
||||
'Enheden er ikke tilknyttet en Tailscale-konto endnu. Log ind for at fuldføre tilknytningen til din konto.',
|
||||
urlPeriod: 'Denne URL er gyldig i 10 minutter',
|
||||
@@ -223,7 +441,9 @@ const da = {
|
||||
deviceIP: 'Enhedens IP',
|
||||
account: 'Konto',
|
||||
logout: 'Log ud',
|
||||
logout2: 'Er du sikker på at du vil logge ud?',
|
||||
logoutDesc: 'Er du sikker på, at du vil logge ud?',
|
||||
uninstall: 'Afinstaller Tailscale',
|
||||
uninstallDesc: 'Er du sikker på, at du vil afinstallere Tailscale?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
},
|
||||
@@ -235,15 +455,161 @@ const da = {
|
||||
available: 'En opdatering er tilgængelig. Vil du installere den?',
|
||||
updating: 'Opdatering i gang. Vent venligst...',
|
||||
confirm: 'Bekræft',
|
||||
cancel: 'Annuller'
|
||||
cancel: 'Annuller',
|
||||
preview: 'Forhåndsvisning af opdateringer',
|
||||
previewDesc: 'Få tidlig adgang til nye funktioner og forbedringer',
|
||||
previewTip:
|
||||
'Vær opmærksom på, at forhåndsvisningsudgivelser kan indeholde fejl eller ufuldstændig funktionalitet!',
|
||||
offline: {
|
||||
title: 'Offline opdateringer',
|
||||
desc: 'Opdatering via lokal installationspakke',
|
||||
upload: 'Upload',
|
||||
invalidName: 'Ugyldigt filnavnsformat. Download venligst fra GitHub-udgivelser.',
|
||||
updateFailed: 'Opdatering fejlede. Prøv igen.'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
title: 'Account',
|
||||
webAccount: 'Web Account Name',
|
||||
password: 'Password',
|
||||
title: 'Konto',
|
||||
webAccount: 'Navn på webkonto',
|
||||
password: 'Adgangskode',
|
||||
updateBtn: 'Update',
|
||||
logoutBtn: 'Logout'
|
||||
logoutBtn: 'Log ud',
|
||||
logoutDesc: 'Er du sikker på, at du vil logge ud?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Annuller'
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
title: 'PicoClaw Assistent',
|
||||
empty: 'Åbn panelet og start en opgave for at begynde.',
|
||||
inputPlaceholder: 'Beskriv, hvad du vil have PicoClaw til at gøre',
|
||||
newConversation: 'Ny samtale',
|
||||
processing: 'Behandler...',
|
||||
agent: {
|
||||
defaultTitle: 'Generel assistent',
|
||||
defaultDescription: 'Generel hjælp til chat, søgning og arbejdsområde.',
|
||||
kvmTitle: 'Fjernstyring',
|
||||
kvmDescription: 'Betjen fjernværten gennem NanoKVM.',
|
||||
switched: 'Agentrolle skiftet',
|
||||
switchFailed: 'Kunne ikke skifte agentrolle'
|
||||
},
|
||||
send: 'Send',
|
||||
cancel: 'Annuller',
|
||||
status: {
|
||||
connecting: 'Opretter forbindelse til gateway...',
|
||||
connected: 'PicoClaw-session tilsluttet',
|
||||
disconnected: 'PicoClaw-session lukket',
|
||||
stopped: 'Stopanmodning sendt',
|
||||
runtimeStarted: 'PicoClaw runtime startet',
|
||||
runtimeStartFailed: 'Kunne ikke starte PicoClaw runtime',
|
||||
runtimeStopped: 'PicoClaw runtime stoppet',
|
||||
runtimeStopFailed: 'Kunne ikke stoppe PicoClaw runtime'
|
||||
},
|
||||
connection: {
|
||||
runtime: {
|
||||
checking: 'Kontrol',
|
||||
ready: 'Runtime klar',
|
||||
stopped: 'Runtime stoppet',
|
||||
unavailable: 'Runtime utilgængelig',
|
||||
configError: 'Konfigurationsfejl'
|
||||
},
|
||||
transport: {
|
||||
connecting: 'Tilslutning',
|
||||
connected: 'Tilsluttet'
|
||||
},
|
||||
run: {
|
||||
idle: 'Tomgang',
|
||||
busy: 'Optaget'
|
||||
}
|
||||
},
|
||||
message: {
|
||||
toolAction: 'Handling',
|
||||
observation: 'Observation',
|
||||
screenshot: 'Skærmbillede'
|
||||
},
|
||||
overlay: {
|
||||
locked: 'PicoClaw styrer enheden. Manuel indtastning er sat på pause.'
|
||||
},
|
||||
install: {
|
||||
install: 'Installer PicoClaw',
|
||||
installing: 'Installation af PicoClaw',
|
||||
success: 'PicoClaw installeret korrekt',
|
||||
failed: 'Kunne ikke installere PicoClaw',
|
||||
uninstalling: 'Afinstallerer runtime...',
|
||||
uninstalled: 'Runtime blev afinstalleret.',
|
||||
uninstallFailed: 'Afinstallation mislykkedes.',
|
||||
requiredTitle: 'PicoClaw er ikke installeret',
|
||||
requiredDescription: 'Installer PicoClaw før start af PicoClaw runtime.',
|
||||
progressDescription: 'PicoClaw bliver downloadet og installeret.',
|
||||
stages: {
|
||||
preparing: 'Forberedelse',
|
||||
downloading: 'Downloader',
|
||||
extracting: 'Udpakning',
|
||||
installing: 'Installerer',
|
||||
installed: 'Installeret',
|
||||
install_timeout: 'Timeout',
|
||||
install_failed: 'Mislykkedes'
|
||||
}
|
||||
},
|
||||
model: {
|
||||
requiredTitle: 'Modelkonfiguration er påkrævet',
|
||||
requiredDescription: 'Konfigurer PicoClaw-modellen, før du bruger PicoClaw chat.',
|
||||
docsTitle: 'Konfigurationsvejledning',
|
||||
docsDesc: 'Understøttede modeller og protokoller',
|
||||
menuLabel: 'Konfigurer model',
|
||||
modelIdentifier: 'Modelidentifikator',
|
||||
modelIdentifierPlaceholder: 'openai/gpt-5.4',
|
||||
apiBase: 'API Base URL',
|
||||
apiBasePlaceholder: 'https://api.example.com/v1',
|
||||
apiKey: 'API-nøgle',
|
||||
apiKeyPlaceholder: 'Indtast modellens API-nøgle',
|
||||
save: 'Gem',
|
||||
saving: 'Gemmer',
|
||||
saved: 'Modelkonfiguration gemt',
|
||||
saveFailed: 'Kunne ikke gemme modelkonfigurationen',
|
||||
invalid: 'Model-id, API Base URL og API-nøgle er påkrævet'
|
||||
},
|
||||
uninstall: {
|
||||
menuLabel: 'Afinstaller',
|
||||
confirmTitle: 'Afinstaller PicoClaw',
|
||||
confirmContent:
|
||||
'Er du sikker på, at du vil afinstallere PicoClaw? Dette vil slette den eksekverbare fil og alle konfigurationsfiler.',
|
||||
confirmOk: 'Afinstaller',
|
||||
confirmCancel: 'Annuller'
|
||||
},
|
||||
history: {
|
||||
title: 'Historik',
|
||||
loading: 'Indlæser sessioner...',
|
||||
emptyTitle: 'Ingen historik endnu',
|
||||
emptyDescription: 'Tidligere PicoClaw sessioner vil blive vist her.',
|
||||
loadFailed: 'Kunne ikke indlæse sessionshistorikken',
|
||||
deleteFailed: 'Kunne ikke slette session',
|
||||
deleteConfirmTitle: 'Slet session',
|
||||
deleteConfirmContent: 'Er du sikker på, at du vil slette "{{title}}"?',
|
||||
deleteConfirmOk: 'Slet',
|
||||
deleteConfirmCancel: 'Annuller',
|
||||
messageCount_one: '{{count}} besked',
|
||||
messageCount_other: '{{count}} beskeder'
|
||||
},
|
||||
config: {
|
||||
startRuntime: 'Start PicoClaw',
|
||||
stopRuntime: 'Stop PicoClaw'
|
||||
},
|
||||
start: {
|
||||
title: 'Start PicoClaw',
|
||||
description: 'Start runtime for at begynde at bruge PicoClaw-assistenten.'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: 'Vi er stødt på et problem',
|
||||
refresh: 'Opdater'
|
||||
},
|
||||
fullscreen: {
|
||||
toggle: 'Skift fuldskærm'
|
||||
},
|
||||
menu: {
|
||||
collapse: 'Skjul menu',
|
||||
expand: 'Udvid menu'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ const de = {
|
||||
noAccount:
|
||||
'Abfragen der Benutzerdaten fehlgeschlagen, bitte die Seite neu laden oder Passwort zurücksetzen',
|
||||
invalidUser: 'Falscher Benutzername oder falsches Passwort',
|
||||
locked: 'Zu viele Anmeldungen, bitte versuchen Sie es später noch einmal',
|
||||
globalLocked: 'System wird geschützt, bitte versuchen Sie es später erneut',
|
||||
error: 'Unerwarteter Fehler',
|
||||
changePassword: 'Passwort ändern',
|
||||
changePasswordDesc: 'Für die Sicherheit Ihres Geräts ändern Sie bitte das Passwort!',
|
||||
@@ -46,12 +48,24 @@ const de = {
|
||||
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.',
|
||||
invalidMode:
|
||||
'Der aktuelle Modus unterstützt keine Netzwerkeinrichtung. Bitte gehen Sie zu Ihrem Gerät und aktivieren Sie den Wi-Fi-Konfigurationsmodus.',
|
||||
confirmBtn: 'Ok',
|
||||
finishBtn: 'Fertig'
|
||||
finishBtn: 'Fertig',
|
||||
ap: {
|
||||
authTitle: 'Authentifizierung erforderlich',
|
||||
authDescription: 'Bitte geben Sie das Passwort AP ein, um fortzufahren',
|
||||
authFailed: 'Ungültiges AP Passwort',
|
||||
passPlaceholder: 'AP Passwort',
|
||||
verifyBtn: 'Überprüfen'
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
scale: 'Skala',
|
||||
title: 'Bildschirm',
|
||||
video: 'Video Modus',
|
||||
videoDirectTips:
|
||||
'Aktivieren Sie HTTPS unter „Einstellungen > Gerät“, um diesen Modus zu verwenden',
|
||||
resolution: 'Auflösung',
|
||||
auto: 'Automatisch',
|
||||
autoTips:
|
||||
@@ -75,10 +89,52 @@ const de = {
|
||||
placeholder: 'Bitte eingeben',
|
||||
submit: 'Senden',
|
||||
virtual: 'Tastatur',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del',
|
||||
readClipboard: 'Aus der Zwischenablage lesen',
|
||||
clipboardPermissionDenied:
|
||||
'Berechtigung für die Zwischenablage verweigert. Bitte erlauben Sie den Zugriff auf die Zwischenablage in Ihrem Browser.',
|
||||
clipboardReadError: 'Zwischenablage konnte nicht gelesen werden',
|
||||
dropdownEnglish: 'Englisch',
|
||||
dropdownGerman: 'Deutsch',
|
||||
dropdownRussian: 'Russisch'
|
||||
dropdownFrench: 'Französisch',
|
||||
dropdownRussian: 'Russisch',
|
||||
shortcut: {
|
||||
title: 'Verknüpfungen',
|
||||
custom: 'Benutzerdefiniert',
|
||||
capture: 'Klicken Sie hier, um die Verknüpfung zu erfassen',
|
||||
clear: 'Klar',
|
||||
save: 'Speichern',
|
||||
captureTips:
|
||||
'Das Erfassen systemweiter Tasten (z. B. der Windows-Taste) erfordert die Vollbildberechtigung.',
|
||||
enterFullScreen: 'Vollbildmodus umschalten.'
|
||||
},
|
||||
leaderKey: {
|
||||
title: 'Leader-Taste',
|
||||
desc: 'Browserbeschränkungen umgehen und Systemverknüpfungen direkt an den Remote-Host senden.',
|
||||
howToUse: 'Verwendung',
|
||||
simultaneous: {
|
||||
title: 'Simultanmodus',
|
||||
desc1: 'Halten Sie die Leader-Taste gedrückt und drücken Sie dann die Tastenkombination.',
|
||||
desc2: 'Intuitiv, kann jedoch zu Konflikten mit Systemverknüpfungen führen.'
|
||||
},
|
||||
sequential: {
|
||||
title: 'Sequenzieller Modus',
|
||||
desc1:
|
||||
'Drücken Sie die Leader-Taste → drücken Sie die Tastenkombination nacheinander → drücken Sie erneut die Leader-Taste.',
|
||||
desc2: 'Erfordert mehr Schritte, vermeidet jedoch vollständig Systemkonflikte.'
|
||||
},
|
||||
enable: 'Leader-Taste aktivieren',
|
||||
tip: 'Wenn diese Taste als Leader-Taste zugewiesen wird, dient sie ausschließlich als Auslöser für Tastenkombinationen und verliert ihr Standardverhalten.',
|
||||
placeholder: 'Bitte drücken Sie die Leader-Taste',
|
||||
shiftRight: 'Rechts Shift',
|
||||
ctrlRight: 'Rechts Ctrl',
|
||||
metaRight: 'Rechts Win',
|
||||
submit: 'Senden',
|
||||
recorder: {
|
||||
rec: 'REC',
|
||||
activate: 'Tasten aktivieren',
|
||||
input: 'Bitte drücken Sie die Tastenkombination...'
|
||||
}
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: 'Maus',
|
||||
@@ -92,11 +148,17 @@ const de = {
|
||||
mode: 'Maus Modus',
|
||||
absolute: 'Absoluter Modus',
|
||||
relative: 'Relativer Modus',
|
||||
direction: 'Scrollrichtung',
|
||||
scrollUp: 'Nach oben scrollen',
|
||||
scrollDown: 'Scrollen Sie nach unten',
|
||||
speed: 'Scrollgeschwindigkeit',
|
||||
fast: 'Schnell',
|
||||
slow: 'Langsam',
|
||||
requestPointer:
|
||||
'Relativer Modus aktiv. Klicken Sie auf den Desktop um den Mauszeiger zu sehen.',
|
||||
resetHid: 'HID zurücksetzen',
|
||||
hidOnly: {
|
||||
title: 'HID-Only Modus',
|
||||
title: 'HID-Only-Modus',
|
||||
desc: 'Wenn Ihre Maus und Tastatur nicht mehr reagieren und das Zurücksetzen der HID-Verbindung nicht hilft, könnte es sich um ein Kompatibilitätsproblem zwischen dem NanoKVM und dem Gerät handeln. Versuchen Sie, den HID-Only Modus zu aktivieren, um die Kompatibilität zu verbessern.',
|
||||
tip1: 'Die Aktivierung des HID-Only Modus entfernt das virtuelle U-Laufwerk und das virtuelle Netzwerk.',
|
||||
tip2: 'Im HID-Only Modus ist das Einbinden von Systemabbilder deaktiviert.',
|
||||
@@ -109,11 +171,18 @@ const de = {
|
||||
title: 'Bilder',
|
||||
loading: 'Lädt...',
|
||||
empty: 'Nichts gefunden',
|
||||
cdrom: 'Binden Sie das Abbild im CD-ROM Modus ein',
|
||||
mountMode: 'Mount-Modus',
|
||||
mountFailed: 'Einbinden fehlgeschlagen',
|
||||
mountDesc:
|
||||
'In einigen Systemen ist es notwendig, die virtuelle Festplatte auf dem entfernten Host auszuwerfen, bevor das Image eingebunden werden kann.',
|
||||
unmountFailed: 'Das Aufheben der Bereitstellung ist fehlgeschlagen',
|
||||
unmountDesc:
|
||||
'Auf einigen Systemen müssen Sie das Image manuell vom Remote-Host auswerfen, bevor Sie die Bereitstellung aufheben.',
|
||||
refresh: 'Bilder aktualisieren',
|
||||
attention: 'Achtung',
|
||||
deleteConfirm: 'Sind Sie sicher, dass Sie dieses Bild löschen möchten?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nein',
|
||||
tips: {
|
||||
title: 'So laden Sie Dateien hoch',
|
||||
usb1: 'Verbinden Sie den NanoKVM über USB mit Ihrem Computer.',
|
||||
@@ -156,8 +225,8 @@ const de = {
|
||||
parityOdd: 'Ungerade',
|
||||
flowControl: 'Fluss-Kontrolle',
|
||||
flowControlNone: 'Keine',
|
||||
flowControlSoft: 'Wenig',
|
||||
flowControlHard: 'Viel',
|
||||
flowControlSoft: 'Software',
|
||||
flowControlHard: 'Hardware',
|
||||
dataBits: 'Daten bits',
|
||||
stopBits: 'Stopp bits',
|
||||
confirm: 'Ok'
|
||||
@@ -203,7 +272,7 @@ const de = {
|
||||
applicationTip: 'NanoKVM Web Applikations-Version',
|
||||
image: 'Systemabbild-Version',
|
||||
imageTip: 'NanoKVM Systemabbild-Version',
|
||||
deviceKey: 'Geräte Schlüssel',
|
||||
deviceKey: 'Geräteschlüssel',
|
||||
community: 'Community',
|
||||
hostname: 'Hostname',
|
||||
hostnameUpdated: 'Hostname aktualisiert. Neustarten um zu übernehmen.',
|
||||
@@ -217,10 +286,19 @@ const de = {
|
||||
title: 'Erscheinungsbild',
|
||||
display: 'Bildschirm',
|
||||
language: 'Sprache',
|
||||
menuBar: 'Menu-Leiste',
|
||||
menuBarDesc: 'Symbole in der Menu-Leiste anzeigen',
|
||||
languageDesc: 'Wählen Sie die Sprache für die Benutzeroberfläche aus',
|
||||
webTitle: 'Web Titel',
|
||||
webTitleDesc: 'Passen Sie den Web-Seite Titel an'
|
||||
webTitleDesc: 'Passen Sie den Web-Seite Titel an',
|
||||
menuBar: {
|
||||
title: 'Menüleiste',
|
||||
mode: 'Anzeigemodus',
|
||||
modeDesc: 'Menüleiste auf dem Bildschirm anzeigen',
|
||||
modeOff: 'Aus',
|
||||
modeAuto: 'Automatisch ausblenden',
|
||||
modeAlways: 'Immer sichtbar',
|
||||
icons: 'Untermenüsymbole',
|
||||
iconsDesc: 'Untermenüsymbole in der Menüleiste anzeigen'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: 'Gerät',
|
||||
@@ -237,11 +315,6 @@ const de = {
|
||||
1800: '30 Min',
|
||||
3600: '1 Stunde'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Wi-Fi konfigurieren',
|
||||
setBtn: 'Konfigurieren'
|
||||
},
|
||||
ssh: {
|
||||
description: 'Aktiviere entfernten SSH-Zugang',
|
||||
tip: 'Setzten Sie ein starkes Passwort vor dem aktivieren (Konto - Passwort ändern)'
|
||||
@@ -264,7 +337,23 @@ const de = {
|
||||
description: 'mDNS-Erkennungsdienst aktivieren',
|
||||
tip: 'Deaktivieren Sie den Dienst, wenn Sie ihn nicht benötigen'
|
||||
},
|
||||
hdmi: {
|
||||
description: 'HDMI/Monitor-Ausgabe aktivieren'
|
||||
},
|
||||
autostart: {
|
||||
title: 'Autostart-Skripteinstellungen',
|
||||
description: 'Skripte verwalten, die beim Systemstart automatisch ausgeführt werden',
|
||||
new: 'Neu',
|
||||
deleteConfirm: 'Möchten Sie diese Datei wirklich löschen?',
|
||||
yes: 'Ja',
|
||||
no: 'Nein',
|
||||
scriptName: 'Name des Autostart-Skripts',
|
||||
scriptContent: 'Inhalt des Autostart-Skripts',
|
||||
settings: 'Einstellungen'
|
||||
},
|
||||
hidOnly: 'HID-Only Mode',
|
||||
hidOnlyDesc:
|
||||
'Hören Sie auf, virtuelle Geräte zu emulieren, und behalten Sie nur die grundlegende HID-Steuerung bei',
|
||||
disk: 'Virtuelle Festplatte',
|
||||
diskDesc: 'Binde das virtuelle U-Laufwerk an den entfernten Host',
|
||||
network: 'Virtuelles Netzwerk',
|
||||
@@ -274,12 +363,61 @@ const de = {
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nein'
|
||||
},
|
||||
network: {
|
||||
title: 'Netzwerk',
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Wi-Fi konfigurieren',
|
||||
apMode: 'AP-Modus ist aktiviert, verbinden Sie sich per QR-Code mit dem Wi-Fi',
|
||||
connect: 'Wi-Fi verbinden',
|
||||
connectDesc1: 'Bitte geben Sie die Netzwerk-SSID und das Passwort ein',
|
||||
connectDesc2: 'Bitte geben Sie das Passwort ein, um diesem Netzwerk beizutreten',
|
||||
disconnect: 'Möchten Sie die Netzwerkverbindung wirklich trennen?',
|
||||
failed: 'Verbindung fehlgeschlagen, bitte erneut versuchen.',
|
||||
ssid: 'Name',
|
||||
password: 'Passwort',
|
||||
joinBtn: 'Verbinden',
|
||||
confirmBtn: 'OK',
|
||||
cancelBtn: 'Abbrechen'
|
||||
},
|
||||
tls: {
|
||||
description: 'HTTPS-Protokoll aktivieren',
|
||||
tip: 'Hinweis: Die Verwendung von HTTPS kann die Latenz erhöhen, besonders im MJPEG-Videomodus.'
|
||||
},
|
||||
dns: {
|
||||
title: 'DNS',
|
||||
description: 'DNS-Server für NanoKVM konfigurieren',
|
||||
mode: 'Modus',
|
||||
dhcp: 'DHCP',
|
||||
manual: 'Manuell',
|
||||
add: 'DNS hinzufügen',
|
||||
save: 'Speichern',
|
||||
invalid: 'Bitte geben Sie eine gültige IP-Adresse ein',
|
||||
noDhcp: 'Derzeit ist kein DHCP-DNS verfügbar',
|
||||
saved: 'DNS-Einstellungen gespeichert',
|
||||
saveFailed: 'DNS-Einstellungen konnten nicht gespeichert werden',
|
||||
unsaved: 'Ungespeicherte Änderungen',
|
||||
maxServers: 'Maximal {{count}} DNS-Server erlaubt',
|
||||
dnsServers: 'DNS-Server',
|
||||
dhcpServersDescription: 'DNS-Server werden automatisch per DHCP bezogen',
|
||||
manualServersDescription: 'DNS-Server können manuell bearbeitet werden',
|
||||
networkDetails: 'Netzwerkdetails',
|
||||
interface: 'Schnittstelle',
|
||||
ipAddress: 'IP-Adresse',
|
||||
subnetMask: 'Subnetzmaske',
|
||||
router: 'Router',
|
||||
none: 'Keine'
|
||||
}
|
||||
},
|
||||
tailscale: {
|
||||
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.',
|
||||
disable: 'Deaktivieren'
|
||||
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.'
|
||||
},
|
||||
swap: {
|
||||
title: 'Speicher austauschen',
|
||||
tip: 'Wenn die Probleme nach der Aktivierung der Speicheroptimierung weiterhin bestehen, versuchen Sie, den Swap-Speicher zu aktivieren. Dadurch wird die Größe der Auslagerungsdatei standardmäßig auf 256MB festgelegt, was unter „Einstellungen > Gerät“ angepasst werden kann.'
|
||||
},
|
||||
restart: 'Tailscale neu starten?',
|
||||
stop: 'Tailscale stoppen?',
|
||||
@@ -296,6 +434,8 @@ const de = {
|
||||
upTailscale: 'Tailscale nach /usr/bin/ auf NanoKVM hochladen',
|
||||
upTailscaled: 'Tailscaled nach /usr/bin/ auf NanoKVM hochladen',
|
||||
refresh: 'Aktuelle Seite neu laden',
|
||||
notRunning: 'Tailscale läuft nicht. Bitte starten Sie es, um fortzufahren.',
|
||||
run: 'Start',
|
||||
notLogin:
|
||||
'Das Gerät konnte noch nicht gefunden werden. Bitte melden Sie sich an und verknüpfen Sie dieses Gerät mit Ihrem Konto.',
|
||||
urlPeriod: 'Diese URL ist für 10 Minuten gültig',
|
||||
@@ -308,6 +448,7 @@ const de = {
|
||||
logout: 'Abmelden',
|
||||
logoutDesc: 'Möchten Sie sich wirklich abmelden?',
|
||||
uninstall: 'Tailscale deinstallieren',
|
||||
uninstallDesc: 'Sind Sie sicher, dass Sie Tailscale deinstallieren möchten?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nein'
|
||||
},
|
||||
@@ -344,6 +485,129 @@ const de = {
|
||||
cancelBtn: 'Nein'
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
title: 'PicoClaw Assistent',
|
||||
empty: 'Öffnen Sie das Bedienfeld und starten Sie eine Aufgabe.',
|
||||
inputPlaceholder: 'Beschreiben Sie, was der PicoClaw tun soll',
|
||||
newConversation: 'Neues Gespräch',
|
||||
processing: 'Wird verarbeitet...',
|
||||
agent: {
|
||||
defaultTitle: 'Allgemeiner Assistent',
|
||||
defaultDescription: 'Allgemeine Chat-, Such- und Arbeitsbereichshilfe.',
|
||||
kvmTitle: 'Fernsteuerung',
|
||||
kvmDescription: 'Betreiben Sie den Remote-Host über NanoKVM.',
|
||||
switched: 'Agentenrolle gewechselt',
|
||||
switchFailed: 'Agentenrolle konnte nicht gewechselt werden'
|
||||
},
|
||||
send: 'Senden',
|
||||
cancel: 'Abbrechen',
|
||||
status: {
|
||||
connecting: 'Verbindung zum Gateway wird hergestellt...',
|
||||
connected: 'PicoClaw Sitzung verbunden',
|
||||
disconnected: 'PicoClaw Sitzung geschlossen',
|
||||
stopped: 'Stoppanforderung gesendet',
|
||||
runtimeStarted: 'PicoClaw Runtime gestartet',
|
||||
runtimeStartFailed: 'PicoClaw Runtime konnte nicht gestartet werden',
|
||||
runtimeStopped: 'PicoClaw Runtime gestoppt',
|
||||
runtimeStopFailed: 'PicoClaw Runtime konnte nicht gestoppt werden'
|
||||
},
|
||||
connection: {
|
||||
runtime: {
|
||||
checking: 'Überprüfung',
|
||||
ready: 'Runtime bereit',
|
||||
stopped: 'Runtime gestoppt',
|
||||
unavailable: 'Runtime nicht verfügbar',
|
||||
configError: 'Konfigurationsfehler'
|
||||
},
|
||||
transport: {
|
||||
connecting: 'Verbinden',
|
||||
connected: 'Verbunden'
|
||||
},
|
||||
run: {
|
||||
idle: 'Leerlauf',
|
||||
busy: 'Beschäftigt'
|
||||
}
|
||||
},
|
||||
message: {
|
||||
toolAction: 'Aktion',
|
||||
observation: 'Beobachtung',
|
||||
screenshot: 'Screenshot'
|
||||
},
|
||||
overlay: {
|
||||
locked: 'PicoClaw steuert das Gerät. Die manuelle Eingabe wird angehalten.'
|
||||
},
|
||||
install: {
|
||||
install: 'Installieren Sie PicoClaw',
|
||||
installing: 'Installation von PicoClaw',
|
||||
success: 'PicoClaw erfolgreich installiert',
|
||||
failed: 'Installation von PicoClaw fehlgeschlagen',
|
||||
uninstalling: 'Runtime wird deinstalliert...',
|
||||
uninstalled: 'Runtime erfolgreich deinstalliert.',
|
||||
uninstallFailed: 'Deinstallation fehlgeschlagen.',
|
||||
requiredTitle: 'PicoClaw ist nicht installiert',
|
||||
requiredDescription: 'Installieren Sie PicoClaw, bevor Sie die PicoClaw Runtime starten.',
|
||||
progressDescription: 'PicoClaw wird heruntergeladen und installiert.',
|
||||
stages: {
|
||||
preparing: 'Vorbereiten',
|
||||
downloading: 'Wird heruntergeladen',
|
||||
extracting: 'Extrahieren',
|
||||
installing: 'Installiere',
|
||||
installed: 'Installiert',
|
||||
install_timeout: 'Zeitüberschreitung',
|
||||
install_failed: 'Fehlgeschlagen'
|
||||
}
|
||||
},
|
||||
model: {
|
||||
requiredTitle: 'Modellkonfiguration ist erforderlich',
|
||||
requiredDescription:
|
||||
'Konfigurieren Sie das PicoClaw-Modell, bevor Sie den PicoClaw-Chat verwenden.',
|
||||
docsTitle: 'Konfigurationshandbuch',
|
||||
docsDesc: 'Unterstützte Modelle und Protokolle',
|
||||
menuLabel: 'Modell konfigurieren',
|
||||
modelIdentifier: 'Modell-ID',
|
||||
modelIdentifierPlaceholder: 'openai/gpt-5.4',
|
||||
apiBase: 'API Base URL',
|
||||
apiBasePlaceholder: 'https://api.example.com/v1',
|
||||
apiKey: 'API-Schlüssel',
|
||||
apiKeyPlaceholder: 'API-Schlüssel des Modells eingeben',
|
||||
save: 'Speichern',
|
||||
saving: 'Speichern',
|
||||
saved: 'Modellkonfiguration gespeichert',
|
||||
saveFailed: 'Modellkonfiguration konnte nicht gespeichert werden',
|
||||
invalid: 'Modellkennung, API Base URL und API-Schlüssel sind erforderlich'
|
||||
},
|
||||
uninstall: {
|
||||
menuLabel: 'Deinstallieren',
|
||||
confirmTitle: 'Deinstallieren PicoClaw',
|
||||
confirmContent:
|
||||
'Sind Sie sicher, dass Sie PicoClaw deinstallieren möchten? Dadurch werden die ausführbare Datei und alle Konfigurationsdateien gelöscht.',
|
||||
confirmOk: 'Deinstallieren',
|
||||
confirmCancel: 'Abbrechen'
|
||||
},
|
||||
history: {
|
||||
title: 'Verlauf',
|
||||
loading: 'Sitzungen werden geladen...',
|
||||
emptyTitle: 'Noch keine Historie',
|
||||
emptyDescription: 'Frühere PicoClaw-Sitzungen werden hier angezeigt.',
|
||||
loadFailed: 'Der Sitzungsverlauf konnte nicht geladen werden',
|
||||
deleteFailed: 'Sitzung konnte nicht gelöscht werden',
|
||||
deleteConfirmTitle: 'Sitzung löschen',
|
||||
deleteConfirmContent: 'Sind Sie sicher, dass Sie „{{title}}“ löschen möchten?',
|
||||
deleteConfirmOk: 'Löschen',
|
||||
deleteConfirmCancel: 'Abbrechen',
|
||||
messageCount_one: '{{count}} Nachricht',
|
||||
messageCount_other: '{{count}} Nachrichten'
|
||||
},
|
||||
config: {
|
||||
startRuntime: 'PicoClaw starten',
|
||||
stopRuntime: 'PicoClaw stoppen'
|
||||
},
|
||||
start: {
|
||||
title: 'PicoClaw starten',
|
||||
description:
|
||||
'Starten Sie die Runtime, um mit der Verwendung des PicoClaw-Assistenten zu beginnen.'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: 'Wir sind auf ein Problem gestossen',
|
||||
refresh: 'Neuladen'
|
||||
|
||||
@@ -16,6 +16,8 @@ const en = {
|
||||
noEmptyPassword: 'Password required',
|
||||
noAccount: 'Failed to get user information, please refresh web page or reset password',
|
||||
invalidUser: 'Invalid username or password',
|
||||
locked: 'Too many logins, please try again later',
|
||||
globalLocked: 'System under protection, please try again later',
|
||||
error: 'Unexpected error',
|
||||
changePassword: 'Change Password',
|
||||
changePasswordDesc: 'For the security of your device, please change the password!',
|
||||
@@ -46,7 +48,14 @@ const en = {
|
||||
invalidMode:
|
||||
'The current mode does not support network setup. Please go to your device and enable Wi-Fi configuration mode.',
|
||||
confirmBtn: 'Ok',
|
||||
finishBtn: 'Finished'
|
||||
finishBtn: 'Finished',
|
||||
ap: {
|
||||
authTitle: 'Authentication Required',
|
||||
authDescription: 'Please enter the AP password to continue',
|
||||
authFailed: 'Invalid AP password',
|
||||
passPlaceholder: 'AP password',
|
||||
verifyBtn: 'Verify'
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
scale: 'Scale',
|
||||
@@ -82,6 +91,7 @@ const en = {
|
||||
clipboardReadError: 'Failed to read clipboard',
|
||||
dropdownEnglish: 'English',
|
||||
dropdownGerman: 'German',
|
||||
dropdownFrench: 'French',
|
||||
dropdownRussian: 'Russian',
|
||||
shortcut: {
|
||||
title: 'Shortcuts',
|
||||
@@ -92,6 +102,34 @@ const en = {
|
||||
captureTips:
|
||||
'Capturing system-level keys (such as the Windows key) requires full-screen permission.',
|
||||
enterFullScreen: 'Toggle full-screen mode.'
|
||||
},
|
||||
leaderKey: {
|
||||
title: 'Leader Key',
|
||||
desc: 'Bypass browser restrictions and send system shortcuts directly to the remote host.',
|
||||
howToUse: 'How to Use',
|
||||
simultaneous: {
|
||||
title: 'Simultaneous Mode',
|
||||
desc1: 'Press and hold the Leader Key, then press the shortcut.',
|
||||
desc2: 'Intuitive, but may conflict with system shortcuts.'
|
||||
},
|
||||
sequential: {
|
||||
title: 'Sequential Mode',
|
||||
desc1:
|
||||
'Press the Leader Key → press the shortcut in sequence → press the Leader Key again.',
|
||||
desc2: 'Requires more steps, but completely avoids system conflicts.'
|
||||
},
|
||||
enable: 'Enable Leader Key',
|
||||
tip: 'When assigned as a Leader Key, this key functions exclusively as a shortcut trigger and loses its default behavior.',
|
||||
placeholder: 'Please press the Leader Key',
|
||||
shiftRight: 'Right Shift',
|
||||
ctrlRight: 'Right Ctrl',
|
||||
metaRight: 'Right Win',
|
||||
submit: 'Submit',
|
||||
recorder: {
|
||||
rec: 'REC',
|
||||
activate: 'Activate keys',
|
||||
input: 'Please press the shortcut...'
|
||||
}
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
@@ -243,10 +281,18 @@ const en = {
|
||||
display: 'Display',
|
||||
language: 'Language',
|
||||
languageDesc: 'Select the language for the interface',
|
||||
menuBar: 'Menu Bar',
|
||||
menuBarDesc: 'Display icons in the menu bar',
|
||||
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',
|
||||
@@ -263,29 +309,10 @@ const en = {
|
||||
1800: '30 min',
|
||||
3600: '1 hour'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi',
|
||||
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',
|
||||
tip: 'Set a strong password before enabling (Account - Change Password)'
|
||||
},
|
||||
tls: {
|
||||
description: 'Enable HTTPS protocol',
|
||||
tip: 'Be aware: Using HTTPS can increase latency, especially with MJPEG video mode.'
|
||||
},
|
||||
advanced: 'Advanced Settings',
|
||||
swap: {
|
||||
title: 'Swap',
|
||||
@@ -329,6 +356,52 @@ const en = {
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
},
|
||||
network: {
|
||||
title: 'Network',
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi',
|
||||
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'
|
||||
},
|
||||
tls: {
|
||||
description: 'Enable HTTPS protocol',
|
||||
tip: 'Be aware: Using HTTPS can increase latency, especially with MJPEG video mode.'
|
||||
},
|
||||
dns: {
|
||||
title: 'DNS',
|
||||
description: 'Configure DNS servers for NanoKVM',
|
||||
mode: 'Mode',
|
||||
dhcp: 'DHCP',
|
||||
manual: 'Manual',
|
||||
add: 'Add DNS',
|
||||
save: 'Save',
|
||||
invalid: 'Please enter a valid IP address',
|
||||
noDhcp: 'No DHCP DNS is currently available',
|
||||
saved: 'DNS settings saved',
|
||||
saveFailed: 'Failed to save DNS settings',
|
||||
unsaved: 'Unsaved changes',
|
||||
maxServers: 'Maximum {{count}} DNS servers allowed',
|
||||
dnsServers: 'DNS Servers',
|
||||
dhcpServersDescription: 'DNS servers are automatically obtained from DHCP',
|
||||
manualServersDescription: 'DNS servers can be edited manually',
|
||||
networkDetails: 'Network Details',
|
||||
interface: 'Interface',
|
||||
ipAddress: 'IP Address',
|
||||
subnetMask: 'Subnet Mask',
|
||||
router: 'Router',
|
||||
none: 'None'
|
||||
}
|
||||
},
|
||||
tailscale: {
|
||||
title: 'Tailscale',
|
||||
memory: {
|
||||
@@ -404,6 +477,127 @@ const en = {
|
||||
cancelBtn: 'No'
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
title: 'PicoClaw Assistant',
|
||||
empty: 'Open the panel and start a task to begin.',
|
||||
inputPlaceholder: 'Describe what you want the PicoClaw to do',
|
||||
newConversation: 'New conversation',
|
||||
processing: 'Processing...',
|
||||
agent: {
|
||||
defaultTitle: 'General Assistant',
|
||||
defaultDescription: 'General chat, search, and workspace help.',
|
||||
kvmTitle: 'Remote Control',
|
||||
kvmDescription: 'Operate the remote host through NanoKVM.',
|
||||
switched: 'Agent role switched',
|
||||
switchFailed: 'Failed to switch agent role'
|
||||
},
|
||||
send: 'Send',
|
||||
cancel: 'Cancel',
|
||||
status: {
|
||||
connecting: 'Connecting to gateway...',
|
||||
connected: 'PicoClaw session connected',
|
||||
disconnected: 'PicoClaw session closed',
|
||||
stopped: 'Stop request sent',
|
||||
runtimeStarted: 'PicoClaw runtime started',
|
||||
runtimeStartFailed: 'Failed to start PicoClaw runtime',
|
||||
runtimeStopped: 'PicoClaw runtime stopped',
|
||||
runtimeStopFailed: 'Failed to stop PicoClaw runtime'
|
||||
},
|
||||
connection: {
|
||||
runtime: {
|
||||
checking: 'Checking',
|
||||
ready: 'Runtime ready',
|
||||
stopped: 'Runtime stopped',
|
||||
unavailable: 'Runtime unavailable',
|
||||
configError: 'Configuration error'
|
||||
},
|
||||
transport: {
|
||||
connecting: 'Connecting',
|
||||
connected: 'Connected'
|
||||
},
|
||||
run: {
|
||||
idle: 'Idle',
|
||||
busy: 'Busy'
|
||||
}
|
||||
},
|
||||
message: {
|
||||
toolAction: 'Action',
|
||||
observation: 'Observation',
|
||||
screenshot: 'Screenshot'
|
||||
},
|
||||
overlay: {
|
||||
locked: 'PicoClaw is controlling the device. Manual input is paused.'
|
||||
},
|
||||
install: {
|
||||
install: 'Install PicoClaw',
|
||||
installing: 'Installing PicoClaw',
|
||||
success: 'PicoClaw installed successfully',
|
||||
failed: 'Failed to install PicoClaw',
|
||||
uninstalling: 'Uninstalling runtime...',
|
||||
uninstalled: 'Runtime uninstalled successfully.',
|
||||
uninstallFailed: 'Uninstall failed.',
|
||||
requiredTitle: 'PicoClaw is not installed',
|
||||
requiredDescription: 'Install PicoClaw before starting the PicoClaw runtime.',
|
||||
progressDescription: 'PicoClaw is being downloaded and installed.',
|
||||
stages: {
|
||||
preparing: 'Preparing',
|
||||
downloading: 'Downloading',
|
||||
extracting: 'Extracting',
|
||||
installing: 'Installing',
|
||||
installed: 'Installed',
|
||||
install_timeout: 'Timed Out',
|
||||
install_failed: 'Failed'
|
||||
}
|
||||
},
|
||||
model: {
|
||||
requiredTitle: 'Model configuration is required',
|
||||
requiredDescription: 'Configure the PicoClaw model before using PicoClaw chat.',
|
||||
docsTitle: 'Configuration Guide',
|
||||
docsDesc: 'Supported models and protocols',
|
||||
menuLabel: 'Configure model',
|
||||
modelIdentifier: 'Model Identifier',
|
||||
modelIdentifierPlaceholder: 'openai/gpt-5.4',
|
||||
apiBase: 'API Base URL',
|
||||
apiBasePlaceholder: 'https://api.example.com/v1',
|
||||
apiKey: 'API Key',
|
||||
apiKeyPlaceholder: 'Enter the model API key',
|
||||
save: 'Save',
|
||||
saving: 'Saving',
|
||||
saved: 'Model configuration saved',
|
||||
saveFailed: 'Failed to save model configuration',
|
||||
invalid: 'Model identifier, API base URL, and API key are required'
|
||||
},
|
||||
uninstall: {
|
||||
menuLabel: 'Uninstall',
|
||||
confirmTitle: 'Uninstall PicoClaw',
|
||||
confirmContent:
|
||||
'Are you sure you want to uninstall PicoClaw? This will delete the executable and all configuration files.',
|
||||
confirmOk: 'Uninstall',
|
||||
confirmCancel: 'Cancel'
|
||||
},
|
||||
history: {
|
||||
title: 'History',
|
||||
loading: 'Loading sessions...',
|
||||
emptyTitle: 'No history yet',
|
||||
emptyDescription: 'Previous PicoClaw sessions will appear here.',
|
||||
loadFailed: 'Failed to load session history',
|
||||
deleteFailed: 'Failed to delete session',
|
||||
deleteConfirmTitle: 'Delete session',
|
||||
deleteConfirmContent: 'Are you sure you want to delete "{{title}}"?',
|
||||
deleteConfirmOk: 'Delete',
|
||||
deleteConfirmCancel: 'Cancel',
|
||||
messageCount_one: '{{count}} message',
|
||||
messageCount_other: '{{count}} messages'
|
||||
},
|
||||
config: {
|
||||
startRuntime: 'Start PicoClaw',
|
||||
stopRuntime: 'Stop PicoClaw'
|
||||
},
|
||||
start: {
|
||||
title: 'Start PicoClaw',
|
||||
description: 'Start the runtime to begin using the PicoClaw assistant.'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: "We've ran into an issue",
|
||||
refresh: 'Refresh'
|
||||
|
||||
@@ -14,11 +14,15 @@ const es = {
|
||||
placeholderPassword2: 'Introduce tu contraseña de nuevo',
|
||||
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.',
|
||||
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',
|
||||
locked: 'Demasiados inicios de sesión, inténtalo de nuevo más tarde',
|
||||
globalLocked: 'Sistema bajo protección, inténtelo nuevamente más tarde',
|
||||
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.',
|
||||
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',
|
||||
illegalUsername: 'El nombre de usuario contiene caracteres no permitidos',
|
||||
illegalPassword: 'La contraseña contiene caracteres no permitidos',
|
||||
@@ -27,7 +31,8 @@ const es = {
|
||||
cancel: 'Cancelar',
|
||||
loginButtonText: 'Iniciar sesión',
|
||||
tips: {
|
||||
reset1: 'Para restablecer las contraseñas, mantén pulsado el botón BOOT del NanoKVM durante 10 segundos.',
|
||||
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:',
|
||||
@@ -42,16 +47,27 @@ const es = {
|
||||
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.',
|
||||
invalidMode:
|
||||
'El modo actual no admite la configuración de red. Vaya a su dispositivo y habilite el modo de configuración Wi-Fi.',
|
||||
confirmBtn: 'Aceptar',
|
||||
finishBtn: 'Finalizado'
|
||||
finishBtn: 'Finalizado',
|
||||
ap: {
|
||||
authTitle: 'Autenticación requerida',
|
||||
authDescription: 'Por favor ingrese la contraseña AP para continuar',
|
||||
authFailed: 'Contraseña AP no válida',
|
||||
passPlaceholder: 'AP contraseña',
|
||||
verifyBtn: 'Verificar'
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
scale: 'Escala',
|
||||
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 (tearing) 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',
|
||||
@@ -60,22 +76,63 @@ const es = {
|
||||
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.',
|
||||
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',
|
||||
title: 'Teclado',
|
||||
paste: 'Pegar',
|
||||
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',
|
||||
readClipboard: 'Leer del portapapeles',
|
||||
clipboardPermissionDenied: 'Permiso de portapapeles denegado. Por favor, permite el acceso al portapapeles en tu navegador.',
|
||||
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',
|
||||
dropdownFrench: 'Francés',
|
||||
dropdownRussian: 'ruso',
|
||||
shortcut: {
|
||||
title: 'Atajos',
|
||||
custom: 'Personalizado',
|
||||
capture: 'Haga clic aquí para capturar el acceso directo',
|
||||
clear: 'Borrar',
|
||||
save: 'Guardar',
|
||||
captureTips:
|
||||
'Capturar teclas del sistema (como la tecla Windows) requiere permiso de pantalla completa.',
|
||||
enterFullScreen: 'Alternar el modo de pantalla completa.'
|
||||
},
|
||||
leaderKey: {
|
||||
title: 'Tecla líder',
|
||||
desc: 'Omite las restricciones del navegador y envía accesos directos al sistema directamente al host remoto.',
|
||||
howToUse: 'Cómo utilizar',
|
||||
simultaneous: {
|
||||
title: 'Modo Simultáneo',
|
||||
desc1: 'Mantenga pulsada la tecla líder y luego pulse el atajo.',
|
||||
desc2: 'Intuitivo, pero puede entrar en conflicto con los atajos del sistema.'
|
||||
},
|
||||
sequential: {
|
||||
title: 'Modo Secuencial',
|
||||
desc1:
|
||||
'Pulse la tecla líder → pulse el atajo en secuencia → vuelva a pulsar la tecla líder.',
|
||||
desc2: 'Requiere más pasos, pero evita por completo conflictos del sistema.'
|
||||
},
|
||||
enable: 'Habilitar tecla líder',
|
||||
tip: 'Al asignarse como tecla líder, esta tecla funciona únicamente como activador de atajos y pierde su comportamiento predeterminado.',
|
||||
placeholder: 'Pulse la tecla líder',
|
||||
shiftRight: 'Shift derecho',
|
||||
ctrlRight: 'Ctrl derecho',
|
||||
metaRight: 'Win derecho',
|
||||
submit: 'Enviar',
|
||||
recorder: {
|
||||
rec: 'REC',
|
||||
activate: 'Activar teclas',
|
||||
input: 'Por favor presione el atajo...'
|
||||
}
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: 'Ratón',
|
||||
@@ -89,13 +146,17 @@ const es = {
|
||||
mode: 'Modo de ratón',
|
||||
absolute: 'Modo absoluto',
|
||||
relative: 'Modo relativo',
|
||||
speed: 'Velocidad de la rueda',
|
||||
direction: 'Dirección de la rueda de desplazamiento',
|
||||
scrollUp: 'Desplazarse hacia arriba',
|
||||
scrollDown: 'Desplácese hacia abajo',
|
||||
speed: 'Velocidad de la rueda de desplazamiento',
|
||||
fast: 'Rápida',
|
||||
slow: 'Lenta',
|
||||
requestPointer: 'Usando modo relativo. Por favor, haz clic en el escritorio para obtener el cursor del ratón.',
|
||||
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',
|
||||
title: 'Modo solo 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',
|
||||
@@ -110,9 +171,11 @@ const es = {
|
||||
empty: 'No se ha encontrado nada',
|
||||
mountMode: 'Modo de montaje',
|
||||
mountFailed: 'Fallo al montar',
|
||||
mountDesc: 'En algunos sistemas, es necesario expulsar el disco virtual del 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.',
|
||||
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?',
|
||||
@@ -177,7 +240,10 @@ const es = {
|
||||
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'
|
||||
disabled: 'La partición /data es de sólo lectura, no se puede descargar la imagen',
|
||||
uploadbox: 'Suelte el archivo aquí o haga clic para seleccionar',
|
||||
inputfile: 'Por favor ingrese el archivo de imagen',
|
||||
NoISO: 'Sin ISO'
|
||||
},
|
||||
power: {
|
||||
title: 'Encender / Apagar',
|
||||
@@ -200,9 +266,9 @@ const es = {
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: 'Versión de la aplicación',
|
||||
applicationTip: 'NanoKVM web application version',
|
||||
applicationTip: 'Versión de la aplicación web NanoKVM',
|
||||
image: 'Versión de la imagen',
|
||||
imageTip: 'NanoKVM system image version',
|
||||
imageTip: 'Versión de la imagen del sistema NanoKVM',
|
||||
deviceKey: 'Clave del dispositivo',
|
||||
community: 'Comunidad',
|
||||
hostname: 'Nombre del host',
|
||||
@@ -217,10 +283,19 @@ const es = {
|
||||
title: 'Apariencia',
|
||||
display: 'Pantalla',
|
||||
language: 'Idioma',
|
||||
menuBar: 'Barra de menú',
|
||||
menuBarDesc: 'Mostrar iconos en la barra de menú',
|
||||
languageDesc: 'Seleccionar el idioma de la interfaz',
|
||||
webTitle: 'Título web',
|
||||
webTitleDesc: 'Personaliza el título de la página web'
|
||||
webTitleDesc: 'Personaliza el título de la página web',
|
||||
menuBar: {
|
||||
title: 'Barra de menú',
|
||||
mode: 'Modo de visualización',
|
||||
modeDesc: 'Mostrar barra de menú en la pantalla',
|
||||
modeOff: 'Apagado',
|
||||
modeAuto: 'Ocultar automáticamente',
|
||||
modeAlways: 'Siempre visible',
|
||||
icons: 'Iconos del submenú',
|
||||
iconsDesc: 'Mostrar iconos de submenú en la barra de menú'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: 'Dispositivo',
|
||||
@@ -237,19 +312,10 @@ const es = {
|
||||
1800: '30 min',
|
||||
3600: '1 hora'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configura el Wi-Fi',
|
||||
setBtn: 'Configurar'
|
||||
},
|
||||
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',
|
||||
@@ -271,20 +337,75 @@ const es = {
|
||||
hdmi: {
|
||||
description: 'Habilitar salida HDMI/monitor'
|
||||
},
|
||||
autostart: {
|
||||
title: 'Configuración de scripts de inicio automático',
|
||||
description: 'Administrar scripts que se ejecutan automáticamente al iniciar el sistema',
|
||||
new: 'Nuevo',
|
||||
deleteConfirm: '¿Estás seguro de que deseas eliminar este archivo?',
|
||||
yes: 'Sí',
|
||||
no: 'No',
|
||||
scriptName: 'Nombre del script de inicio automático',
|
||||
scriptContent: 'Contenido del script de inicio automático',
|
||||
settings: 'Ajustes'
|
||||
},
|
||||
hidOnly: 'Modo sólo HID',
|
||||
hidOnlyDesc:
|
||||
'Dejar de emular dispositivos virtuales y conservar solo el control básico 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'
|
||||
},
|
||||
network: {
|
||||
title: 'Red',
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configura el Wi-Fi',
|
||||
apMode: 'El modo AP está activado; conéctate al Wi-Fi escaneando el código QR',
|
||||
connect: 'Conectar Wi-Fi',
|
||||
connectDesc1: 'Introduce el SSID de la red y la contraseña',
|
||||
connectDesc2: 'Introduce la contraseña para unirte a esta red',
|
||||
disconnect: '¿Seguro que quieres desconectar la red?',
|
||||
failed: 'Error de conexión, inténtalo de nuevo.',
|
||||
ssid: 'Nombre',
|
||||
password: 'Contraseña',
|
||||
joinBtn: 'Unirse',
|
||||
confirmBtn: 'Aceptar',
|
||||
cancelBtn: 'Cancelar'
|
||||
},
|
||||
tls: {
|
||||
description: 'Habilitar protocolo HTTPS',
|
||||
tip: 'Aviso: Usar HTTPS puede aumentar la latencia, especialmente en modo de vídeo MJPEG.'
|
||||
},
|
||||
dns: {
|
||||
title: 'DNS',
|
||||
description: 'Configura los servidores DNS para NanoKVM',
|
||||
mode: 'Modo',
|
||||
dhcp: 'DHCP',
|
||||
manual: 'Manual',
|
||||
add: 'Añadir DNS',
|
||||
save: 'Guardar',
|
||||
invalid: 'Introduce una dirección IP válida',
|
||||
noDhcp: 'No hay DNS DHCP disponible actualmente',
|
||||
saved: 'Configuración DNS guardada',
|
||||
saveFailed: 'No se pudo guardar la configuración DNS',
|
||||
unsaved: 'Cambios sin guardar',
|
||||
maxServers: 'Se permiten como máximo {{count}} servidores DNS',
|
||||
dnsServers: 'Servidores DNS',
|
||||
dhcpServersDescription: 'Los servidores DNS se obtienen automáticamente por DHCP',
|
||||
manualServersDescription: 'Los servidores DNS se pueden editar manualmente',
|
||||
networkDetails: 'Detalles de red',
|
||||
interface: 'Interfaz',
|
||||
ipAddress: 'Dirección IP',
|
||||
subnetMask: 'Máscara de subred',
|
||||
router: 'Router',
|
||||
none: 'Ninguno'
|
||||
}
|
||||
},
|
||||
tailscale: {
|
||||
title: 'Tailscale',
|
||||
memory: {
|
||||
@@ -303,7 +424,8 @@ const es = {
|
||||
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',
|
||||
@@ -312,7 +434,8 @@ const es = {
|
||||
refresh: 'Actualizar la página actual',
|
||||
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.',
|
||||
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',
|
||||
@@ -338,7 +461,16 @@ const es = {
|
||||
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'
|
||||
previewTip:
|
||||
'Ten en cuenta que las versiones de vista previa pueden contener errores o funcionalidades incompletas',
|
||||
offline: {
|
||||
title: 'Actualizaciones sin conexión',
|
||||
desc: 'Actualización a través del paquete de instalación local',
|
||||
upload: 'Subir',
|
||||
invalidName:
|
||||
'Formato de nombre de archivo no válido. Descargue desde las versiones de GitHub.',
|
||||
updateFailed: 'La actualización falló. Por favor, inténtalo de nuevo.'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
title: 'Cuenta',
|
||||
@@ -351,6 +483,128 @@ const es = {
|
||||
cancelBtn: 'No'
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
title: 'PicoClaw Asistente',
|
||||
empty: 'Abre el panel e inicia una tarea para comenzar.',
|
||||
inputPlaceholder: 'Describe lo que quieres que haga el PicoClaw',
|
||||
newConversation: 'Nueva conversación',
|
||||
processing: 'Procesando...',
|
||||
agent: {
|
||||
defaultTitle: 'Asistente general',
|
||||
defaultDescription: 'Ayuda general para chat, búsqueda y espacio de trabajo.',
|
||||
kvmTitle: 'Control remoto',
|
||||
kvmDescription: 'Opere el host remoto a través de NanoKVM.',
|
||||
switched: 'Rol de agente cambiado',
|
||||
switchFailed: 'No se pudo cambiar la función del agente'
|
||||
},
|
||||
send: 'Enviar',
|
||||
cancel: 'Cancelar',
|
||||
status: {
|
||||
connecting: 'Conectándose a la puerta de enlace...',
|
||||
connected: 'Sesión de PicoClaw conectada',
|
||||
disconnected: 'Sesión de PicoClaw cerrada',
|
||||
stopped: 'Solicitud de detención enviada',
|
||||
runtimeStarted: 'Tiempo de ejecución de PicoClaw iniciado',
|
||||
runtimeStartFailed: 'Error al iniciar el tiempo de ejecución de PicoClaw',
|
||||
runtimeStopped: 'Tiempo de ejecución de PicoClaw detenido',
|
||||
runtimeStopFailed: 'No se pudo detener el tiempo de ejecución de PicoClaw'
|
||||
},
|
||||
connection: {
|
||||
runtime: {
|
||||
checking: 'Comprobando',
|
||||
ready: 'Tiempo de ejecución listo',
|
||||
stopped: 'Tiempo de ejecución detenido',
|
||||
unavailable: 'Tiempo de ejecución no disponible',
|
||||
configError: 'Error de configuración'
|
||||
},
|
||||
transport: {
|
||||
connecting: 'Conectando',
|
||||
connected: 'Conectado'
|
||||
},
|
||||
run: {
|
||||
idle: 'Inactivo',
|
||||
busy: 'Ocupado'
|
||||
}
|
||||
},
|
||||
message: {
|
||||
toolAction: 'Acción',
|
||||
observation: 'Observación',
|
||||
screenshot: 'Captura de pantalla'
|
||||
},
|
||||
overlay: {
|
||||
locked: 'PicoClaw está controlando el dispositivo. La entrada manual está en pausa.'
|
||||
},
|
||||
install: {
|
||||
install: 'Instalar PicoClaw',
|
||||
installing: 'Instalando PicoClaw',
|
||||
success: 'PicoClaw instalado correctamente',
|
||||
failed: 'Error al instalar PicoClaw',
|
||||
uninstalling: 'Desinstalando el tiempo de ejecución...',
|
||||
uninstalled: 'El tiempo de ejecución se desinstaló exitosamente.',
|
||||
uninstallFailed: 'Falló la desinstalación.',
|
||||
requiredTitle: 'PicoClaw no está instalado',
|
||||
requiredDescription:
|
||||
'Instala PicoClaw antes de iniciar el tiempo de ejecución de PicoClaw.',
|
||||
progressDescription: 'PicoClaw se está descargando e instalando.',
|
||||
stages: {
|
||||
preparing: 'Preparando',
|
||||
downloading: 'Descargando',
|
||||
extracting: 'Extrayendo',
|
||||
installing: 'Instalando',
|
||||
installed: 'Instalado',
|
||||
install_timeout: 'Tiempo de espera agotado',
|
||||
install_failed: 'Falló'
|
||||
}
|
||||
},
|
||||
model: {
|
||||
requiredTitle: 'Se requiere configuración del modelo',
|
||||
requiredDescription: 'Configure el modelo PicoClaw antes de usar el chat PicoClaw.',
|
||||
docsTitle: 'Guía de configuración',
|
||||
docsDesc: 'Modelos y protocolos compatibles',
|
||||
menuLabel: 'Configurar modelo',
|
||||
modelIdentifier: 'Identificador de modelo',
|
||||
modelIdentifierPlaceholder: 'openai/gpt-5.4',
|
||||
apiBase: 'API Base URL',
|
||||
apiBasePlaceholder: 'https://api.example.com/v1',
|
||||
apiKey: 'Clave API',
|
||||
apiKeyPlaceholder: 'Introduzca la clave API del modelo',
|
||||
save: 'Guardar',
|
||||
saving: 'Guardando',
|
||||
saved: 'Configuración del modelo guardada',
|
||||
saveFailed: 'No se pudo guardar la configuración del modelo',
|
||||
invalid: 'Se requieren el identificador del modelo, API Base URL y la clave API'
|
||||
},
|
||||
uninstall: {
|
||||
menuLabel: 'Desinstalar',
|
||||
confirmTitle: 'Desinstalar PicoClaw',
|
||||
confirmContent:
|
||||
'¿Está seguro de que desea desinstalar PicoClaw? Esto eliminará el ejecutable y todos los archivos de configuración.',
|
||||
confirmOk: 'Desinstalar',
|
||||
confirmCancel: 'Cancelar'
|
||||
},
|
||||
history: {
|
||||
title: 'Historial',
|
||||
loading: 'Cargando sesiones...',
|
||||
emptyTitle: 'Aún no hay historial',
|
||||
emptyDescription: 'Las sesiones PicoClaw anteriores aparecerán aquí.',
|
||||
loadFailed: 'Error al cargar el historial de sesiones',
|
||||
deleteFailed: 'No se pudo eliminar la sesión',
|
||||
deleteConfirmTitle: 'Eliminar sesión',
|
||||
deleteConfirmContent: '¿Está seguro de que desea eliminar "{{title}}"?',
|
||||
deleteConfirmOk: 'Eliminar',
|
||||
deleteConfirmCancel: 'Cancelar',
|
||||
messageCount_one: '{{count}} mensaje',
|
||||
messageCount_other: '{{count}} mensajes'
|
||||
},
|
||||
config: {
|
||||
startRuntime: 'Iniciar PicoClaw',
|
||||
stopRuntime: 'Detener PicoClaw'
|
||||
},
|
||||
start: {
|
||||
title: 'Iniciar PicoClaw',
|
||||
description: 'Inicia el tiempo de ejecución para comenzar a utilizar el asistente PicoClaw.'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: 'Hemos encontrado un problema',
|
||||
refresh: 'Actualizar'
|
||||
|
||||
@@ -14,11 +14,15 @@ const fr = {
|
||||
placeholderPassword2: 'Veuillez entrer votre mot de passe à nouveau',
|
||||
noEmptyUsername: "Le nom d'utilisateur ne peut pas être vide",
|
||||
noEmptyPassword: 'Le mot de passe ne peut pas être vide',
|
||||
noAccount: "Impossible de récupérer les informations de l'utilisateur, veuillez rafraîchir la page ou réinitialiser le mot de passe",
|
||||
noAccount:
|
||||
"Impossible de récupérer les informations de l'utilisateur, veuillez rafraîchir la page ou réinitialiser le mot de passe",
|
||||
invalidUser: "Nom d'utilisateur ou mot de passe invalide",
|
||||
locked: 'Trop de connexions, veuillez réessayer plus tard',
|
||||
globalLocked: 'Système sous protection, veuillez réessayer plus tard',
|
||||
error: 'Erreur inattendue',
|
||||
changePassword: 'Changer le mot de passe',
|
||||
changePasswordDesc: 'Pour la sécurité de votre appareil, veuillez modifier le mot de passe de connexion Web.',
|
||||
changePasswordDesc:
|
||||
'Pour la sécurité de votre appareil, veuillez modifier le mot de passe de connexion Web.',
|
||||
differentPassword: 'Les mots de passe ne correspondent pas',
|
||||
illegalUsername: "Le nom d'utilisateur contient des caractères illégaux",
|
||||
illegalPassword: 'Le mot de passe contient des caractères illégaux',
|
||||
@@ -35,19 +39,33 @@ const fr = {
|
||||
change1: 'Veuillez noter que cette action modifiera les mots de passe suivants :',
|
||||
change2: 'Mot de passe de connexion Web',
|
||||
change3: 'Mot de passe racine du système (mot de passe de connexion SSH)',
|
||||
change4: 'Pour réinitialiser les mots de passe, appuyez et maintenez enfoncé le bouton BOOT sur le NanoKVM.'
|
||||
change4:
|
||||
'Pour réinitialiser les mots de passe, appuyez et maintenez enfoncé le bouton BOOT sur le NanoKVM.'
|
||||
}
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configurez le Wi-Fi pour le NanoKVM',
|
||||
success: 'Veuillez vérifier le statut du réseau du NanoKVM et visitez la nouvelle adresse IP.',
|
||||
failed: 'L\'opération a échoué, veuillez réessayer.',
|
||||
success:
|
||||
'Veuillez vérifier le statut du réseau du NanoKVM et visitez la nouvelle adresse IP.',
|
||||
failed: "L'opération a échoué, veuillez réessayer.",
|
||||
invalidMode:
|
||||
'Le mode actuel ne prend pas en charge la configuration réseau. Veuillez accéder à votre appareil et activer le mode de configuration Wi-Fi.',
|
||||
confirmBtn: 'Ok',
|
||||
finishBtn: 'Terminé'
|
||||
finishBtn: 'Terminé',
|
||||
ap: {
|
||||
authTitle: 'Authentification requise',
|
||||
authDescription: 'Veuillez saisir le mot de passe AP pour continuer',
|
||||
authFailed: 'Mot de passe AP invalide',
|
||||
passPlaceholder: 'AP mot de passe',
|
||||
verifyBtn: 'Vérifier'
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
scale: 'Échelle',
|
||||
title: 'Écran',
|
||||
video: 'Mode vidéo',
|
||||
videoDirectTips: 'Activez HTTPS dans "Paramètres > Appareil" pour utiliser ce mode',
|
||||
resolution: 'Résolution',
|
||||
auto: 'Automatique',
|
||||
autoTips:
|
||||
@@ -65,14 +83,62 @@ const fr = {
|
||||
resetHdmi: 'Réinitialiser le HDMI'
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Clavier',
|
||||
paste: 'Coller',
|
||||
tips: 'Seuls les caractères et symboles standard du clavier sont pris en charge',
|
||||
placeholder: 'Veuillez saisir',
|
||||
submit: 'Soumettre',
|
||||
virtual: 'Clavier',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
readClipboard: 'Lire le presse-papiers',
|
||||
clipboardPermissionDenied:
|
||||
"Accès au presse-papiers refusé. Veuillez autoriser l'accès dans votre navigateur.",
|
||||
clipboardReadError: 'Échec de la lecture du presse-papiers',
|
||||
dropdownEnglish: 'Anglais',
|
||||
dropdownGerman: 'Allemand',
|
||||
dropdownFrench: 'Français',
|
||||
dropdownRussian: 'Russe',
|
||||
shortcut: {
|
||||
title: 'Raccourcis',
|
||||
custom: 'Personnalisé',
|
||||
capture: 'Cliquez ici pour capturer le raccourci',
|
||||
clear: 'Effacer',
|
||||
save: 'Enregistrer',
|
||||
captureTips:
|
||||
'La capture de touches système (comme la touche Windows) nécessite l’autorisation du plein écran.',
|
||||
enterFullScreen: 'Basculer en mode plein écran.'
|
||||
},
|
||||
leaderKey: {
|
||||
title: 'Touche Leader',
|
||||
desc: "Contournez les restrictions du navigateur et envoyez les raccourcis système directement à l'hôte distant.",
|
||||
howToUse: 'Comment utiliser',
|
||||
simultaneous: {
|
||||
title: 'Mode simultané',
|
||||
desc1: 'Maintenez la touche Leader enfoncée, puis appuyez sur le raccourci.',
|
||||
desc2: 'Intuitif, mais peut entrer en conflit avec les raccourcis système.'
|
||||
},
|
||||
sequential: {
|
||||
title: 'Mode séquentiel',
|
||||
desc1:
|
||||
'Appuyez sur la touche Leader → appuyez sur le raccourci dans l’ordre → appuyez à nouveau sur la touche Leader.',
|
||||
desc2: "Nécessite plus d'étapes, mais évite complètement les conflits système."
|
||||
},
|
||||
enable: 'Activer la touche Leader',
|
||||
tip: 'Lorsqu’elle est définie comme touche Leader, cette touche sert uniquement de déclencheur de raccourci et perd son comportement par défaut.',
|
||||
placeholder: 'Appuyez sur la touche Leader',
|
||||
shiftRight: 'Shift droit',
|
||||
ctrlRight: 'Ctrl droit',
|
||||
metaRight: 'Win droit',
|
||||
submit: 'Soumettre',
|
||||
recorder: {
|
||||
rec: 'REC',
|
||||
activate: 'Activer les touches',
|
||||
input: 'Veuillez appuyer sur le raccourci...'
|
||||
}
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: 'Souris',
|
||||
cursor: 'Style de curseur',
|
||||
default: 'Curseur par défaut',
|
||||
pointer: 'Curseur de la souris',
|
||||
cell: 'Curseur de cellule',
|
||||
@@ -82,17 +148,41 @@ const fr = {
|
||||
mode: 'Mode de la souris',
|
||||
absolute: 'Mode absolu',
|
||||
relative: 'Mode relatif',
|
||||
direction: 'Sens de la molette',
|
||||
scrollUp: 'Faire défiler vers le haut',
|
||||
scrollDown: 'Faites défiler vers le bas',
|
||||
speed: 'Vitesse de la molette',
|
||||
fast: 'Rapide',
|
||||
slow: 'Lent',
|
||||
requestPointer:
|
||||
'Pour utiliser le mode relatif, cliquez sur le bureau pour capturer le pointeur de la souris.',
|
||||
resetHid: 'Réinitialiser le périphérique HID'
|
||||
resetHid: 'Réinitialiser le périphérique HID',
|
||||
hidOnly: {
|
||||
title: 'Mode HID uniquement',
|
||||
desc: "Si votre souris et votre clavier ne répondent plus et que la réinitialisation de HID ne vous aide pas, il peut s'agir d'un problème de compatibilité entre le NanoKVM et l'appareil. Essayez d'activer le mode HID-Only pour une meilleure compatibilité.",
|
||||
tip1: "L'activation du mode HID-Only démontera le disque U virtuel et le réseau virtuel",
|
||||
tip2: "En mode HID-Only, le montage d'image est désactivé",
|
||||
tip3: 'NanoKVM redémarrera automatiquement après avoir changé de mode',
|
||||
enable: 'Activer le mode HID uniquement',
|
||||
disable: 'Désactiver le mode HID uniquement'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Images',
|
||||
loading: 'Chargement',
|
||||
empty: 'Vide',
|
||||
mountMode: 'Mode de montage',
|
||||
mountFailed: "Échec du montage de l'image.",
|
||||
mountDesc:
|
||||
"Dans certains systèmes, il est nécessaire de déséjecter le disque virtuel sur l'hôte distant avant de monter l'image.",
|
||||
unmountFailed: 'Échec du démontage',
|
||||
unmountDesc:
|
||||
"Sur certains systèmes, vous devez l'éjecter manuellement de l'hôte distant avant de démonter l'image.",
|
||||
refresh: 'Actualiser la liste des images',
|
||||
attention: 'Attention',
|
||||
deleteConfirm: 'Etes-vous sûr de vouloir supprimer cette image?',
|
||||
okBtn: 'Oui',
|
||||
cancelBtn: 'Non',
|
||||
tips: {
|
||||
title: 'Comment télécharger',
|
||||
usb1: 'Connectez le NanoKVM à votre ordinateur via USB.',
|
||||
@@ -129,6 +219,16 @@ const fr = {
|
||||
serialPort: 'Port série',
|
||||
serialPortPlaceholder: 'Veuillez entrer le port série',
|
||||
baudrate: 'Débit en bauds',
|
||||
parity: 'Parité',
|
||||
parityNone: 'Aucun',
|
||||
parityEven: 'Paire',
|
||||
parityOdd: 'Impaire',
|
||||
flowControl: 'Contrôle de débit',
|
||||
flowControlNone: 'Aucun',
|
||||
flowControlSoft: 'Logiciel',
|
||||
flowControlHard: 'Matériel',
|
||||
dataBits: 'Bits de données',
|
||||
stopBits: "Bits d'arrêt",
|
||||
confirm: 'Ok'
|
||||
},
|
||||
wol: {
|
||||
@@ -142,14 +242,23 @@ const fr = {
|
||||
title: 'Télécharger l’image',
|
||||
input: 'Veuillez entrer l’URL d’une image distante',
|
||||
ok: 'Ok',
|
||||
disabled: 'La partition /data est en lecture seule, impossible de télécharger l’image'
|
||||
disabled: 'La partition /data est en lecture seule, impossible de télécharger l’image',
|
||||
uploadbox: 'Déposez le fichier ici ou cliquez pour sélectionner',
|
||||
inputfile: 'Veuillez saisir le fichier image',
|
||||
NoISO: 'Aucun ISO'
|
||||
},
|
||||
power: {
|
||||
title: 'Power',
|
||||
showConfirm: 'Confirmation',
|
||||
showConfirmTip: 'Les opérations électriques nécessitent une confirmation supplémentaire',
|
||||
reset: 'Réinitialiser',
|
||||
power: 'Power',
|
||||
powerShort: 'Power (appui court)',
|
||||
powerLong: 'Power (appui long)'
|
||||
powerLong: 'Power (appui long)',
|
||||
resetConfirm: "Procéder à l'opération de réinitialisation?",
|
||||
powerConfirm: 'Continuer le fonctionnement électrique?',
|
||||
okBtn: 'Oui',
|
||||
cancelBtn: 'Non'
|
||||
},
|
||||
settings: {
|
||||
title: 'Paramètres',
|
||||
@@ -159,24 +268,42 @@ const fr = {
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: "Version de l'application",
|
||||
applicationTip: 'Version de l\'application Web NanoKVM',
|
||||
applicationTip: "Version de l'application Web NanoKVM",
|
||||
image: "Version de l'image",
|
||||
imageTip: 'Version de l\'image système NanoKVM',
|
||||
imageTip: "Version de l'image système NanoKVM",
|
||||
deviceKey: "Clé de l'appareil",
|
||||
community: 'Communauté'
|
||||
community: 'Communauté',
|
||||
hostname: "Nom d'hôte",
|
||||
hostnameUpdated: "Nom d'hôte mis à jour. Redémarrez pour appliquer.",
|
||||
ipType: {
|
||||
Wired: 'Filaire',
|
||||
Wireless: 'Sans fil',
|
||||
Other: 'Autre'
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
title: 'Apparence',
|
||||
display: 'Affichage',
|
||||
language: 'Langue',
|
||||
menuBar: 'Barre de menus',
|
||||
menuBarDesc: 'Afficher les icônes dans la barre de menus'
|
||||
languageDesc: "Sélectionnez la langue de l'interface",
|
||||
webTitle: 'Titre Web',
|
||||
webTitleDesc: 'Personnaliser le titre de la page Web',
|
||||
menuBar: {
|
||||
title: 'Barre de menus',
|
||||
mode: "Mode d'affichage",
|
||||
modeDesc: "Afficher la barre de menu sur l'écran",
|
||||
modeOff: 'Désactivé',
|
||||
modeAuto: 'Masquer automatiquement',
|
||||
modeAlways: 'Toujours visible',
|
||||
icons: 'Icônes du sous-menu',
|
||||
iconsDesc: 'Afficher les icônes des sous-menus dans la barre de menus'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: 'Appareil',
|
||||
oled: {
|
||||
title: 'OLED',
|
||||
description: 'Écran OLED s\'éteint automatiquement',
|
||||
description: "Écran OLED s'éteint automatiquement",
|
||||
0: 'Jamais',
|
||||
15: '15 sec',
|
||||
30: '30 sec',
|
||||
@@ -187,22 +314,109 @@ const fr = {
|
||||
1800: '30 min',
|
||||
3600: '1 heure'
|
||||
},
|
||||
ssh: {
|
||||
description: "Activer l'accès à distance SSH",
|
||||
tip: "Définissez un mot de passe fort avant d'activer (Compte - Modifier le mot de passe)"
|
||||
},
|
||||
advanced: 'Paramètres avancés',
|
||||
swap: {
|
||||
title: 'Échange',
|
||||
disable: 'Désactiver',
|
||||
description: "Définir la taille du fichier d'échange",
|
||||
tip: "L'activation de cette fonctionnalité pourrait réduire la durée de vie de votre carte SD!"
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: 'Souris Jiggler',
|
||||
description: "Empêcher l'hôte distant de dormir",
|
||||
disable: 'Désactiver',
|
||||
absolute: 'Mode absolu',
|
||||
relative: 'Mode relatif'
|
||||
},
|
||||
mdns: {
|
||||
description: 'Activer le service de découverte mDNS',
|
||||
tip: "L'éteindre si ce n'est pas nécessaire"
|
||||
},
|
||||
hdmi: {
|
||||
description: 'Activer HDMI/sortie moniteur'
|
||||
},
|
||||
autostart: {
|
||||
title: 'Paramètres des scripts de démarrage automatique',
|
||||
description: "Gérer les scripts qui s'exécutent automatiquement au démarrage du système",
|
||||
new: 'Nouveau',
|
||||
deleteConfirm: 'Êtes-vous sûr de vouloir supprimer ce fichier ?',
|
||||
yes: 'Oui',
|
||||
no: 'Non',
|
||||
scriptName: 'Nom du script de démarrage automatique',
|
||||
scriptContent: 'Contenu du script de démarrage automatique',
|
||||
settings: 'Paramètres'
|
||||
},
|
||||
hidOnly: 'HID-Mode uniquement',
|
||||
hidOnlyDesc:
|
||||
"Arrêtez d'émuler des périphériques virtuels, en ne conservant que le contrôle de base HID",
|
||||
disk: 'Disque virtuel',
|
||||
diskDesc: "Monter le disque virtuel U sur l'hôte distant",
|
||||
network: 'Réseau virtuel',
|
||||
networkDesc: "Monter la carte réseau virtuelle sur l'hôte distant",
|
||||
reboot: 'Redémarrer',
|
||||
rebootDesc: 'Êtes-vous sûr de vouloir redémarrer NanoKVM?',
|
||||
okBtn: 'Oui',
|
||||
cancelBtn: 'Non'
|
||||
},
|
||||
network: {
|
||||
title: 'Réseau',
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configurez le Wi-Fi',
|
||||
setBtn: 'Configurer'
|
||||
apMode: 'Le mode AP est activé, connectez-vous au Wi-Fi en scannant le code QR',
|
||||
connect: 'Connecter le Wi-Fi',
|
||||
connectDesc1: 'Veuillez saisir le SSID du réseau et le mot de passe',
|
||||
connectDesc2: 'Veuillez saisir le mot de passe pour rejoindre ce réseau',
|
||||
disconnect: 'Voulez-vous vraiment déconnecter le réseau ?',
|
||||
failed: 'Échec de la connexion, veuillez réessayer.',
|
||||
ssid: 'Nom',
|
||||
password: 'Mot de passe',
|
||||
joinBtn: 'Rejoindre',
|
||||
confirmBtn: 'OK',
|
||||
cancelBtn: 'Annuler'
|
||||
},
|
||||
disk: 'Disque virtuel',
|
||||
diskDesc: 'Monter le disque virtuel U sur l\'hôte distant',
|
||||
network: 'Réseau virtuel',
|
||||
networkDesc: 'Monter la carte réseau virtuelle sur l\'hôte distant'
|
||||
tls: {
|
||||
description: 'Activer le protocole HTTPS',
|
||||
tip: "Attention : l'utilisation de HTTPS peut augmenter la latence, surtout en mode vidéo MJPEG."
|
||||
},
|
||||
dns: {
|
||||
title: 'DNS',
|
||||
description: 'Configurer les serveurs DNS pour NanoKVM',
|
||||
mode: 'Mode',
|
||||
dhcp: 'DHCP',
|
||||
manual: 'Manuel',
|
||||
add: 'Ajouter un DNS',
|
||||
save: 'Enregistrer',
|
||||
invalid: 'Veuillez saisir une adresse IP valide',
|
||||
noDhcp: "Aucun DNS DHCP n'est actuellement disponible",
|
||||
saved: 'Paramètres DNS enregistrés',
|
||||
saveFailed: "Échec de l'enregistrement des paramètres DNS",
|
||||
unsaved: 'Modifications non enregistrées',
|
||||
maxServers: '{{count}} serveurs DNS maximum autorisés',
|
||||
dnsServers: 'Serveurs DNS',
|
||||
dhcpServersDescription: 'Les serveurs DNS sont obtenus automatiquement par DHCP',
|
||||
manualServersDescription: 'Les serveurs DNS peuvent être modifiés manuellement',
|
||||
networkDetails: 'Détails du réseau',
|
||||
interface: 'Interface',
|
||||
ipAddress: 'Adresse IP',
|
||||
subnetMask: 'Masque de sous-réseau',
|
||||
router: 'Routeur',
|
||||
none: 'Aucun'
|
||||
}
|
||||
},
|
||||
tailscale: {
|
||||
title: 'Tailscale',
|
||||
memory: {
|
||||
title: 'Optimisation de la mémoire',
|
||||
tip: "Lorsque l'utilisation de la mémoire dépasse la limite, la collecte des ordures est effectuée plus agressivement pour essayer de libérer de la mémoire. Il est recommandé de définir à 50MB si vous utilisez Tailscale. Un redémarrage de Tailscale est nécessaire pour que le changement prenne effet.",
|
||||
disable: 'Désactiver'
|
||||
tip: "Lorsque l'utilisation de la mémoire dépasse la limite, la collecte des ordures est effectuée plus agressivement pour essayer de libérer de la mémoire. Il est recommandé de définir à 50MB si vous utilisez Tailscale. Un redémarrage de Tailscale est nécessaire pour que le changement prenne effet."
|
||||
},
|
||||
swap: {
|
||||
title: 'Échanger la mémoire',
|
||||
tip: "Si les problèmes persistent après l'activation de l'optimisation de la mémoire, essayez d'activer la mémoire d'échange. Cela définit la taille du fichier d'échange sur 256MB par défaut, qui peut être ajustée dans « Paramètres > Appareil »."
|
||||
},
|
||||
restart: 'Êtes-vous sûr de vouloir redémarrer Tailscale ?',
|
||||
stop: 'Êtes-vous sûr de vouloir arrêter Tailscale ?',
|
||||
@@ -214,11 +428,14 @@ const fr = {
|
||||
failed: 'Installation échouée',
|
||||
retry: "Veuillez rafraîchir et réessayer. Ou essayez d'installer manuellement",
|
||||
download: 'Télécharger le',
|
||||
package: 'paquet d\'installation',
|
||||
package: "paquet d'installation",
|
||||
unzip: 'et décompressez-le',
|
||||
upTailscale: 'Téléverser tailscale dans le répertoire NanoKVM /usr/sbin/',
|
||||
upTailscaled: 'Téléverser tailscaled dans le répertoire NanoKVM /usr/sbin/',
|
||||
refresh: 'Rafraîchir la page courante',
|
||||
notRunning:
|
||||
"Tailscale n'est pas en cours d'exécution. Veuillez le démarrer pour continuer.",
|
||||
run: 'Début',
|
||||
notLogin: "L'appareil n'est pas relié. Connectez-vous et liez cet appareil à votre compte.",
|
||||
urlPeriod: "L'URL est valide pendant 10 minutes",
|
||||
login: 'Connexion',
|
||||
@@ -228,7 +445,9 @@ const fr = {
|
||||
deviceIP: "IP de l'appareil",
|
||||
account: 'Compte',
|
||||
logout: 'Déconnexion',
|
||||
logout2: 'Voulez-vous vous déconnecter?',
|
||||
logoutDesc: 'Êtes-vous sûr de vouloir vous déconnecter?',
|
||||
uninstall: 'Désinstaller Tailscale',
|
||||
uninstallDesc: 'Êtes-vous sûr de vouloir désinstaller Tailscale?',
|
||||
okBtn: 'Oui',
|
||||
cancelBtn: 'Non'
|
||||
},
|
||||
@@ -240,19 +459,163 @@ const fr = {
|
||||
available: 'Une mise à jour est disponible. Voulez-vous vraiment mettre à jour?',
|
||||
updating: 'Mise à jour en cours. Veuillez patienter...',
|
||||
confirm: 'Confirmer',
|
||||
cancel: 'Annuler'
|
||||
cancel: 'Annuler',
|
||||
preview: 'Aperçu des mises à jour',
|
||||
previewDesc:
|
||||
"Bénéficiez d'un accès anticipé aux nouvelles fonctionnalités et améliorations",
|
||||
previewTip:
|
||||
'Veuillez noter que les versions préliminaires peuvent contenir des bugs ou des fonctionnalités incomplètes!',
|
||||
offline: {
|
||||
title: 'Mises à jour hors ligne',
|
||||
desc: "Mise à jour via le package d'installation local",
|
||||
upload: 'Téléverser',
|
||||
invalidName:
|
||||
'Format de nom de fichier invalide. Veuillez télécharger à partir des versions de GitHub.',
|
||||
updateFailed: 'Mise à jour échouée. Veuillez réessayer.'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
title: 'Compte',
|
||||
webAccount: 'Nom du compte Web',
|
||||
password: 'Mot de passe',
|
||||
updateBtn: 'Mettre à jour',
|
||||
logoutBtn: 'Déconnexion'
|
||||
},
|
||||
error: {
|
||||
title: "Une erreur est survenue",
|
||||
refresh: 'Rafraîchir'
|
||||
logoutBtn: 'Déconnexion',
|
||||
logoutDesc: 'Êtes-vous sûr de vouloir vous déconnecter?',
|
||||
okBtn: 'Oui',
|
||||
cancelBtn: 'Non'
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
title: 'PicoClaw Assistante',
|
||||
empty: 'Ouvrez le panneau et démarrez une tâche pour commencer.',
|
||||
inputPlaceholder: 'Décrivez ce que vous voulez que le PicoClaw fasse',
|
||||
newConversation: 'Nouvelle conversation',
|
||||
processing: 'Traitement...',
|
||||
agent: {
|
||||
defaultTitle: 'Assistant général',
|
||||
defaultDescription: "Aide générale sur le chat, la recherche et l'espace de travail.",
|
||||
kvmTitle: 'Contrôle à distance',
|
||||
kvmDescription: "Faites fonctionner l'hôte distant via NanoKVM.",
|
||||
switched: "Rôle d'agent modifié",
|
||||
switchFailed: "Échec du changement de rôle d'agent"
|
||||
},
|
||||
send: 'Envoyer',
|
||||
cancel: 'Annuler',
|
||||
status: {
|
||||
connecting: 'Connexion à la passerelle...',
|
||||
connected: 'Session PicoClaw connectée',
|
||||
disconnected: 'Session PicoClaw fermée',
|
||||
stopped: "Demande d'arrêt envoyée",
|
||||
runtimeStarted: 'Runtime PicoClaw démarré',
|
||||
runtimeStartFailed: 'Échec du démarrage du runtime PicoClaw',
|
||||
runtimeStopped: 'Runtime PicoClaw arrêté',
|
||||
runtimeStopFailed: "Échec de l'arrêt du runtime PicoClaw"
|
||||
},
|
||||
connection: {
|
||||
runtime: {
|
||||
checking: 'Vérification',
|
||||
ready: 'Runtime prêt',
|
||||
stopped: 'Runtime arrêté',
|
||||
unavailable: 'Runtime indisponible',
|
||||
configError: 'Erreur de configuration'
|
||||
},
|
||||
transport: {
|
||||
connecting: 'Connexion',
|
||||
connected: 'Connecté'
|
||||
},
|
||||
run: {
|
||||
idle: 'Inactif',
|
||||
busy: 'Occupé'
|
||||
}
|
||||
},
|
||||
message: {
|
||||
toolAction: 'Action',
|
||||
observation: 'Observation',
|
||||
screenshot: "Capture d'écran"
|
||||
},
|
||||
overlay: {
|
||||
locked: "PicoClaw contrôle l'appareil. La saisie manuelle est suspendue."
|
||||
},
|
||||
install: {
|
||||
install: 'Installer PicoClaw',
|
||||
installing: 'Installation de PicoClaw',
|
||||
success: 'PicoClaw installé avec succès',
|
||||
failed: "Échec de l'installation de PicoClaw",
|
||||
uninstalling: 'Désinstallation du runtime...',
|
||||
uninstalled: 'Runtime désinstallé avec succès.',
|
||||
uninstallFailed: 'Échec de la désinstallation.',
|
||||
requiredTitle: "PicoClaw n'est pas installé",
|
||||
requiredDescription: 'Installez PicoClaw avant de démarrer le runtime PicoClaw.',
|
||||
progressDescription: "PicoClaw est en cours de téléchargement et d'installation.",
|
||||
stages: {
|
||||
preparing: 'Préparation',
|
||||
downloading: 'Téléchargement',
|
||||
extracting: 'Extraction',
|
||||
installing: 'Installation',
|
||||
installed: 'Installé',
|
||||
install_timeout: 'Délai expiré',
|
||||
install_failed: 'Échec'
|
||||
}
|
||||
},
|
||||
model: {
|
||||
requiredTitle: 'La configuration du modèle est requise',
|
||||
requiredDescription: "Configurez le modèle PicoClaw avant d'utiliser le chat PicoClaw.",
|
||||
docsTitle: 'Guide de configuration',
|
||||
docsDesc: 'Modèles et protocoles pris en charge',
|
||||
menuLabel: 'Configurer le modèle',
|
||||
modelIdentifier: 'Identifiant du modèle',
|
||||
modelIdentifierPlaceholder: 'openai/gpt-5.4',
|
||||
apiBase: 'API Base URL',
|
||||
apiBasePlaceholder: 'https://api.example.com/v1',
|
||||
apiKey: 'Clé API',
|
||||
apiKeyPlaceholder: 'Saisissez la clé API du modèle',
|
||||
save: 'Enregistrer',
|
||||
saving: 'Enregistrement',
|
||||
saved: 'Configuration du modèle enregistrée',
|
||||
saveFailed: "Échec de l'enregistrement de la configuration du modèle",
|
||||
invalid: 'L’identifiant du modèle, l’API Base URL et la clé API sont requis'
|
||||
},
|
||||
uninstall: {
|
||||
menuLabel: 'Désinstaller',
|
||||
confirmTitle: 'Désinstaller PicoClaw',
|
||||
confirmContent:
|
||||
"Êtes-vous sûr de vouloir désinstaller PicoClaw? Cela supprimera l'exécutable et tous les fichiers de configuration.",
|
||||
confirmOk: 'Désinstaller',
|
||||
confirmCancel: 'Annuler'
|
||||
},
|
||||
history: {
|
||||
title: 'Historique',
|
||||
loading: 'Chargement des sessions...',
|
||||
emptyTitle: "Pas encore d'historique",
|
||||
emptyDescription: 'Les sessions précédentes PicoClaw apparaîtront ici.',
|
||||
loadFailed: "Échec du chargement de l'historique de la session",
|
||||
deleteFailed: 'Échec de la suppression de la session',
|
||||
deleteConfirmTitle: 'Supprimer la session',
|
||||
deleteConfirmContent: 'Etes-vous sûr de vouloir supprimer « {{title}} »?',
|
||||
deleteConfirmOk: 'Supprimer',
|
||||
deleteConfirmCancel: 'Annuler',
|
||||
messageCount_one: '{{count}} message',
|
||||
messageCount_other: '{{count}} messages'
|
||||
},
|
||||
config: {
|
||||
startRuntime: 'Démarrer PicoClaw',
|
||||
stopRuntime: 'Arrêter PicoClaw'
|
||||
},
|
||||
start: {
|
||||
title: 'Démarrer PicoClaw',
|
||||
description: "Démarrez le runtime pour commencer à utiliser l'assistant PicoClaw."
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: 'Nous avons rencontré un problème',
|
||||
refresh: 'Actualiser'
|
||||
},
|
||||
fullscreen: {
|
||||
toggle: 'Basculer vers le plein écran'
|
||||
},
|
||||
menu: {
|
||||
collapse: 'Réduire le menu',
|
||||
expand: 'Développer le menu'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user