Compare commits

...

66 Commits
2.3.2 ... 2.4.1

Author SHA1 Message Date
wenjie
2ca5b19efe release: nanokvm@2.4.1 2026-05-08 18:16:58 +08:00
wenjie
40f3917014 fix: update translation of DNS description 2026-05-08 17:34:27 +08:00
wenjie
a40433522c fix: improve DHCP DNS fallback detection
Read DHCP DNS from /tmp/resolv.conf when the cached DHCP resolv file is empty, and only fall back to the active resolv.conf when DHCP mode is active or no manual boot config exists.
2026-05-08 17:13:13 +08:00
wenjie
8470b89214 fix(i18n): sync translations for supported locales
- Add missing localized strings for recent UI features, including keyboard shortcuts, clipboard paste, Wi-Fi AP auth, terminal options, device settings, Tailscale, updates, and PicoClaw.
- Align non-English locale resources with the English translation keys.
2026-05-08 16:19:37 +08:00
wenjie
5a08170518 ui: optimize the scrollbar style of the settings 2026-05-08 14:56:50 +08:00
wenjie
36cf9c30d8 feat: add DNS management to Network settings
- Add DNS configuration APIs for manual and DHCP modes, including persistence and udhcpc hook support.
- Add a Network settings tab with DNS controls, move Wi-Fi and HTTPS settings into it, and update translations.
- Update locales
2026-05-07 17:03:25 +08:00
wenjie
1c43542919 fix: harden HID recovery and cleanup
- Open HID gadget handles lazily in nonblocking mode and clear stale handles on write failures
- Release keyboard and mouse state when HID queues close or writes fail
- Reopen HID devices with retry after USB PHY reset
- Make websocket client close idempotent and close HID queues safely
- Expire and renew PicoClaw session locks during gateway relay
2026-05-06 16:44:42 +08:00
wenjie
4080788cab chore: update README.md 2026-04-24 14:30:10 +08:00
wenjie
424302789e chore: update README.md 2026-04-24 14:17:32 +08:00
wenjie
6651167dd8 chore: upgrade vulnerable web dependencies
- Bump Axios, Vite, ESLint, TypeScript ESLint, and related lockfile entries.
- Add yaml as an explicit frontend dev dependency.
2026-04-24 10:40:20 +08:00
wenjie
2d67720022 fix: preserve loopback access for host configuration
- Keep internal loopback HTTP APIs reachable when the server is bound to a
specific non-loopback host by adding a dedicated 127.0.0.1 listener.

- Move listener address helpers into utils and normalize HTTPS redirect hosts so
IPv6 request hosts are not double-bracketed.
2026-04-24 10:29:16 +08:00
wenjie
1893912ba7 Merge pull request #792 from kmw0410/main
update korean translation for picoclaw
2026-04-24 10:05:43 +08:00
wenjie
e41ce2d6b8 Merge pull request #788 from ilyesAj/feat/add_french_layout
Add French keyboard layout support on paste window
2026-04-24 10:05:23 +08:00
wenjie
a5c6489f19 Merge pull request #770 from Voranto/Voranto-patch-2
Fix wrong message during support build
2026-04-24 09:55:51 +08:00
wenjie
fafef3d89a Merge pull request #779 from allmazz/feature/server_listen_host
Add `host` server config parameter
2026-04-24 09:46:11 +08:00
kmw0410
4b5f539445 update korean translation for picoclaw 2026-04-23 13:55:39 +09:00
wenjie
cefe0f9ab2 feat: add internal USB recovery and initialize picoclaw token 2026-04-16 17:00:16 +08:00
Kirill Nikiforov
7f3d29eba2 fix ipv6 2026-04-16 00:51:02 +04:00
Kirill Nikiforov
64681e2aac add host server config parameter 2026-04-14 06:51:06 +04:00
ilyesAj
fcb1f1c7fe Add French keyboard layout support and update language options 2026-04-11 22:39:40 +02:00
wenjie
aa0546ff37 release: nanokvm@2.4.0 2026-04-10 18:07:23 +08:00
wenjie
3254412017 Squashed commit of the following:
commit 3dea4b1cfcb866d50c9c093b797c0f4f48fec23f
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 17:11:07 2026 +0800

    fix(picoclaw): clean temp media and surface MCP screenshots in chat

    - remove shared load_image staging and pass source paths through directly
    - delete /tmp/picoclaw_media when a gateway session closes
    - push MCP screenshot observations to downstream websocket clients
    - hide null tool feedback messages and auto-scroll when screenshots load

commit f58ffe1e27f83f64e2b0e8c2f8dcac46af715ace
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 15:55:59 2026 +0800

    Harden PicoClaw local API auth and session locking

    Split PicoClaw routes by caller trust level and require the internal loopback token for local-only endpoints. Update the NanoKVM bridge script to send the internal token for loopback requests. Relax session lock acquisition for screenshot and action calls so the active session can perform local operations without permanently taking over the lock, and stop forcing the runtime dm_scope default.

commit 462a0670bbc5138c893fbd1155e72465b82c2065
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 13:52:39 2026 +0800

    fix(picoclaw): improve KVM reliability and sidebar behavior

    - follow HTTPS loopback redirects in the NanoKVM bridge script
    - enable MJPEG frame caching only during active PicoClaw gateway sessions
    - restore legacy screen zoom behavior across MJPEG and H264 renderers
    - keep the PicoClaw sidebar available on mobile without splitter layout conflicts
    - hide empty "null"/"undefined" chat messages and keep MJPEG failures on a black screen

commit bfda85c6368825c7ddf47d347ba5ec2588135885
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 11:37:15 2026 +0800

    fix(picoclaw): verify runtime downloads and remove unused config API

    - verify the downloaded runtime archive against the published SHA-512 checksum
    - remove the unused /api/picoclaw/config endpoints and related frontend state
    - rename runtime_control.go to runtime_constants.go for clearer intent

commit 10b34aaea59e10d70c3cdecb01915444561ceaf7
Author: wenjie <meetwenjie@gmail.com>
Date:   Thu Apr 9 16:32:30 2026 +0800

    fix: stabilize picoclaw runtime defaults and secure local MCP access

    - persist required NanoKVM startup defaults before launching picoclaw and after saving model config
    - force-enable the pico channel when loading config so runtime status can recover to ready
    - derive the local MCP URL from the configured HTTP port and keep loopback-only HTTP access behind an internal token
    - move loopback HTTP redirect logic into middleware to simplify main server startup
    - improve runtime/sidebar state handling and add the load-image endpoint for active picoclaw sessions

commit d20540195bf45ea290f727b473ee8cd2a1bbb68d
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Mar 27 17:55:29 2026 +0800

    feat(picoclaw): add picoclaw integration
2026-04-10 17:28:28 +08:00
BuGu
fda3c6b1a8 Add default mask for static IP 2026-03-27 10:50:18 +08:00
BuGu
97832557fb Fix the issue of occasional IP not displaying 2026-03-26 18:21:47 +08:00
wenjie
590db5b08c release: nanokvm@2.3.6 2026-03-12 16:02:18 +08:00
wenjie
10a512ec96 feat: Implement AP password authentication for the WiFi configuration page 2026-03-12 15:21:18 +08:00
wenjie
08093dddad Merge pull request #769 from Voranto/Voranto-patch-1
Enable support for 640x480 resolution
2026-03-12 11:21:17 +08:00
wenjie
86a85a61b7 Merge pull request #761 from klim4-bot/main
feat : Add Korean and Japanese virtual keyboard layouts
2026-03-12 11:20:54 +08:00
wenjie
cb3a2c562e Merge pull request #744 from tkmsst/pull-request
Update Japanese translation
2026-03-12 11:18:12 +08:00
Voranto
832832a716 Fix project name in build script for kvm_vision 2026-03-05 16:02:24 +01:00
Voranto
5fe6d0d48f Fix wrong message during ./build kvm_vision add_to_kvmapp 2026-03-05 15:57:44 +01:00
Voranto
67fe57f189 Enable support for 640x480 resolution 2026-03-05 15:52:49 +01:00
BuGu
44caee21ab Add encryption parameters for OLED distribution network 2026-03-04 17:11:42 +08:00
BuGu
5b2a1ff788 Add webpage logo generation tool and method 2026-03-04 17:09:52 +08:00
BuGu
dc5ba29bcf Add a logo file generator based on TUI 2026-03-03 19:12:59 +08:00
BuGu
89c14310c7 Add custom logo function 2026-03-03 18:50:35 +08:00
BuGu
3322a3c07f Add update mechanism for tailscale startup script 2026-03-03 18:49:02 +08:00
wj-xiao
9e1251ad6a release: nanokvm@2.3.5 2026-02-28 17:25:14 +08:00
wj-xiao
afec4efb34 feat: Implement login brute-force protection with lockout mechanism 2026-02-28 16:44:47 +08:00
wj-xiao
11980b9122 fix: improve Tailscale error handling in UI components and refine backend state mapping 2026-02-28 11:12:26 +08:00
wj-xiao
2a43fb5413 chore: update server and web dependencies 2026-02-28 10:53:31 +08:00
jason
57887f3f69 Add Korean and Japanese virtual keyboard layouts 2026-02-19 07:47:41 +09:00
tkmsst
2b8f640957 Update Japanese translation 2026-02-02 01:14:57 +09:00
wj-xiao
4af32fc703 release: nanokvm@2.3.4 2026-01-26 17:56:52 +08:00
wj-xiao
a611e5ef56 feat: support keyboard leader key functionality 2026-01-26 17:10:16 +08:00
wj-xiao
94051c3037 chore: added image version number v1.4.2 2026-01-26 11:23:17 +08:00
wj-xiao
d5cd706078 fix: compatible with non-standard modifier keys 2026-01-26 11:12:19 +08:00
wj-xiao
7460ebfd07 fix: the menu bar cannot be closed 2026-01-26 10:46:57 +08:00
wj-xiao
e3877308a0 fix: trigger update check when switching preview updates 2026-01-26 10:28:27 +08:00
wenjie
7c28fb8124 Merge pull request #737 from kmw0410/main
update korean translation
2026-01-26 10:08:02 +08:00
wenjie
b18873364f Merge pull request #733 from j796160836/feature/update_zh_tw
Update zh_tw translation
2026-01-26 10:07:37 +08:00
kmw0410
016a7d8a51 update korean translation 2026-01-25 22:04:24 +09:00
Johnny Sung
585f1bc2b5 feat: update zh_tw translation 2026-01-20 01:59:53 +08:00
wj-xiao
4fcc4c3747 release: nanokvm@2.3.3 2026-01-16 15:32:14 +08:00
wj-xiao
0d2c52adc3 feat: the menu bar supports setting the display mode 2026-01-16 11:47:56 +08:00
wj-xiao
af256e7f92 fix: compatible with IME composition 2026-01-15 17:04:17 +08:00
wj-xiao
84830421ee fix: compatible with Windows AltGr key 2026-01-15 16:45:48 +08:00
wj-xiao
9b278cdb0e fix: compatible with macOS's command key combinations 2026-01-14 17:58:25 +08:00
wj-xiao
9b40a5cf86 chore: update eslint 2026-01-14 17:58:25 +08:00
wj-xiao
b8d980be33 fix: the IntlBackslash key does not work on German virtual keyboard 2026-01-14 17:58:25 +08:00
wenjie
195037af87 Merge pull request #728 from imguoguo/main
ci: add missing parameters and push when issue closed
2026-01-14 15:48:07 +08:00
Guoguo
c98000069a ci: add missing parameters and push when issue closed 2026-01-14 07:43:19 +00:00
wenjie
2bd277b57d Merge pull request #725 from kmw0410/main
update korean translation
2026-01-14 10:30:40 +08:00
wj-xiao
4852ed813b chore: bump react-router-dom from 6.27.0 to 6.30.3 2026-01-14 10:27:08 +08:00
wj-xiao
fb08df9e4e fix: correct erroneous release date 2026-01-12 19:53:26 +08:00
kmw0410
64f3b49e21 update korean translation 2026-01-12 09:11:56 +09:00
181 changed files with 27365 additions and 4676 deletions

View File

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

View File

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

View File

@@ -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
View 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.

View 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.

View 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.

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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
View 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

View File

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

View File

@@ -27,6 +27,7 @@ server
```yaml
proto: http
host: ""
port:
http: 80
https: 443

View File

@@ -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 服务器授权连接时使用的凭据/密码
```
## 编译部署

View File

@@ -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() {

View 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
}

View File

@@ -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:"-"`

View File

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

View File

@@ -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=

View File

@@ -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()
}

View File

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

View 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()
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

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

View File

@@ -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
View 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)
}

View File

@@ -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
}

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

View File

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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}
}
}

View 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")
}

View File

@@ -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
}

View File

@@ -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},

View File

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

View 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
}

View 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

View File

@@ -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", "")
}

View 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")
}
}

View 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()),
})
}

View 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
}

View 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
}

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

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

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

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

View 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
}

View 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
}

View 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, &params); 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, &params)
}
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, &params); 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,
},
},
},
}
}

View 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
}

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

View 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()
}

View 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(),
})
}

View 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
}

View 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,
})
}

View 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
}

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

View 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
}

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

View 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]) + "..."
}

View 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 != ""
}

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

View 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"`
}

View File

@@ -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()
}

View File

@@ -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 {

View File

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

View File

@@ -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
}

View File

@@ -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
View 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()
}

View File

@@ -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] = {

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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");

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

View 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! 🎉

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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 });
}

View File

@@ -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
View 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 };

View File

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

View File

@@ -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;
}
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

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

View 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;
}

View 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
};
}

View File

@@ -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;

View File

@@ -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'
}
}
};

View File

@@ -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'
}
}
};

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

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