Compare commits

...

44 Commits
2.3.6 ... 2.4.3

Author SHA1 Message Date
916BGAI
3b2ba7c0c1 docs: add 2.4.3 changelog entries 2026-06-09 18:06:03 +08:00
916BGAI
208998c13a support lt6911d 2026-06-09 18:00:32 +08:00
wenjie
3de4a18eb4 chore: update js-cookie for security fix 2026-05-22 10:43:17 +08:00
wenjie
863fea9ab8 release: nanokvm@2.4.2 2026-05-20 16:23:16 +08:00
wenjie
f65c1b06aa chore: refresh desktop menu ordering and community links
- Replace the Discussion community link with Discord
- Align terminal and script ordering in the menu and appearance settings
- Remove the divider before the leader key menu item
2026-05-20 15:07:59 +08:00
wenjie
fe34008ce3 fix: handle left-button touch cancel and contextmenu cleanup 2026-05-20 14:49:33 +08:00
wenjie
961d7f2e89 fix: validate Wi-Fi password field 2026-05-20 11:53:53 +08:00
wenjie
94749ea621 fix: harden Wake-on-LAN MAC handling
Normalize stored MAC addresses, avoid shell execution for ether-wake, handle missing WOL history gracefully, and improve the WOL menu display for named MAC entries.
2026-05-20 11:38:14 +08:00
wenjie
6ae742deed fix: improve WebRTC loading indicator 2026-05-19 17:31:19 +08:00
wenjie
b653f905e4 perf: improve H264 streaming client handling
Use client snapshots for stream fanout, reduce frame queue latency, expose backend ICE server configuration to the WebRTC client, and clean up disconnected WebRTC clients more aggressively.
2026-05-19 17:04:57 +08:00
wenjie
af19795c29 refactor: remove legacy h264 stream package 2026-05-19 16:49:41 +08:00
wenjie
be7584d908 feat: show HDMI capture status on desktop screen
Track capture results for each stream mode, broadcast status updates over WebSocket, and display localized warning/error overlays in the desktop view.
2026-05-19 15:02:34 +08:00
wenjie
a960417739 fix: stabilize PicoClaw session switching
- Wait for gateway session release before reconnecting to a new or selected session
- Refresh runtime status before deciding whether a selected history session can reconnect
- Render structured thought messages from gateway payload metadata
- Prevent PicoClaw history rows from overflowing horizontally
- Add localized labels for the verifying install stage
2026-05-18 17:26:00 +08:00
wenjie
3f0621e7ed Merge pull request #799 from BeaconCat/feat/picoclaw-v3-session-history
feat(picoclaw): support sk_v1_ opaque session keys for V3 history
2026-05-18 15:31:39 +08:00
wenjie
515c5e2a76 Merge pull request #798 from BeaconCat/feat/picoclaw-v3-config
feat(picoclaw): adapt to PicoClaw V3 config format
2026-05-18 15:23:05 +08:00
wenjie
cb025f04b4 chore(web): update PostCSS and MSW worker
- Bump PostCSS to 8.5.10 and refresh the pnpm lockfile
- Regenerate the MSW service worker for msw 2.12.10
- Add pnpm build approvals for frontend tooling
2026-05-18 15:02:31 +08:00
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
BeaconCat
dff794cee4 feat(picoclaw): adapt to PicoClaw V3 config format
- channels -> channel_list with nested settings struct (V3 format)
- security channels -> channel_list.pico.settings.token
- Remove broken pico- prefix token composition logic
- Token now uses exact match with PicoClaw gateway
- Add GitHub Actions CI for building update package
- Version: 2.4.0.Beacon
2026-05-08 08:30:08 +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
BeaconCat
8eca1edf47 feat(picoclaw): support sk_v1_ opaque session keys for V3 history 2026-05-06 10:21:52 +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
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
166 changed files with 24270 additions and 2890 deletions

View File

@@ -1,3 +1,82 @@
## 2.4.3 (2026-06-09)
### Features
* Added LT6911D support
### Bug Fixes
* Improved USB network adapter compatibility on Windows
## 2.4.2 (2026-05-20)
### Features
* Added HDMI capture status detection with localized warning and error overlays on the desktop screen
### Bug Fixes
* Fixed left-button touch cancellation and context menu cleanup for mouse input
* Improved the WebRTC loading indicator
* Added Wi-Fi password field validation
* Hardened Wake-on-LAN MAC handling by normalizing stored addresses, avoiding shell execution, handling missing history gracefully, and improving named MAC display
* Stabilized PicoClaw session switching by waiting for gateway release, refreshing runtime status before reconnecting, rendering structured thought messages, and preventing history row overflow
### Performance
* Improved H.264 streaming client handling by reducing frame queue latency, using client snapshots for fanout, applying backend ICE server configuration, and cleaning up disconnected WebRTC clients more aggressively
### UI Improvements
* Refreshed desktop menu ordering and community links, including replacing the Discussion link with Discord
### Chores
* Removed the legacy H.264 stream package
* Updated PostCSS, refreshed the MSW worker, and added pnpm workspace build approvals
## 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

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

@@ -1,5 +1,5 @@
#!/bin/sh
# kvmhwd Rev2.1
# kvmhwd Rev2.2
start_usb_dev(){
. /etc/profile
@@ -23,6 +23,11 @@ start_usb_dev(){
else
echo 0x1009 > idProduct
fi
echo 0xEF > bDeviceClass
echo 0x02 > bDeviceSubClass
echo 0x01 > bDeviceProtocol
mkdir strings/0x409
echo '0123456789ABCDEF' > strings/0x409/serialnumber
echo 'sipeed' > strings/0x409/manufacturer
@@ -37,15 +42,30 @@ start_usb_dev(){
if [ -e /boot/usb.ncm ]
then
mkdir functions/ncm.usb0
echo WINNCM > functions/ncm.usb0/os_desc/interface.ncm/compatible_id
ln -s functions/ncm.usb0 configs/c.1/
else
if [ -e /boot/usb.rndis0 ]
then
mkdir functions/rndis.usb0
echo e0 > functions/rndis.usb0/class
echo 01 > functions/rndis.usb0/subclass
echo 03 > functions/rndis.usb0/protocol
echo RNDIS > functions/rndis.usb0/os_desc/interface.rndis/compatible_id
echo 5162001 > functions/rndis.usb0/os_desc/interface.rndis/sub_compatible_id
ln -s functions/rndis.usb0 configs/c.1/
fi
fi
if [ -e /boot/usb.ncm ] || [ -e /boot/usb.rndis0 ]
then
# Microsoft OS 2.0 Descriptor
echo 1 > os_desc/use
echo 0xCD > os_desc/b_vendor_code
echo MSFT100 > os_desc/qw_sign
ln -s configs/c.1 os_desc
fi
if [ ! -e /boot/disable_hid ]
then
# keyboard

View File

@@ -1,5 +1,5 @@
#!/bin/sh
# kvmhwd Rev2.4
# kvmhwd Rev2.5
Alpha_OLED_RST_Pin=371
Beta_OLED_RST_Pin=502
@@ -25,7 +25,7 @@ init_alpha_hw(){
devmem 0x03001060 32 0x3 # GPIOA24
devmem 0x03001054 32 0x3 # GPIOA25
devmem 0x03001058 32 0x3 # GPIOA27
devmem 0x03001068 32 0x6 # GPIOA 18 UART1 RX
devmem 0x03001064 32 0x6 # GPIOA 19 UART1 TX
devmem 0x03001070 32 0x2 # GPIOA 28 UART2 TX
@@ -146,19 +146,7 @@ kvm_hw_detect(){
insmod /mnt/system/ko/i2c-algo-bit.ko
insmod /mnt/system/ko/i2c-gpio.ko
kvm_tmp=$(i2cdetect -ry 4 0x2c 0x2c | grep 2c)
if [ -n "$kvm_tmp" ]
then
echo "c" > /etc/kvm/hdmi_version
else
kvm_tmp1=$(i2cdetect -ry 4 0x2b 0x2b | grep 2b)
if [ -n "$kvm_tmp1" ]
then
echo "ux" > /etc/kvm/hdmi_version
else
echo "ue" > /etc/kvm/hdmi_version
fi
fi
kvm_hdmi_version_detect
kvm_tmp=$(i2cdetect -ry 5 0x3c 0x3c | grep 3c)
if [ -n "$kvm_tmp" ]
@@ -199,20 +187,19 @@ kvm_hw_init(){
fi
}
kvm_hdmi_version_detect(){
hdmi_tmp=$(i2cdetect -ry 4 0x2c 0x2c | grep 2c)
if [ -n "$hdmi_tmp" ]
then
echo "c" > /etc/kvm/hdmi_version
else
hdmi_tmp1=$(i2cdetect -ry 4 0x2b 0x2b | grep 2b)
if [ -n "$hdmi_tmp1" ]
then
echo "ux" > /etc/kvm/hdmi_version
kvm_hdmi_version_detect()
{
if i2cdetect -ry 4 0x2c 0x2c | grep -qw "2c"; then
echo "c" > /etc/kvm/hdmi_version
elif i2cdetect -ry 4 0x2b 0x2b | grep -qw "2b"; then
if i2cdetect -ry 4 0x44 0x44 | grep -qw "44"; then
echo "ux" > /etc/kvm/hdmi_version
else
echo "d" > /etc/kvm/hdmi_version
fi
else
echo "ue" > /etc/kvm/hdmi_version
echo "ue" > /etc/kvm/hdmi_version
fi
fi
}
case "$1" in

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

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

@@ -28,6 +28,7 @@ The configuration file path is `/etc/kvm/server.yaml`.
```yaml
# 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 # 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`

View File

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

View File

@@ -26,6 +26,7 @@ server
```yaml
# 网络设置
proto: http # 访问协议,默认为 `http`,仅当配置了证书时支持改为 `https`
host: ""
port:
http: 80 # HTTP 服务的监听端口,默认为 `80`
https: 443 # HTTPS 服务的监听端口(启用 https 协议时生效),默认为 `443`

View File

@@ -2,6 +2,7 @@ package config
var defaultConfig = &Config{
Proto: "http",
Host: "",
Port: Port{
Http: 80,
Https: 443,

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

@@ -2,6 +2,7 @@ package config
type Config struct {
Proto string `yaml:"proto"`
Host string `yaml:"host"`
Port Port `yaml:"port"`
Cert Cert `yaml:"cert"`
Logger Logger `yaml:"logger"`

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

@@ -26,5 +26,27 @@ type GetWifiRsp struct {
type ConnectWifiReq struct {
Ssid string `validate:"required"`
Password string `valid:"required"`
Password string `validate:"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
@@ -23,4 +30,6 @@ func hidRouter(r *gin.Engine) {
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,8 +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/verify", service.VerifyApLogin) // verify ap login
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())
@@ -23,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

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

@@ -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 (
"errors"
"fmt"
"os"
"os/exec"
@@ -33,8 +34,7 @@ func (s *Service) WakeOnLAN(c *gin.Context) {
return
}
command := fmt.Sprintf("ether-wake -b %s", mac)
cmd := exec.Command("sh", "-c", command)
cmd := exec.Command("ether-wake", "-b", mac)
output, err := cmd.CombinedOutput()
if err != nil {
@@ -43,7 +43,7 @@ func (s *Service) WakeOnLAN(c *gin.Context) {
return
}
go saveMac(mac)
saveMac(mac)
rsp.OkRsp(c)
log.Debugf("wake on lan: %s", mac)
@@ -52,14 +52,19 @@ func (s *Service) WakeOnLAN(c *gin.Context) {
func (s *Service) GetMac(c *gin.Context) {
var rsp proto.Response
content, err := os.ReadFile(WolMacFile)
macs, err := readWolMacs()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
rsp.OkRspWithData(c, &proto.GetMacRsp{Macs: []string{}})
return
}
rsp.ErrRsp(c, -2, "open file error")
return
}
data := &proto.GetMacRsp{
Macs: strings.Split(string(content), "\n"),
Macs: macs,
}
rsp.OkRspWithData(c, data)
@@ -74,36 +79,54 @@ func (s *Service) SetMacName(c *gin.Context) {
return
}
content, err := os.ReadFile(WolMacFile)
mac, err := parseMAC(req.Mac)
if err != nil {
rsp.ErrRsp(c, -2, "invalid MAC address")
return
}
name := sanitizeWolMacName(req.Name)
if name == "" {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
macs, err := readWolMacs()
if err != nil {
log.Errorf("failed to open %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -2, "read failed")
return
}
macs := strings.Split(string(content), "\n")
var newLines []string
macFound := false
for _, line := range macs {
parts := strings.Split(line, " ")
if req.Mac != parts[0] {
newLines = append(newLines, line)
itemMac, itemName, ok := splitWolMacLine(line)
if !ok {
continue
}
newLines = append(newLines, parts[0]+" "+req.Name)
normalizedMac, err := parseMAC(itemMac)
if err != nil {
newLines = append(newLines, formatWolMacLine(itemMac, itemName))
continue
}
if mac != normalizedMac {
newLines = append(newLines, formatWolMacLine(normalizedMac, itemName))
continue
}
newLines = append(newLines, formatWolMacLine(normalizedMac, name))
macFound = true
}
if !macFound {
log.Errorf("failed to found mac %s: %s", req.Mac, err)
log.Errorf("failed to find mac %s", req.Mac)
rsp.ErrRsp(c, -3, "write failed")
return
}
data := strings.Join(newLines, "\n")
err = os.WriteFile(WolMacFile, []byte(data), 0o644)
if err != nil {
if err = writeWolMacs(newLines); err != nil {
log.Errorf("failed to write %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -3, "write failed")
return
@@ -122,26 +145,37 @@ func (s *Service) DeleteMac(c *gin.Context) {
return
}
content, err := os.ReadFile(WolMacFile)
mac, err := parseMAC(req.Mac)
if err != nil {
rsp.ErrRsp(c, -2, "invalid MAC address")
return
}
macs, err := readWolMacs()
if err != nil {
log.Errorf("failed to open %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -2, "read failed")
return
}
macs := strings.Split(string(content), "\n")
var newMacs []string
for _, mac := range macs {
parts := strings.Split(mac, " ")
if req.Mac != parts[0] {
newMacs = append(newMacs, mac)
for _, line := range macs {
itemMac, itemName, ok := splitWolMacLine(line)
if !ok {
continue
}
normalizedMac, err := parseMAC(itemMac)
if err != nil {
newMacs = append(newMacs, formatWolMacLine(itemMac, itemName))
continue
}
if mac != normalizedMac {
newMacs = append(newMacs, formatWolMacLine(normalizedMac, itemName))
}
}
data := strings.Join(newMacs, "\n")
err = os.WriteFile(WolMacFile, []byte(data), 0o644)
if err != nil {
if err = writeWolMacs(newMacs); err != nil {
log.Errorf("failed to write %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -3, "write failed")
return
@@ -182,7 +216,7 @@ func saveMac(mac string) {
return
}
err := os.MkdirAll(filepath.Dir(WolMacFile), 0o644)
err := os.MkdirAll(filepath.Dir(WolMacFile), 0o755)
if err != nil {
log.Errorf("failed to create dir: %s", err)
return
@@ -206,18 +240,85 @@ func saveMac(mac string) {
}
func isMacExist(mac string) bool {
content, err := os.ReadFile(WolMacFile)
macs, err := readWolMacs()
if err != nil {
return false
}
macs := strings.Split(string(content), "\n")
for _, item := range macs {
parts := strings.Split(item, " ")
if mac == parts[0] {
itemMac, _, ok := splitWolMacLine(item)
if !ok {
continue
}
normalizedMac, err := parseMAC(itemMac)
if err != nil {
continue
}
if mac == normalizedMac {
return true
}
}
return false
}
func readWolMacs() ([]string, error) {
content, err := os.ReadFile(WolMacFile)
if err != nil {
return nil, err
}
lines := strings.Split(string(content), "\n")
macs := make([]string, 0, len(lines))
for _, line := range lines {
mac, name, ok := splitWolMacLine(line)
if !ok {
continue
}
macs = append(macs, formatWolMacLine(mac, name))
}
return macs, nil
}
func writeWolMacs(macs []string) error {
data := ""
if len(macs) > 0 {
data = strings.Join(macs, "\n") + "\n"
}
return os.WriteFile(WolMacFile, []byte(data), 0o644)
}
func splitWolMacLine(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if line == "" {
return "", "", false
}
fields := strings.Fields(line)
if len(fields) == 0 {
return "", "", false
}
mac := fields[0]
if len(line) == len(mac) {
return mac, "", true
}
name := line[len(mac):]
return mac, strings.TrimSpace(name), true
}
func formatWolMacLine(mac string, name string) string {
if name == "" {
return mac
}
return mac + " " + name
}
func sanitizeWolMacName(name string) string {
return strings.Join(strings.Fields(name), " ")
}

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,184 @@
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
}
picoSettings := picoclawPicoSettingsV3{}
if pico, ok := cfg.Channels["pico"]; ok {
picoSettings = pico.Settings
}
settings := picoclawGatewaySettings{
GatewayURL: fmt.Sprintf("ws://%s:%d%s", host, port, picoclawGatewayPath),
Token: doc.resolvedGatewayToken(),
AllowTokenQuery: picoSettings.AllowTokenQuery,
}
settings.TargetModelName = resolvePicoclawTargetModelName(cfg)
if isPicoclawModelConfigured(cfg, doc.security, settings.TargetModelName) {
settings.ModelConfigured = true
settings.ModelName = settings.TargetModelName
}
if picoSettings.PingInterval > 0 {
settings.PingIntervalMs = picoSettings.PingInterval * 1000
}
if picoSettings.ReadTimeout > 0 {
settings.ReadTimeoutMs = picoSettings.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,215 @@
package picoclaw
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
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 map[string]picoclawChannelEntry `json:"channel_list"`
}
type picoclawChannelEntry struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
Settings picoclawPicoSettingsV3 `json:"settings"`
}
type picoclawPicoSettingsV3 struct {
Token string `json:"token,omitempty"`
AllowTokenQuery bool `json:"allow_token_query,omitempty"`
PingInterval int `json:"ping_interval,omitempty"`
ReadTimeout int `json:"read_timeout,omitempty"`
WriteTimeout int `json:"write_timeout,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
}
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"`
ChannelList map[string]picoclawChannelSecurityEntry `yaml:"channel_list,omitempty"`
}
type picoclawModelSecurityEntry struct {
APIKeys []string `yaml:"api_keys,omitempty"`
}
type picoclawChannelSecurityEntry struct {
Token string `yaml:"token,omitempty"`
Settings *picoclawChannelSecuritySettings `yaml:"settings,omitempty"`
}
type picoclawChannelSecuritySettings struct {
Token string `yaml:"token,omitempty"`
}
func (d *picoclawConfigDocument) resolvedPicoToken() string {
if d == nil {
return ""
}
// Check security config (channel_list.pico with nested settings)
if entry, ok := d.security.ChannelList["pico"]; ok {
if entry.Settings != nil && entry.Settings.Token != "" {
return entry.Settings.Token
}
if entry.Token != "" {
return entry.Token
}
}
// Fall back to config.json channel_list.pico.settings.token
if pico, ok := d.config.Channels["pico"]; ok {
return pico.Settings.Token
}
return ""
}
func (d *picoclawConfigDocument) resolvedGatewayToken() string {
return strings.TrimSpace(d.resolvedPicoToken())
}
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.8/picoclaw_Linux_riscv64.tar.gz"
picoclawChecksumURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/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,253 @@
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{"channel_list", "pico", "type"}, value: "pico"},
{path: []string{"channel_list", "pico", "settings", "allow_token_query"}, value: false},
{path: []string{"channel_list", "pico", "settings", "ping_interval"}, value: defaultPicoclawPingSec},
{path: []string{"channel_list", "pico", "settings", "read_timeout"}, value: defaultPicoclawReadSec},
{path: []string{"channel_list", "pico", "settings", "write_timeout"}, value: defaultPicoclawWriteSec},
{path: []string{"channel_list", "pico", "settings", "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 {
return nil
}
if pico, ok := doc.config.Channels["pico"]; ok && 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 {
return
}
if pico, ok := doc.config.Channels["pico"]; ok && pico.Enabled {
return
}
editor.setValue(true, "channel_list", "pico", "enabled")
if doc.config.Channels == nil {
doc.config.Channels = make(map[string]picoclawChannelEntry)
}
pico := doc.config.Channels["pico"]
pico.Enabled = true
doc.config.Channels["pico"] = pico
}
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.ChannelList == nil {
doc.security.ChannelList = make(map[string]picoclawChannelSecurityEntry)
}
entry := doc.security.ChannelList["pico"]
if entry.Settings == nil {
entry.Settings = &picoclawChannelSecuritySettings{}
}
if strings.TrimSpace(entry.Settings.Token) == token {
return false, nil
}
entry.Settings.Token = token
doc.security.ChannelList["pico"] = entry
return true, nil
}
func picoSecurityToken(doc *picoclawConfigDocument) (string, error) {
if doc == nil {
return "", nil
}
// Check security channel_list.pico.settings.token
if entry, ok := doc.security.ChannelList["pico"]; ok {
if entry.Settings != nil {
if token := strings.TrimSpace(entry.Settings.Token); token != "" {
return token, nil
}
}
if token := strings.TrimSpace(entry.Token); token != "" {
return token, nil
}
}
// Fall back to config channel_list.pico.settings.token
if pico, ok := doc.config.Channels["pico"]; ok {
if token := strings.TrimSpace(pico.Settings.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,543 @@
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_"
opaqueSessionKeyPrefix = "sk_v1_"
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 isOpaqueSessionKey(key string) bool {
return strings.HasPrefix(key, opaqueSessionKeyPrefix)
}
func extractOpaqueSessionID(fileName string) (string, bool) {
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
if strings.HasSuffix(fileName, ".meta.json") {
base = strings.TrimSuffix(fileName, ".meta.json")
}
if isOpaqueSessionKey(base) {
return base, 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"):
baseName := strings.TrimSuffix(name, ".jsonl")
if opaqueID, found := extractOpaqueSessionID(name); found {
sessionID = opaqueID
sess, loadErr = readOpaqueJSONLSession(dir, sessionID)
} else {
sessionID, ok = extractPicoSessionIDFromSanitizedKey(baseName)
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
}
var sess sessionStoredFile
if isOpaqueSessionKey(sessionID) {
sess, err = readOpaqueJSONLSession(dir, sessionID)
} else {
sess, err = readJSONLSession(dir, sessionID)
}
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
if err != nil {
if errors.Is(err, os.ErrNotExist) && !isOpaqueSessionKey(sessionID) {
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
}
var base string
if isOpaqueSessionKey(sessionID) {
base = filepath.Join(dir, sessionID)
} else {
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 readOpaqueJSONLSession(dir, opaqueKey string) (sessionStoredFile, error) {
base := filepath.Join(dir, opaqueKey)
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
meta, err := readSessionMeta(metaPath, opaqueKey)
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 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

@@ -0,0 +1,167 @@
package stream
import (
"sort"
"sync"
"time"
)
const (
CaptureStatusEvent = "capture-status"
CaptureModeDirect = "direct"
CaptureModeH264 = "h264"
CaptureModeMJPEG = "mjpeg"
CaptureSeverityError = "error"
CaptureSeverityWarning = "warning"
)
type CaptureStatus struct {
Ok bool `json:"ok"`
Result int `json:"result"`
Message string `json:"message"`
Mode string `json:"mode"`
Severity string `json:"severity"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CaptureStatusSubscriber func(CaptureStatus)
var defaultStore = newStore(time.Now)
func SubscribeCaptureStatus(subscriber CaptureStatusSubscriber) func() {
return defaultStore.Subscribe(subscriber)
}
func UpdateCaptureStatus(mode string, result int) {
defaultStore.UpdateCaptureStatus(mode, result)
}
func LatestCaptureStatuses() []CaptureStatus {
return defaultStore.LatestCaptureStatuses()
}
type store struct {
mutex sync.Mutex
latestByMode map[string]CaptureStatus
now func() time.Time
subscribers map[int]CaptureStatusSubscriber
nextSubscriberID int
}
func newStore(now func() time.Time) *store {
return &store{
now: now,
latestByMode: make(map[string]CaptureStatus),
subscribers: make(map[int]CaptureStatusSubscriber),
}
}
func (s *store) Subscribe(subscriber CaptureStatusSubscriber) func() {
s.mutex.Lock()
id := s.nextSubscriberID
s.nextSubscriberID++
s.subscribers[id] = subscriber
s.mutex.Unlock()
return func() {
s.mutex.Lock()
delete(s.subscribers, id)
s.mutex.Unlock()
}
}
func (s *store) UpdateCaptureStatus(mode string, result int) {
next := newCaptureStatus(mode, result, s.now())
subscribers := s.update(next)
for _, subscriber := range subscribers {
subscriber(next)
}
}
func (s *store) update(next CaptureStatus) []CaptureStatusSubscriber {
s.mutex.Lock()
defer s.mutex.Unlock()
last, ok := s.latestByMode[next.Mode]
if ok && samePublicStatus(last, next) {
return nil
}
s.latestByMode[next.Mode] = next
subscribers := make([]CaptureStatusSubscriber, 0, len(s.subscribers))
for _, subscriber := range s.subscribers {
subscribers = append(subscribers, subscriber)
}
return subscribers
}
func (s *store) LatestCaptureStatus(mode string) (CaptureStatus, bool) {
s.mutex.Lock()
defer s.mutex.Unlock()
status, ok := s.latestByMode[mode]
return status, ok
}
func (s *store) LatestCaptureStatuses() []CaptureStatus {
s.mutex.Lock()
defer s.mutex.Unlock()
modes := make([]string, 0, len(s.latestByMode))
for mode := range s.latestByMode {
modes = append(modes, mode)
}
sort.Strings(modes)
statuses := make([]CaptureStatus, 0, len(modes))
for _, mode := range modes {
statuses = append(statuses, s.latestByMode[mode])
}
return statuses
}
func samePublicStatus(a CaptureStatus, b CaptureStatus) bool {
if a.Ok && b.Ok {
return true
}
return a.Ok == b.Ok && a.Result == b.Result && a.Mode == b.Mode
}
func newCaptureStatus(mode string, result int, updatedAt time.Time) CaptureStatus {
message, severity := captureResultMessage(result)
return CaptureStatus{
Ok: result >= 0,
Result: result,
Message: message,
Mode: mode,
Severity: severity,
UpdatedAt: updatedAt,
}
}
func captureResultMessage(result int) (string, string) {
switch result {
case -7:
return "HDMI input resolution error", CaptureSeverityError
case -6:
return "Unsupported HDMI resolution", CaptureSeverityError
case -5:
return "Retrieving image", CaptureSeverityWarning
case -4:
return "Changing image resolution", CaptureSeverityWarning
case -3:
return "Image buffer full", CaptureSeverityError
case -2:
return "Encoder error", CaptureSeverityError
case -1:
return "No image captured", CaptureSeverityError
default:
if result < 0 {
return "Capture failed", CaptureSeverityError
}
return "", ""
}
}

View File

@@ -12,6 +12,7 @@ import (
var (
streamer = newStreamer()
upgrader = websocket.Upgrader{
WriteBufferSize: 256 * 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
@@ -36,7 +37,7 @@ func Connect(c *gin.Context) {
defer streamer.removeClient(ws)
for {
if _, _, err := ws.ReadMessage(); err != nil {
if _, _, err := ws.NextReader(); err != nil {
log.Debugf("failed to read message (client disconnected): %s", err)
return
}

View File

@@ -14,20 +14,25 @@ import (
)
type Streamer struct {
mutex sync.RWMutex
clients map[*websocket.Conn]bool
running int32
mutex sync.Mutex
clients map[*websocket.Conn]bool
clientSnapshot atomic.Pointer[[]*websocket.Conn]
running int32
}
func newStreamer() *Streamer {
return &Streamer{
s := &Streamer{
clients: make(map[*websocket.Conn]bool),
}
s.updateClientSnapshotLocked()
return s
}
func (s *Streamer) addClient(ws *websocket.Conn) {
s.mutex.Lock()
s.clients[ws] = true
s.updateClientSnapshotLocked()
s.mutex.Unlock()
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
@@ -39,36 +44,53 @@ func (s *Streamer) addClient(ws *websocket.Conn) {
func (s *Streamer) removeClient(ws *websocket.Conn) {
s.mutex.Lock()
delete(s.clients, ws)
count := s.updateClientSnapshotLocked()
s.mutex.Unlock()
log.Debugf("h264 websocket disconnected, remaining clients: %d", len(s.clients))
log.Debugf("h264 websocket disconnected, remaining clients: %d", count)
}
func (s *Streamer) getClientCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
func (s *Streamer) updateClientSnapshotLocked() int {
clients := make([]*websocket.Conn, 0, len(s.clients))
for client := range s.clients {
clients = append(clients, client)
}
s.clientSnapshot.Store(&clients)
return len(s.clients)
return len(clients)
}
func (s *Streamer) getClients() []*websocket.Conn {
clients := s.clientSnapshot.Load()
if clients == nil {
return nil
}
return *clients
}
func (s *Streamer) run() {
defer atomic.StoreInt32(&s.running, 0)
duration := time.Second / time.Duration(120)
ticker := time.NewTicker(duration)
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
screen := common.GetScreen()
vision := common.GetKvmVision()
startTime := time.Now()
for range ticker.C {
if s.getClientCount() == 0 {
clients := s.getClients()
if len(clients) == 0 {
log.Debug("h264 stream stopped due to no clients")
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
stream.UpdateCaptureStatus(stream.CaptureModeDirect, result)
if result < 0 || len(data) == 0 {
continue
}
@@ -80,28 +102,34 @@ func (s *Streamer) run() {
timestamp := time.Since(startTime).Microseconds()
if err := s.send(isKeyFrame, timestamp, data); err != nil {
if err := s.send(clients, isKeyFrame, timestamp, data); err != nil {
continue
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
stream.GetFrameRateCounter().Update()
}
}
func (s *Streamer) send(isKeyFrame byte, timestamp int64, data []byte) error {
func (s *Streamer) send(clients []*websocket.Conn, isKeyFrame byte, timestamp int64, data []byte) error {
buf := BufferPool.Get().(*bytes.Buffer)
defer BufferPool.Put(buf)
buf.Reset()
buf.Grow(1 + 8 + len(data))
if err := buf.WriteByte(isKeyFrame); err != nil {
log.Errorf("failed to write keyframe flag: %s", err)
return err
}
tsBytes := make([]byte, 8)
binary.LittleEndian.PutUint64(tsBytes, uint64(timestamp))
if _, err := buf.Write(tsBytes); err != nil {
var tsBytes [8]byte
binary.LittleEndian.PutUint64(tsBytes[:], uint64(timestamp))
if _, err := buf.Write(tsBytes[:]); err != nil {
log.Errorf("failed to write timestamp: %s", err)
return err
}
@@ -111,7 +139,7 @@ func (s *Streamer) send(isKeyFrame byte, timestamp int64, data []byte) error {
return err
}
for client := range s.clients {
for _, client := range clients {
if err := client.WriteMessage(websocket.BinaryMessage, buf.Bytes()); err != nil {
log.Errorf("failed to write message to client %s: %s.", client.RemoteAddr(), err)

View File

@@ -16,9 +16,8 @@ var (
)
type FrameRateCounter struct {
frameCount int32
fps int32
mutex sync.Mutex
frameCount atomic.Int32
fps atomic.Int32
}
func GetFrameRateCounter() *FrameRateCounter {
@@ -30,16 +29,10 @@ func GetFrameRateCounter() *FrameRateCounter {
defer ticker.Stop()
for range ticker.C {
counter.mutex.Lock()
currentCount := counter.frameCount.Swap(0)
counter.fps.Store(currentCount / 3)
currentCount := atomic.LoadInt32(&counter.frameCount)
counter.fps = currentCount / 3
atomic.StoreInt32(&counter.frameCount, 0)
counter.mutex.Unlock()
data := fmt.Sprintf("%d", counter.fps)
data := fmt.Sprintf("%d", counter.fps.Load())
err := os.WriteFile("/kvmapp/kvm/now_fps", []byte(data), 0o666)
if err != nil {
log.Errorf("failed to write fps: %s", err)
@@ -52,12 +45,9 @@ func GetFrameRateCounter() *FrameRateCounter {
}
func (f *FrameRateCounter) Update() {
atomic.AddInt32(&f.frameCount, 1)
f.frameCount.Add(1)
}
func (f *FrameRateCounter) GetFPS() int32 {
f.mutex.Lock()
defer f.mutex.Unlock()
return f.fps
return f.fps.Load()
}

View File

@@ -1,167 +0,0 @@
package h264
import (
"encoding/json"
"sync"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
type Client struct {
ws *websocket.Conn
pc *webrtc.PeerConnection
mutex sync.Mutex
}
type Message struct {
Event string `json:"event"`
Data string `json:"data"`
}
// add video track
func (c *Client) addTrack() {
videoTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
"video",
"pion",
)
if err != nil {
log.Errorf("failed to create video track: %s", err)
return
}
_, err = c.pc.AddTrack(videoTrack)
if err != nil {
log.Errorf("failed to add video track: %s", err)
return
}
trackMap[c.ws] = videoTrack
}
// register callback events
func (c *Client) register() {
// new ICE candidate found
c.pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
return
}
candidateByte, err := json.Marshal(candidate.ToJSON())
if err != nil {
log.Errorf("failed to marshal candidate: %s", err)
return
}
_ = c.sendMessage("candidate", string(candidateByte))
})
// ICE connection state has changed
c.pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
if state == webrtc.ICEConnectionStateConnected && !isSending {
// start sending h264 data
go send()
isSending = true
}
log.Debugf("ice connection state has changed to %s", state.String())
})
}
// read websocket message
func (c *Client) readMessage() {
message := &Message{}
for {
_, raw, err := c.ws.ReadMessage()
if err != nil {
delete(trackMap, c.ws)
if isSending && len(trackMap) == 0 {
// stop sending when all websocket connections are closed
isSending = false
}
log.Debugf("failed to read message: %s", err)
return
}
if err := json.Unmarshal(raw, &message); err != nil {
log.Errorf("failed to unmarshal message: %s", err)
continue
}
log.Debugf("receive message event: %s", message.Event)
switch message.Event {
case "offer":
offer := webrtc.SessionDescription{}
if err := json.Unmarshal([]byte(message.Data), &offer); err != nil {
log.Errorf("failed to unmarshal offer message: %s", err)
return
}
if err := c.pc.SetRemoteDescription(offer); err != nil {
log.Errorf("failed to set remote description: %s", err)
return
}
answer, answerErr := c.pc.CreateAnswer(nil)
if answerErr != nil {
log.Errorf("failed to create answer: %s", answerErr)
return
}
if err := c.pc.SetLocalDescription(answer); err != nil {
log.Errorf("failed to set local description: %s", err)
return
}
answerByte, answerByteErr := json.Marshal(answer)
if answerByteErr != nil {
log.Errorf("failed to marshal answer: %s", answerByteErr)
return
}
_ = c.sendMessage("answer", string(answerByte))
case "candidate":
candidate := webrtc.ICECandidateInit{}
if err := json.Unmarshal([]byte(message.Data), &candidate); err != nil {
log.Errorf("failed to unmarshal candidate message: %s", err)
return
}
if err := c.pc.AddICECandidate(candidate); err != nil {
log.Errorf("failed to add ICE candidate: %s", err)
return
}
case "heartbeat":
_ = c.sendMessage("heartbeat", "")
default:
log.Debugf("unhandled message event: %s", message.Event)
}
}
}
// send websocket message
func (c *Client) sendMessage(event string, data string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
message := &Message{
Event: event,
Data: data,
}
if err := c.ws.WriteJSON(message); err != nil {
log.Errorf("failed to send message %s: %s", event, err)
return err
}
log.Debugf("send message %s", message.Event)
return nil
}

View File

@@ -1,80 +0,0 @@
package h264
import (
"NanoKVM-Server/config"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
var (
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
trackMap = make(map[*websocket.Conn]*webrtc.TrackLocalStaticSample)
isSending = false
)
func Connect(c *gin.Context) {
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("failed to create websocket: %s", err)
return
}
defer func() {
_ = wsConn.Close()
log.Debugf("h264 websocket disconnected")
}()
var zeroTime time.Time
_ = wsConn.SetReadDeadline(zeroTime)
conf := config.GetInstance()
var iceServers []webrtc.ICEServer
if conf.Stun != "" && conf.Stun != "disable" {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{"stun:" + conf.Stun},
})
}
if conf.Turn.TurnAddr != "" && conf.Turn.TurnUser != "" && conf.Turn.TurnCred != "" {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{"turn:" + conf.Turn.TurnAddr},
Username: conf.Turn.TurnUser,
Credential: conf.Turn.TurnCred,
})
}
peerConn, err := webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: iceServers,
})
if err != nil {
log.Errorf("failed to create PeerConnection: %s", err)
return
}
defer func() {
_ = peerConn.Close()
log.Debugf("PeerConnection disconnected")
}()
client := &Client{
ws: wsConn,
pc: peerConn,
mutex: sync.Mutex{},
}
client.addTrack()
client.register()
client.readMessage()
}

View File

@@ -1,49 +0,0 @@
package h264
import (
"NanoKVM-Server/common"
"time"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
func send() {
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
duration := time.Second / time.Duration(fps)
ticker := time.NewTicker(duration)
defer ticker.Stop()
vision := common.GetKvmVision()
for range ticker.C {
if !isSending && len(trackMap) == 0 {
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 || len(data) == 0 {
continue
}
sample := media.Sample{
Data: data,
Duration: duration,
}
for _, track := range trackMap {
if err := track.WriteSample(sample); err != nil {
log.Errorf("failed to send h264 data: %s", err)
}
}
if screen.FPS != fps {
fps = screen.FPS
duration = time.Second / time.Duration(fps)
ticker.Reset(duration)
}
}
}

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

@@ -13,21 +13,31 @@ import (
log "github.com/sirupsen/logrus"
)
var crlf = []byte("\r\n")
type Streamer struct {
mutex sync.RWMutex
clients map[*gin.Context]bool
running int32
mutex sync.Mutex
clients map[*gin.Context]bool
clientSnapshot atomic.Pointer[[]*gin.Context]
running int32
frameMutex sync.RWMutex
latestFrame LatestFrame
cacheRefs int32
}
func NewStreamer() *Streamer {
return &Streamer{
s := &Streamer{
clients: make(map[*gin.Context]bool),
}
s.updateClientSnapshotLocked()
return s
}
func (s *Streamer) AddClient(c *gin.Context) {
s.mutex.Lock()
s.clients[c] = true
s.updateClientSnapshotLocked()
s.mutex.Unlock()
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
@@ -39,28 +49,29 @@ func (s *Streamer) AddClient(c *gin.Context) {
func (s *Streamer) RemoveClient(c *gin.Context) {
s.mutex.Lock()
delete(s.clients, c)
count := s.updateClientSnapshotLocked()
s.mutex.Unlock()
log.Debugf("mjpeg connection removed, remaining clients: %d", len(s.clients))
log.Debugf("mjpeg connection removed, remaining clients: %d", count)
}
func (s *Streamer) getClients() []*gin.Context {
s.mutex.RLock()
defer s.mutex.RUnlock()
func (s *Streamer) updateClientSnapshotLocked() int {
clients := make([]*gin.Context, 0, len(s.clients))
for c := range s.clients {
clients = append(clients, c)
}
s.clientSnapshot.Store(&clients)
return clients
return len(clients)
}
func (s *Streamer) getClientCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
func (s *Streamer) getClients() []*gin.Context {
clients := s.clientSnapshot.Load()
if clients == nil {
return nil
}
return len(s.clients)
return *clients
}
func (s *Streamer) run() {
@@ -76,17 +87,22 @@ func (s *Streamer) run() {
defer ticker.Stop()
for range ticker.C {
if s.getClientCount() == 0 {
clients := s.getClients()
if len(clients) == 0 {
log.Debug("mjpeg stream stopped due to no clients")
return
}
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
stream.UpdateCaptureStatus(stream.CaptureModeMJPEG, result)
if result < 0 || result == 5 || len(data) == 0 {
continue
}
clients := s.getClients()
if s.frameCacheEnabled() {
s.setLatestFrame(data, screen.Width, screen.Height)
}
for _, client := range clients {
if err := writeFrame(client, data); err != nil {
log.Errorf("failed to write mjpeg frame for client %s: %s", client.Request.RemoteAddr, err)
@@ -103,6 +119,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 {
@@ -122,7 +203,7 @@ func writeFrame(c *gin.Context, data []byte) (err error) {
return err
}
if _, err = c.Writer.Write([]byte("\r\n")); err != nil {
if _, err = c.Writer.Write(crlf); err != nil {
return err
}

View File

@@ -21,6 +21,20 @@ func NewClient(ws *websocket.Conn, videoConn *webrtc.PeerConnection) *Client {
}
}
func (c *Client) Close() {
if c.video != nil {
if err := c.video.Close(); err != nil {
log.Debugf("failed to close video peer connection: %s", err)
}
}
if c.ws != nil {
if err := c.ws.Close(); err != nil {
log.Debugf("failed to close websocket: %s", err)
}
}
}
func (c *Client) WriteMessage(event string, data string) error {
c.mutex.Lock()
defer c.mutex.Unlock()

View File

@@ -2,6 +2,7 @@ package webrtc
import (
"NanoKVM-Server/config"
"encoding/json"
"net/http"
"sync"
"time"
@@ -15,6 +16,7 @@ import (
var (
upgrader = websocket.Upgrader{
WriteBufferSize: 256 * 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
@@ -79,6 +81,10 @@ func Connect(c *gin.Context) {
// handle signaling
signalingHandler := NewSignalingHandler(client)
signalingHandler.RegisterCallbacks()
if err := sendICEServers(client, iceServers); err != nil {
log.Errorf("failed to send ICE servers: %s", err)
return
}
// read and wait
for {
@@ -116,6 +122,30 @@ func createICEServers() []webrtc.ICEServer {
return iceServers
}
type clientICEServer struct {
URLs []string `json:"urls"`
Username string `json:"username,omitempty"`
Credential interface{} `json:"credential,omitempty"`
}
func sendICEServers(client *Client, iceServers []webrtc.ICEServer) error {
clientServers := make([]clientICEServer, 0, len(iceServers))
for _, server := range iceServers {
clientServers = append(clientServers, clientICEServer{
URLs: server.URLs,
Username: server.Username,
Credential: server.Credential,
})
}
data, err := json.Marshal(clientServers)
if err != nil {
return err
}
return client.WriteMessage("ice-servers", string(data))
}
func createMediaEngine() (*webrtc.MediaEngine, error) {
mediaEngine := &webrtc.MediaEngine{}

View File

@@ -3,7 +3,6 @@ package webrtc
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"sync"
"sync/atomic"
"time"
@@ -13,11 +12,13 @@ import (
)
func NewWebRTCManager() *WebRTCManager {
return &WebRTCManager{
m := &WebRTCManager{
clients: make(map[*websocket.Conn]*Client),
videoSending: 0,
mutex: sync.RWMutex{},
}
m.updateClientSnapshotLocked()
return m
}
func (m *WebRTCManager) AddClient(ws *websocket.Conn, client *Client) {
@@ -25,24 +26,42 @@ func (m *WebRTCManager) AddClient(ws *websocket.Conn, client *Client) {
m.mutex.Lock()
m.clients[ws] = client
count := m.updateClientSnapshotLocked()
m.mutex.Unlock()
log.Debugf("added client %s, total clients: %d", ws.RemoteAddr(), len(m.clients))
log.Debugf("added client %s, total clients: %d", ws.RemoteAddr(), count)
}
func (m *WebRTCManager) RemoveClient(ws *websocket.Conn) {
m.mutex.Lock()
delete(m.clients, ws)
count := m.updateClientSnapshotLocked()
m.mutex.Unlock()
log.Debugf("removed client %s, total clients: %d", ws.RemoteAddr(), len(m.clients))
log.Debugf("removed client %s, total clients: %d", ws.RemoteAddr(), count)
}
func (m *WebRTCManager) GetClientCount() int {
m.mutex.RLock()
defer m.mutex.RUnlock()
return len(m.getClients())
}
return len(m.clients)
func (m *WebRTCManager) updateClientSnapshotLocked() int {
clients := make([]*Client, 0, len(m.clients))
for _, client := range m.clients {
clients = append(clients, client)
}
m.clientSnapshot.Store(&clients)
return len(clients)
}
func (m *WebRTCManager) getClients() []*Client {
clients := m.clientSnapshot.Load()
if clients == nil {
return nil
}
return *clients
}
func (m *WebRTCManager) StartVideoStream() {
@@ -66,12 +85,14 @@ func (m *WebRTCManager) sendVideoStream() {
defer ticker.Stop()
for range ticker.C {
if m.GetClientCount() == 0 {
clients := m.getClients()
if len(clients) == 0 {
log.Debugf("stop sending h264 stream")
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
stream.UpdateCaptureStatus(stream.CaptureModeH264, result)
if result < 0 || len(data) == 0 {
continue
}
@@ -81,8 +102,12 @@ func (m *WebRTCManager) sendVideoStream() {
Duration: duration,
}
for _, client := range m.clients {
client.track.writeVideo(sample)
for _, client := range clients {
if err := client.track.writeVideoSample(sample); err != nil {
log.Errorf("failed to write h264 video to client: %s", err)
m.RemoveClient(client.WsConn())
client.Close()
}
}
if screen.FPS != fps && screen.FPS != 0 {

View File

@@ -11,7 +11,7 @@ func (t *Track) updateExtension() {
t.playoutDelayExtensionID = 5
}
if t.playoutDelayExtensionData == nil || len(t.playoutDelayExtensionData) == 0 {
if len(t.playoutDelayExtensionData) == 0 {
playoutDelay := &rtp.PlayoutDelayExtension{
MinDelay: 0,
MaxDelay: 0,
@@ -44,10 +44,3 @@ func (t *Track) writeVideoSample(sample media.Sample) error {
return nil
}
func (t *Track) writeVideo(sample media.Sample) {
err := t.writeVideoSample(sample)
if err != nil {
log.Errorf("failed to write h264 video: %s", err)
}
}

View File

@@ -2,6 +2,7 @@ package webrtc
import (
"sync"
"sync/atomic"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
@@ -9,9 +10,10 @@ import (
)
type WebRTCManager struct {
clients map[*websocket.Conn]*Client
videoSending int32
mutex sync.RWMutex
clients map[*websocket.Conn]*Client
clientSnapshot atomic.Pointer[[]*Client]
videoSending int32
mutex sync.Mutex
}
type Client struct {
@@ -21,6 +23,10 @@ type Client struct {
mutex sync.Mutex
}
func (c *Client) WsConn() *websocket.Conn {
return c.ws
}
type SignalingHandler struct {
client *Client
}

View File

@@ -0,0 +1,111 @@
package ws
import (
"encoding/json"
"sort"
"sync"
"NanoKVM-Server/service/stream"
log "github.com/sirupsen/logrus"
)
var captureBroadcaster = newCaptureStatusBroadcaster(broadcastCaptureStatus)
func init() {
go captureBroadcaster.Run()
stream.SubscribeCaptureStatus(func(status stream.CaptureStatus) {
captureBroadcaster.Enqueue(status)
})
}
type captureStatusBroadcaster struct {
mutex sync.Mutex
pending map[string]stream.CaptureStatus
notify chan struct{}
broadcast func(stream.CaptureStatus)
}
func newCaptureStatusBroadcaster(broadcast func(stream.CaptureStatus)) *captureStatusBroadcaster {
return &captureStatusBroadcaster{
pending: make(map[string]stream.CaptureStatus),
notify: make(chan struct{}, 1),
broadcast: broadcast,
}
}
func (b *captureStatusBroadcaster) Enqueue(status stream.CaptureStatus) {
b.mutex.Lock()
b.pending[status.Mode] = status
b.mutex.Unlock()
select {
case b.notify <- struct{}{}:
default:
}
}
func (b *captureStatusBroadcaster) Run() {
for range b.notify {
for {
statuses := b.takePending()
if len(statuses) == 0 {
break
}
for _, status := range statuses {
b.broadcast(status)
}
}
}
}
func (b *captureStatusBroadcaster) takePending() []stream.CaptureStatus {
b.mutex.Lock()
defer b.mutex.Unlock()
if len(b.pending) == 0 {
return nil
}
modes := make([]string, 0, len(b.pending))
for mode := range b.pending {
modes = append(modes, mode)
}
sort.Strings(modes)
statuses := make([]stream.CaptureStatus, 0, len(modes))
for _, mode := range modes {
statuses = append(statuses, b.pending[mode])
delete(b.pending, mode)
}
return statuses
}
func sendCaptureStatusSnapshot(client *Client) {
for _, status := range stream.LatestCaptureStatuses() {
if err := sendCaptureStatus(client, status); err != nil {
log.Errorf("failed to send capture status snapshot: %s", err)
}
}
}
func broadcastCaptureStatus(status stream.CaptureStatus) {
for _, client := range GetManager().GetClients() {
if err := sendCaptureStatus(client, status); err != nil {
log.Errorf("failed to send capture status: %s", err)
}
}
}
func sendCaptureStatus(client *Client, status stream.CaptureStatus) error {
payload, err := json.Marshal(status)
if err != nil {
log.Errorf("failed to marshal capture status: %s", err)
return err
}
return client.Write(stream.CaptureStatusEvent, string(payload))
}

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

@@ -37,5 +37,7 @@ func (s *Service) Connect(c *gin.Context) {
manager.AddClient(ws, client)
defer manager.RemoveClient(ws)
sendCaptureStatusSnapshot(client)
client.Start()
}

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

@@ -222,7 +222,7 @@ uint8_t get_vi_state()
uint8_t VIWHGTLSCnt[4];
FILE* fp = popen( cmd, "r" );
uint8_t ret = 0;
if (fgets(VI_State, sizeof(VI_State), fp) != NULL){
FPS[0] = atoi(VI_State);
// debug("VIDevFPS = %d\n", FPS[0]);
@@ -318,7 +318,7 @@ uint8_t watchdog_sf_is_open(void)
else return 0;
}
int vision_update_watchdog()
int vision_update_watchdog()
{
FILE *file;
@@ -344,7 +344,7 @@ int get_manual_resolution(void)
if(access("/kvmapp/kvm/width", F_OK) == 0){
fp = fopen("/kvmapp/kvm/width", "r");
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
file_size = ftell(fp);
fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), file_size, fp);
fclose(fp);
@@ -356,7 +356,7 @@ int get_manual_resolution(void)
if(access("/kvmapp/kvm/height", F_OK) == 0){
fp = fopen("/kvmapp/kvm/height", "r");
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
file_size = ftell(fp);
fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), file_size, fp);
fclose(fp);
@@ -495,15 +495,20 @@ uint8_t chack_ion()
void lt6911_enable()
{
uint8_t buf[2];
if(kvmv_cfg.hdmi_version == 3){
return;
}
buf[0] = 0xff;
buf[1] = 0x80;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
buf[0] = 0xee;
buf[1] = 0x01;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
if(kvmv_cfg.hdmi_version != 0){
if(kvmv_cfg.hdmi_version == 1){
// disable watchdog
buf[0] = 0x10;
buf[1] = 0x00;
@@ -514,10 +519,15 @@ void lt6911_enable()
void lt6911_disable()
{
uint8_t buf[2];
if(kvmv_cfg.hdmi_version == 3){
return;
}
buf[0] = 0xff;
buf[1] = 0x80;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
buf[0] = 0xee;
buf[1] = 0x00;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
@@ -629,6 +639,38 @@ uint8_t lt6911_get_hdmi_res()
if(revbuf[0] == 0x55) return 1;
else if(revbuf[0] == 0x88) return 0;
else return 0;
} else if (kvmv_cfg.hdmi_version == 3){
// LT6911D
buf[0] = 0xff;
buf[1] = 0xe0;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
// Hactive
buf[0] = 0x8c;
LT6911_i2c.writeto(LT6911_ADDR, buf, 1);
maix::Bytes *dat0 = LT6911_i2c.readfrom(LT6911_ADDR, 2);
// Vactive
buf[0] = 0x8e;
LT6911_i2c.writeto(LT6911_ADDR, buf, 1);
maix::Bytes *dat1 = LT6911_i2c.readfrom(LT6911_ADDR, 2);
revbuf[0] = (uint8_t)dat0->data[0];
revbuf[1] = (uint8_t)dat0->data[1];
revbuf[2] = (uint8_t)dat1->data[0];
revbuf[3] = (uint8_t)dat1->data[1];
Hactive = ((revbuf[0] << 8)|revbuf[1]) * 2;
Vactive = (revbuf[2] << 8)|revbuf[3];
debug("[hdmi]HDMI-D res modification event\n");
debug("[hdmi]new res: %d * %d\n", Hactive, Vactive);
delete dat0;
delete dat1;
if(Hactive != 0 || Vactive != 0) return 1;
else return 0;
} else {
return 0;
}
@@ -686,7 +728,7 @@ uint8_t lt6911_get_csi_res(uint16_t *p_width, uint16_t *p_height)
uint16_t Hactive;
if(kvmv_cfg.hdmi_version == 0){
// LT6911C
// LT6911C
buf[0] = 0xff;
buf[1] = 0xc2;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
@@ -705,7 +747,7 @@ uint8_t lt6911_get_csi_res(uint16_t *p_width, uint16_t *p_height)
revbuf[1] = (uint8_t)dat0->data[1];
revbuf[2] = (uint8_t)dat1->data[0];
revbuf[3] = (uint8_t)dat1->data[1];
delete dat0;
delete dat1;
@@ -738,6 +780,33 @@ uint8_t lt6911_get_csi_res(uint16_t *p_width, uint16_t *p_height)
Vactive = (revbuf[0] << 8)|revbuf[1];
Hactive = (revbuf[2] << 8)|revbuf[3];
} else if(kvmv_cfg.hdmi_version == 3) {
// LT6911D
debug("[hdmi]D get csi res\n");
buf[0] = 0xff;
buf[1] = 0xe0;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
// Vactive
buf[0] = 0x8e;
LT6911_i2c.writeto(LT6911_ADDR, buf, 1);
maix::Bytes *dat0 = LT6911_i2c.readfrom(LT6911_ADDR, 2);
// Hactive
buf[0] = 0x8c;
LT6911_i2c.writeto(LT6911_ADDR, buf, 1);
maix::Bytes *dat1 = LT6911_i2c.readfrom(LT6911_ADDR, 2);
revbuf[0] = (uint8_t)dat0->data[0];
revbuf[1] = (uint8_t)dat0->data[1];
revbuf[2] = (uint8_t)dat1->data[0];
revbuf[3] = (uint8_t)dat1->data[1];
delete dat0;
delete dat1;
Vactive = (revbuf[0] << 8)|revbuf[1];
Hactive = ((revbuf[2] << 8)|revbuf[3]) * 2;
} else {
return UNKNOWN_RES;
}
@@ -790,7 +859,7 @@ void lt6911_read_reg(uint8_t reg)
buf[0] = reg;
LT6911_i2c.writeto(LT6911_ADDR, buf, 1);
maix::Bytes *dat = LT6911_i2c.readfrom(LT6911_ADDR, 16);
maix::Bytes *dat = LT6911_i2c.readfrom(LT6911_ADDR, 16);
for(int i = 0; i < 16; i++){
buf[i] = (uint8_t)dat->data[i];
@@ -809,7 +878,7 @@ uint8_t lt6911_read_one_reg(uint8_t reg)
ret = reg;
LT6911_i2c.writeto(LT6911_ADDR, &ret, 1);
maix::Bytes *dat = LT6911_i2c.readfrom(LT6911_ADDR, 1);
maix::Bytes *dat = LT6911_i2c.readfrom(LT6911_ADDR, 1);
ret = (uint8_t)dat->data[0];
@@ -846,7 +915,7 @@ void lt6911_write_edid(void)
lt6911_write_reg(0x5E, 0xEF);
lt6911_write_reg(0x5A, 0xA2);
lt6911_write_reg(0x5A, 0x82);
lt6911_write_reg(0x58, 0x01);
if(i < 8) {
@@ -1048,6 +1117,10 @@ void get_hdmi_version()
kvmv_cfg.hdmi_version = 1;
debug("[hdmi]Incomplete version number, set to 'ux'\n");
}
} else if(RW_Data[0] == 'd'){
// 6911d
kvmv_cfg.hdmi_version = 3;
debug("[hdmi]HDMI-D exist!\n");
} else {
// 6911c
kvmv_cfg.hdmi_version = 0;
@@ -1085,7 +1158,7 @@ void* vi_subsystem_detection(void * arg)
{
if(kvmv_cfg.try_exit_thread == 1)
break;
uint8_t get_new_hdmi_mode = get_hdmi_mode();
uint8_t try_res;
uint8_t err_code;
@@ -1109,7 +1182,7 @@ void* vi_subsystem_detection(void * arg)
fp = fopen(hdmi_state_path, "r+");
if(fp != NULL){
// fseek(fp, 0, SEEK_END);
// file_size = ftell(fp);
// file_size = ftell(fp);
// fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), 2, fp);
tmp8 = atoi((char*)RW_Data);
@@ -1134,7 +1207,7 @@ void* vi_subsystem_detection(void * arg)
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
/* Move HDMI resolution modification directly
/* Move HDMI resolution modification directly
to mode1 to solve deadlock problem */
auto_change_mode = 1;
set_hdmi_mode(1);
@@ -1159,7 +1232,7 @@ void* vi_subsystem_detection(void * arg)
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
/* Move HDMI resolution modification directly
/* Move HDMI resolution modification directly
to mode1 to solve deadlock problem */
auto_change_mode = 1;
set_hdmi_mode(1);
@@ -1171,6 +1244,31 @@ void* vi_subsystem_detection(void * arg)
}
lt6911_disable();
}
} else if (kvmv_cfg.hdmi_version == 3){
// LT6911D
debug("[hdmi] D int\n");
if(falling_times != 0){
debug("[hdmi] D int && \n");
lt6911_enable();
if(lt6911_get_hdmi_res()){
// hdmi get res
debug("[hdmi] D HDMI cable insertion!\n");
kvmv_cfg.hdmi_cable_state = 1;
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
/* Move HDMI resolution modification directly
to mode1 to solve deadlock problem */
auto_change_mode = 1;
set_hdmi_mode(1);
}
} else {
// HDMI res = 0*0/x*0
debug("[hdmi] D HDMI cable unplugged!\n");
kvmv_cfg.hdmi_cable_state = 0;
}
lt6911_disable();
}
} else {
debug("[hdmi] Chip not supported for reading \n");
}
@@ -1180,15 +1278,15 @@ void* vi_subsystem_detection(void * arg)
fclose(fp);
}
break;
/* Mode 1 & 2 will disable API access to the image during detection,
/* Mode 1 & 2 will disable API access to the image during detection,
and will output -4: Modifying image resolution, please wait.*/
case 1: // Automatically trying common resolutions
/* kvmv_cfg.vi_detect_state :
/* kvmv_cfg.vi_detect_state :
* 0: HDMI standard mode, detection program does not interfere with the camera
* 1: Preparing / Testing in progress
* 2: Test completed: Suitable resolution found,
*/
if(get_new_hdmi_mode == 1){
kvmv_cfg.vi_detect_state = 1;
}
@@ -1199,9 +1297,9 @@ void* vi_subsystem_detection(void * arg)
kvmv_cfg.hdmi_try_rounds = 0;
kvmv_cfg.vi_detect_state = 2;
} else if (try_res == 0) {
/* There may be a situation where the correct resolution cannot be recognized
after one round of detection. By default, Try_rounds_HDMI_err_res rounds will be detected,
and if it cannot be detected, jump to the next mode */
/* There may be a situation where the correct resolution cannot be recognized
after one round of detection. By default, Try_rounds_HDMI_err_res rounds will be detected,
and if it cannot be detected, jump to the next mode */
kvmv_cfg.hdmi_res_err = ERROR_RES;
kvmv_cfg.hdmi_try_rounds++;
if(kvmv_cfg.hdmi_try_rounds >= Try_rounds_HDMI_err_res){
@@ -1226,7 +1324,7 @@ void* vi_subsystem_detection(void * arg)
if (err_code != 1) {
kvmv_cfg.vi_detect_state = 1;
}
time::sleep_ms(1000);
time::sleep_ms(1000);
} else {
kvmv_cfg.vi_detect_state = 1;
}
@@ -1280,7 +1378,7 @@ void* vi_subsystem_detection(void * arg)
}
}
break;
default:
debug("Non-existent hdmi state = %d\n", kvmv_cfg.hdmi_mode);
break;
@@ -1308,7 +1406,7 @@ int sync_vi_res()
} else {
fp = fopen(vi_width_path, "r");
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
file_size = ftell(fp);
fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), file_size, fp);
fclose(fp);
@@ -1328,7 +1426,7 @@ int sync_vi_res()
} else {
fp = fopen(vi_height_path, "r");
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
file_size = ftell(fp);
fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), file_size, fp);
fclose(fp);
@@ -1465,15 +1563,15 @@ int8_t raw_to_h264(image::Image *raw, kvmv_data_t* ret_stream, uint16_t _qlty)
// log::info("getimg: %d \r\n", (int)(time::time_ms()));
mmf_stream_t _stream = {0};
if(kvm_venc.enc_h264_init != 1 || raw->width() != kvm_venc.kvm_venc_cfg.w || raw->height() != kvm_venc.kvm_venc_cfg.h || _qlty != kvm_venc.kvm_venc_cfg.bitrate){
debug("[kvmv]init_venc_h264 enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
kvm_venc.enc_h264_init,
raw->width(), kvm_venc.kvm_venc_cfg.w,
debug("[kvmv]init_venc_h264 enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
kvm_venc.enc_h264_init,
raw->width(), kvm_venc.kvm_venc_cfg.w,
raw->height(), kvm_venc.kvm_venc_cfg.h);
init_venc_h264(raw->width(), raw->height(), _qlty);
debug("[kvmv]init_venc_h264 finish enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
kvm_venc.enc_h264_init,
raw->width(), kvm_venc.kvm_venc_cfg.w,
init_venc_h264(raw->width(), raw->height(), _qlty);
debug("[kvmv]init_venc_h264 finish enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
kvm_venc.enc_h264_init,
raw->width(), kvm_venc.kvm_venc_cfg.w,
raw->height(), kvm_venc.kvm_venc_cfg.h);
// if(kvm_venc.enc_h264_init == 1){
// init_venc_h264(raw->width(), raw->height(), _qlty);
@@ -1489,8 +1587,8 @@ int8_t raw_to_h264(image::Image *raw, kvmv_data_t* ret_stream, uint16_t _qlty)
debug("[kvmv]mmf venc push failed!\n");
// err::check_raise(err::ERR_RUNTIME, "mmf venc push failed!\r\n");
return -1;
}
// log::info("push(): %d \r\n", (int)(time::time_ms() - start_time));
}
// log::info("push(): %d \r\n", (int)(time::time_ms() - start_time));
if (mmf_venc_pop(kvm_venc.mmf_venc_chn, &_stream)) {
// log::error("mmf_venc_pop failed\n");
mmf_venc_free(kvm_venc.mmf_venc_chn);
@@ -1500,10 +1598,10 @@ int8_t raw_to_h264(image::Image *raw, kvmv_data_t* ret_stream, uint16_t _qlty)
// rtmp->unlock();
return -1;
}
// log::info("pop(): %d \r\n", (int)(time::time_ms() - start_time));
// log::info("pop(): %d \r\n", (int)(time::time_ms() - start_time));
ret = h264_stream_dump(ret_stream, &_stream);
mmf_venc_free(kvm_venc.mmf_venc_chn);
// log::info("dump(): %d \r\n", (int)(time::time_ms() - start_time));
// log::info("dump(): %d \r\n", (int)(time::time_ms() - start_time));
// debug("[kvmv]_stream.data[0][4] = %d;\n", _stream.data[0][4]);
debug("[kvmv]Frame size = %d;\n", ret_stream->img_data_size);
@@ -1539,7 +1637,7 @@ void kvmv_init(uint8_t _debug_info_en)
kvmv_cfg.try_exit_thread = 0;
// debug("[kvmv]kvmv_init - 2\r\n");
if(kvmv_cfg.thread_is_running == 1){
debug("[kvmv]thread is running!\r\n");
} else {
@@ -1656,14 +1754,14 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
// Set the output
kvmv_cfg.vpss_width = _width;
kvmv_cfg.vpss_height = _height;
cam->set_resolution(kvmv_cfg.vpss_width, kvmv_cfg.vpss_height);
kvmv_cfg.reinit_flag = 1;
}
}
//
if (kvmv_cfg.reinit_flag == 1) {
if (kvmv_cfg.reinit_flag == 1) {
cam->hmirror(1);
cam->vflip(1);
kvmv_cfg.reinit_flag = 0;
@@ -1671,7 +1769,7 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
// debug("[kvmv]befor read img: %d \r\n", (int)(time::time_ms() - start_time));
image::Image *img = cam->read();
// debug("[kvmv]read img: %d \r\n", (int)(time::time_ms() - start_time));
if(img != NULL){
// frame detect
if(_type == VENC_MJPEG && kvmv_cfg.frame_detact != 0){
@@ -1680,10 +1778,10 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
} else {
frame_undetact_count = kvmv_cfg.frame_detact;
}
if(frame_undetact_count == kvmv_cfg.frame_detact){
frame_undetact_count = 0;
if(frame_changed(img) == 0){
debug("[kvmv]frame not changed...\n");
kvmv_cfg.stream_stop = 1;
@@ -1694,7 +1792,7 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
kvmv_cfg.stream_stop = 0;
}
}
}
}
if(kvmv_cfg.cam_state == 0) {
kvmv_cfg.cam_state = 1;
kvmv_cfg.hdmi_cable_state = 1;
@@ -1741,7 +1839,7 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
*_pp_kvm_data = NULL;
pthread_mutex_unlock(&vi_mutex);
return IMG_BUFFER_FULL;
}
}
jpg_dump(p_kvmv_data, jpg);
delete jpg;
delete img;
@@ -1758,7 +1856,7 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
*_pp_kvm_data = NULL;
pthread_mutex_unlock(&vi_mutex);
return IMG_BUFFER_FULL;
}
}
// debug("[kvmv]get_save_buffer: %d \r\n", (int)(time::time_ms() - start_time));
ret = raw_to_h264(img, p_kvmv_data, maxmin_data(10000, 500, (int)_qlty));
// debug("[kvmv]venc raw_to_h264: %d \r\n", (int)(time::time_ms() - start_time));

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

View File

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

View File

@@ -17,7 +17,7 @@ uint8_t get_hdmi_version()
fread(RW_Data, sizeof(char), 2, fp);
fclose(fp);
if(RW_Data[0] == 'u'){
// 6911uxc
// 6911uxc / 6911uxe
if(RW_Data[1] == 'e'){
return 2;
} else if(RW_Data[1] == 'x') {
@@ -25,6 +25,9 @@ uint8_t get_hdmi_version()
} else {
return 1;
}
} else if(RW_Data[0] == 'd'){
// 6911d
return 3;
} else {
// 6911c
return 0;
@@ -139,6 +142,7 @@ void new_app_init(void)
fclose(fp);
if(RW_Data[0] == 'c') hdmi_ver = 1;
else if(RW_Data[0] == 'u') hdmi_ver = 2;
else if(RW_Data[0] == 'd') hdmi_ver = 2;
}
// system("/etc/init.d/S03usbdev stop_start");
@@ -195,4 +199,4 @@ void new_img_init(void)
{
build_complete_resolv();
system("rm /kvmapp/kvm_new_img");
}
}

View File

@@ -47,9 +47,9 @@ int check_edid(uint8_t *edid_data, uint16_t edid_size)
}
// Check EDID header
if (edid_data[0] != 0x00 || edid_data[1] != 0xFF ||
edid_data[2] != 0xFF || edid_data[3] != 0xFF ||
edid_data[4] != 0xFF || edid_data[5] != 0xFF ||
if (edid_data[0] != 0x00 || edid_data[1] != 0xFF ||
edid_data[2] != 0xFF || edid_data[3] != 0xFF ||
edid_data[4] != 0xFF || edid_data[5] != 0xFF ||
edid_data[6] != 0xFF || edid_data[7] != 0x00) {
fprintf(stderr, "EDID header is invalid\n");
return -1; // EDID header is invalid
@@ -64,7 +64,7 @@ int check_edid(uint8_t *edid_data, uint16_t edid_size)
if (checksum1 != edid_data[127]) {
// Checksum for first 128 bytes is incorrect
fprintf(stderr, "Checksum for first 128 bytes is incorrect\n");
return -1;
return -1;
}
// Second 128 Bytes checksum
@@ -95,7 +95,7 @@ void NanoKVM_PCIe_HDMI_Reset(void)
// =======================================================================================================
// I2C write function
int i2c_write_byte(uint8_t offset, uint8_t reg, uint8_t data)
int i2c_write_byte(uint8_t offset, uint8_t reg, uint8_t data)
{
// reg buffer
uint8_t reg_buf[2] = {0};
@@ -121,7 +121,7 @@ int i2c_write_byte(uint8_t offset, uint8_t reg, uint8_t data)
return 0;
}
int i2c_write_bytes(uint8_t offset, uint8_t reg, const uint8_t *data, size_t len)
int i2c_write_bytes(uint8_t offset, uint8_t reg, const uint8_t *data, size_t len)
{
// check len
if (len == 0) {
@@ -159,7 +159,7 @@ int i2c_write_bytes(uint8_t offset, uint8_t reg, const uint8_t *data, size_t len
}
// I2C read function
int i2c_read_byte(uint8_t offset, uint8_t reg, uint8_t *data)
int i2c_read_byte(uint8_t offset, uint8_t reg, uint8_t *data)
{
// reg buffer
uint8_t reg_buf[2] = {0};
@@ -191,7 +191,7 @@ int i2c_read_byte(uint8_t offset, uint8_t reg, uint8_t *data)
return 0;
}
int i2c_read_bytes(uint8_t offset, uint8_t reg, uint8_t *data, size_t len)
int i2c_read_bytes(uint8_t offset, uint8_t reg, uint8_t *data, size_t len)
{
// 检查数据长度
if (len == 0) {
@@ -259,7 +259,7 @@ int lt6911uxc_edid_write(uint8_t *edid_data, uint16_t edid_size)
uint8_t version_str[32] = {0};
fprintf(stdout, "Writing EDID....\n");
// Start
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xFF, 0x80) != 0) return -1;
lt6911_enable();
@@ -286,7 +286,7 @@ int lt6911uxc_edid_write(uint8_t *edid_data, uint16_t edid_size)
if (chip_data[0] != 0xEE) {
fprintf(stderr, "Unsupported chip version\n");
return -1;
}
}
if (i2c_write_byte(LT6911_SYS3_OFFSET, 0x08, 0xAE) != 0) return -1;
if (i2c_write_byte(LT6911_SYS3_OFFSET, 0x08, 0xEE) != 0) return -1;
// Write
@@ -373,7 +373,7 @@ int lt6911c_edid_write(uint8_t *edid_data, uint16_t edid_size)
uint8_t version_str[32] = {0};
fprintf(stdout, "Writing EDID....\n");
// Start
lt6911_enable();
if (i2c_read_bytes(LT6911_SYS4_OFFSET, 0x00, chip_data, 2) != 0) return -1;
@@ -487,9 +487,161 @@ int lt6911c_edid_read(uint8_t *edid_data, uint16_t edid_size)
return 0;
}
int lt6911d_str_read(uint8_t *str)
{
fprintf(stdout, "Reading LT6911D version data...\n");
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0xEE, 0x01) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x59, 0x50) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x21) != 0) return -1;
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xBF) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xFF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x04) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0x5F) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x20) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5B, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5C, 0x81) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5D, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_read_bytes(LT6911D_MANAGE_OFFSET, 0x5F, str, LT6911D_WR_SIZE) != 0) return -1;
usleep(10000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x08) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
return 0;
}
int lt6911d_edid_write(uint8_t *edid_data, uint16_t edid_size)
{
uint8_t i;
uint8_t wr_count = edid_size / LT6911D_WR_SIZE + 1;
uint8_t version_str[LT6911D_WR_SIZE] = {0};
if (lt6911d_str_read(version_str) != 0) {
fprintf(stderr, "Failed to read LT6911D version data\n");
return -1;
}
fprintf(stdout, "Writing EDID....\n");
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0xEE, 0x01) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x59, 0x50) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x21) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x04) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5B, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5C, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5D, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x01) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
usleep(600000);
for (i = 0; i < wr_count; i++) {
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xBF) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xFF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x04) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x20) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x21) != 0) return -1;
if (i != wr_count - 1) {
if (i2c_write_bytes(LT6911D_MANAGE_OFFSET, 0x59, edid_data + (LT6911D_WR_SIZE * i), LT6911D_WR_SIZE) != 0) return -1;
} else {
if (i2c_write_bytes(LT6911D_MANAGE_OFFSET, 0x59, version_str, LT6911D_WR_SIZE) != 0) return -1;
}
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5B, 0x00) != 0) return -1;
if (i != wr_count - 1) {
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5C, 0x80) != 0) return -1;
} else {
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5C, 0x81) != 0) return -1;
}
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5D, 0x00 + (LT6911D_WR_SIZE * i)) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(5000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
usleep(10000);
}
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x08) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
fprintf(stdout, "EDID write completed\n");
return 0;
}
int lt6911d_edid_read(uint8_t *edid_data, uint16_t edid_size)
{
uint8_t i;
uint8_t wr_count = edid_size / LT6911D_WR_SIZE;
fprintf(stdout, "Reading EDID...\n");
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0xEE, 0x01) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x59, 0x50) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x21) != 0) return -1;
for (i = 0; i < wr_count; i++) {
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xBF) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xFF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x04) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0x5F) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x20) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5B, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5C, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5D, 0x00 + (LT6911D_WR_SIZE * i)) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_read_bytes(LT6911D_MANAGE_OFFSET, 0x5F, edid_data + (LT6911D_WR_SIZE * i), LT6911D_WR_SIZE) != 0) return -1;
usleep(10000);
}
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x08) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
return 0;
}
// =======================================================================================================
int lt6911_edid_config(chip_version_t chip_version, uint8_t *edid_data, uint16_t edid_size)
int lt6911_edid_config(chip_version_t chip_version, uint8_t *edid_data, uint16_t edid_size)
{
if (chip_version == CHIP_UNKNOWN)
{
@@ -523,6 +675,12 @@ int lt6911_edid_config(chip_version_t chip_version, uint8_t *edid_data, uint16_t
close(client);
return -1;
}
} else if (chip_version == CHIP_LT6911D) {
if (lt6911d_edid_write(edid_data, edid_size) != 0) {
fprintf(stderr, "Failed to write EDID data to LT6911D\n");
close(client);
return -1;
}
} else {
fprintf(stderr, "Unknown chip version\n");
close(client);
@@ -545,6 +703,12 @@ int lt6911_edid_config(chip_version_t chip_version, uint8_t *edid_data, uint16_t
close(client);
return -1;
}
} else if (chip_version == CHIP_LT6911D) {
if (lt6911d_edid_read(edid_read_data, EDID_BUFFER_SIZE) != 0) {
fprintf(stderr, "Failed to read EDID data from LT6911D\n");
close(client);
return -1;
}
} else {
fprintf(stderr, "Unknown chip version\n");
close(client);
@@ -596,7 +760,7 @@ void print_warning(product_version_t product_version) {
printf("Please ensure you can physically disconnect its power,\n");
printf("NOT just remotely reboot it!!\n");
printf("==========================================================\n\n");
}
}
}
void print_success(product_version_t product_version) {
@@ -604,31 +768,31 @@ void print_success(product_version_t product_version) {
printf("✅ EDID update successful!\n");
if (product_version == PRODUCT_CUBE_A || product_version == PRODUCT_CUBE_B) {
printf("Please manually power cycle the device to apply changes.\n");
}
}
printf("=========================================================\n\n");
}
int get_user_confirmation() {
char input[256];
printf("Do you want to continue? (Y/N): \n");
while (1) {
if (fgets(input, sizeof(input), stdin) == NULL) {
printf("\nInput error. Exiting.\n");
return 0;
}
// 去除换行符
input[strcspn(input, "\n")] = '\0';
if (strlen(input) == 0) {
continue; // 空输入,重新提示
}
// 转换为小写方便比较
char choice = tolower(input[0]);
if (choice == 'y') {
return 1;
} else if (choice == 'n') {
@@ -656,7 +820,7 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "Please upgrade to the latest system\n");
return 1;
}
if (fgets(chip_version_str, sizeof(chip_version_str), file) == NULL) {
fprintf(stderr, "Failed to read chip version\n");
fclose(file);
@@ -669,23 +833,26 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "Please upgrade to the latest system\n");
return 1;
}
if (fgets(product_version_str, sizeof(product_version_str), file) == NULL) {
fprintf(stderr, "Failed to read product version\n");
fclose(file);
return 1;
}
fclose(file);
chip_version_str[strcspn(chip_version_str, "\n")] = '\0';
product_version_str[strcspn(product_version_str, "\n")] = '\0';
if (strcmp(chip_version_str, "c") == 0) {
fprintf(stdout, "Chip Version: LT6911C\n");
chip_version = CHIP_LT6911C;
} else if (strcmp(chip_version_str, "ux") == 0) {
fprintf(stdout, "Chip Version: LT6911UXC\n");
chip_version = CHIP_LT6911UXC;
} else if (strcmp(chip_version_str, "d") == 0) {
fprintf(stdout, "Chip Version: LT6911D\n");
chip_version = CHIP_LT6911D;
} else if (strcmp(chip_version_str, "ue") == 0) {
fprintf(stderr, "Chip Version Error: UE version's edid can't be updated\n");
return 1;
@@ -693,7 +860,7 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "Chip Version Error: Unknown version\n");
return 1;
}
if (strcmp(product_version_str, "alpha") == 0) {
fprintf(stdout, "Product Version: CUBE_A\n");
product_version = PRODUCT_CUBE_A;

View File

@@ -17,10 +17,13 @@
#define LT6911C_HDMI_INFO_OFFSET 0xD2 // LT6911C HDMI信息寄存器偏移地址
#define LT6911C_AUDIO_INFO_OFFSET 0xD1 // LT6911C 音频信息寄存器偏移地址
#define LT6911C_CSI_INFO_OFFSET 0xC2 // LT6911C CSI信息寄存器偏移地址
#define LT6911D_MANAGE_OFFSET 0xE0 // LT6911D 管理寄存器偏移地址
#define LT6911D_DATA_OFFSET 0xE1 // LT6911D 数据寄存器偏移地址
#define EDID_BUFFER_SIZE 256 // 最大支持的字节数
#define LT6911UXC_WR_SIZE 32 // LT6911UXC单次读写最大字节数
#define LT6911C_WR_SIZE 16 // LT6911C单次读写最大字节数
#define LT6911D_WR_SIZE 32 // LT6911D单次读写最大字节数
#define VERSION_PATH "/etc/kvm/hdmi_version"
#define PRODUCT_PATH "/etc/kvm/hw"
@@ -28,6 +31,7 @@
typedef enum {
CHIP_LT6911UXC = 0,
CHIP_LT6911C,
CHIP_LT6911D,
CHIP_UNKNOWN
} chip_version_t;
@@ -38,4 +42,4 @@ typedef enum {
PRODUCT_UNKNOWN,
} product_version_t;
#endif // NANOKVM_UPDATE_EDID_H
#endif // NANOKVM_UPDATE_EDID_H

View File

@@ -18,12 +18,12 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"antd": "^5.29.3",
"axios": "1.13.6",
"axios": "^1.15.2",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"i18next": "^23.16.8",
"jotai": "^2.18.0",
"js-cookie": "^3.0.5",
"js-cookie": "^3.0.7",
"lucide-react": "^0.562.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -31,16 +31,18 @@
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^14.1.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.2"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@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",
@@ -48,25 +50,26 @@
"@types/react-dom": "^18.3.7",
"@types/semver": "^7.7.1",
"@types/websocket": "^1.0.10",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"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",
"postcss": "^8.5.10",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "7.1.11",
"vite-tsconfig-paths": "^4.3.2"
"typescript-eslint": "^8.59.0",
"vite": "7.3.2",
"vite-tsconfig-paths": "^4.3.2",
"yaml": "^2.8.3"
},
"msw": {
"workerDirectory": [

1815
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
web/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,6 @@
allowBuilds:
bufferutil: true
es5-ext: true
esbuild: true
msw: true
utf-8-validate: true

View File

@@ -5,24 +5,23 @@
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.6.0'
const INTEGRITY_CHECKSUM = '07a8241b182f8a246a7cd39894799a9e'
const PACKAGE_VERSION = '2.12.10'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
self.addEventListener('install', function () {
addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
@@ -72,11 +71,6 @@ self.addEventListener('message', async function (event) {
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
@@ -94,69 +88,92 @@ self.addEventListener('message', async function (event) {
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (request.mode === 'navigate') {
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
async function handleRequest(event, requestId) {
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const responseClone = response.clone()
const serializedRequest = await serializeRequest(requestCloneForEvents)
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseClone.body,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
[responseClone.body],
)
})()
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
@@ -184,20 +201,39 @@ async function resolveMainClient(event) {
})
}
async function getResponse(event, client, requestId) {
const { request } = event
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = request.clone()
const requestClone = event.request.clone()
function passthrough() {
const headers = Object.fromEntries(requestClone.headers.entries())
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove internal MSW request header so the passthrough request
// complies with any potential CORS preflight checks on the server.
// Some servers forbid unknown request headers.
delete headers['x-msw-intention']
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
@@ -216,29 +252,18 @@ async function getResponse(event, client, requestId) {
}
// Notify the client that a request has been intercepted.
const requestBuffer = await request.arrayBuffer()
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: requestBuffer,
keepalive: request.keepalive,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[requestBuffer],
[serializedRequest.body],
)
switch (clientMessage.type) {
@@ -254,6 +279,12 @@ async function getResponse(event, client, requestId) {
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
@@ -266,14 +297,18 @@ function sendToClient(client, message, transferrables = []) {
resolve(event.data)
}
client.postMessage(
message,
[channel.port2].concat(transferrables.filter(Boolean)),
)
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
async function respondWithMock(response) {
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
@@ -291,3 +326,24 @@ async function respondWithMock(response) {
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

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 = {
@@ -69,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 async function closeGateway() {
const activeSessionId = picoclawGateway.getSessionId();
picoclawGateway.close();
if (!activeSessionId) {
return;
}
await 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

@@ -1,347 +1,625 @@
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',
captureStatus: {
hdmiError: 'Error a la pantalla HDMI',
unsupportedResolution: 'La resolució actual no és compatible',
retrieving: "S'està obtenint la pantalla...",
changingResolution: "S'està canviant la resolució...",
updateFailed: 'La pantalla no es pot actualitzar ara',
videoError: 'Error de visualització de vídeo',
noHdmi: "No s'ha detectat cap senyal HDMI",
unavailable: 'La pantalla no es pot mostrar ara'
}
},
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ó...",
uninstaling: "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ó',
verifying: 'Verificant',
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,75 @@ 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',
captureStatus: {
hdmiError: 'Chyba obrazu HDMI',
unsupportedResolution: 'Aktuální rozlišení není podporováno',
retrieving: 'Načítá se obraz...',
changingResolution: 'Přepíná se rozlišení...',
updateFailed: 'Obraz se teď nemůže aktualizovat',
videoError: 'Chyba zobrazení videa',
noHdmi: 'Nebyl zjištěn signál HDMI',
unavailable: 'Obraz teď nelze zobrazit'
}
},
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 +156,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 +227,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 +246,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 +276,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 +320,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 +440,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 +452,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 +466,162 @@ 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',
verifying: 'Ověřování',
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,75 @@ 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',
captureStatus: {
hdmiError: 'Fejl i HDMI-billedet',
unsupportedResolution: 'Den aktuelle opløsning understøttes ikke',
retrieving: 'Henter skærmbillede...',
changingResolution: 'Skifter opløsning...',
updateFailed: 'Skærmbilledet kan ikke opdateres lige nu',
videoError: 'Fejl i videovisning',
noHdmi: 'Intet HDMI-signal registreret',
unavailable: 'Skærmbilledet kan ikke vises lige nu'
}
},
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 +155,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 +225,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 +244,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 +436,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 +451,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 +465,162 @@ 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',
verifying: 'Bekræfter',
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:
@@ -66,7 +80,17 @@ const de = {
frameDetect: 'Bilderkennung',
frameDetectTip:
'Berechnet den Unterschied zwischen den Einzelbildern. Beendet die Liveübertragung des Videostreams wenn keine Änderungen auf dem Bildschirm des Hosts festgestellt werden kann.',
resetHdmi: 'HDMI zurücksetzen'
resetHdmi: 'HDMI zurücksetzen',
captureStatus: {
hdmiError: 'HDMI-Bildschirmfehler',
unsupportedResolution: 'Die aktuelle Auflösung wird nicht unterstützt',
retrieving: 'Bildschirm wird abgerufen...',
changingResolution: 'Auflösung wird gewechselt...',
updateFailed: 'Der Bildschirm kann derzeit nicht aktualisiert werden',
videoError: 'Fehler bei der Videoanzeige',
noHdmi: 'Kein HDMI-Signal erkannt',
unavailable: 'Der Bildschirm kann derzeit nicht angezeigt werden'
}
},
keyboard: {
title: 'Tastatur',
@@ -75,10 +99,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 +158,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 +181,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 +235,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 +282,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 +296,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 +325,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 +347,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 +373,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 +444,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 +458,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 +495,130 @@ 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',
verifying: 'Überprüfen',
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

@@ -54,7 +54,7 @@ const en = {
authDescription: 'Please enter the AP password to continue',
authFailed: 'Invalid AP password',
passPlaceholder: 'AP password',
verifyBtn: 'Verify',
verifyBtn: 'Verify'
}
},
screen: {
@@ -76,7 +76,17 @@ const en = {
frameDetect: 'Frame Detect',
frameDetectTip:
"Calculate the difference between frames. Stop transmitting video stream when no changes are detected on the remote host's screen.",
resetHdmi: 'Reset HDMI'
resetHdmi: 'Reset HDMI',
captureStatus: {
hdmiError: 'HDMI screen error',
unsupportedResolution: 'Current resolution is not supported',
retrieving: 'Getting screen...',
changingResolution: 'Switching resolution...',
updateFailed: 'Screen cannot update right now',
videoError: 'Video display error',
noHdmi: 'No HDMI signal detected',
unavailable: 'Screen cannot be displayed right now'
}
},
keyboard: {
title: 'Keyboard',
@@ -91,6 +101,7 @@ const en = {
clipboardReadError: 'Failed to read clipboard',
dropdownEnglish: 'English',
dropdownGerman: 'German',
dropdownFrench: 'French',
dropdownRussian: 'Russian',
shortcut: {
title: 'Shortcuts',
@@ -308,29 +319,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',
@@ -374,6 +366,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: {
@@ -449,6 +487,128 @@ 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',
verifying: 'Verifying',
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'

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