Compare commits

...

58 Commits
2.3.1 ... 2.4.0

Author SHA1 Message Date
wenjie
aa0546ff37 release: nanokvm@2.4.0 2026-04-10 18:07:23 +08:00
wenjie
3254412017 Squashed commit of the following:
commit 3dea4b1cfcb866d50c9c093b797c0f4f48fec23f
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 17:11:07 2026 +0800

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

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

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

    Harden PicoClaw local API auth and session locking

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

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

    fix(picoclaw): improve KVM reliability and sidebar behavior

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

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

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

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

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

    fix: stabilize picoclaw runtime defaults and secure local MCP access

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

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

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

68
.github/workflows/issue-tracker.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: Issue Tracker
on:
issues:
types: [opened, reopened, closed]
jobs:
track-issue:
name: Track Issue Updates
runs-on: ubuntu-latest
steps:
- name: Send Issue Data to API
env:
RAW_TITLE: ${{ github.event.issue.title }}
RAW_BODY: ${{ github.event.issue.body }}
RAW_LABELS: ${{ toJSON(github.event.issue.labels) }}
RAW_ASSIGNEES: ${{ toJSON(github.event.issue.assignees) }}
API_URL: ${{ secrets.ISSUE_TRACKER_API_URL }}
VERIFY_TOKEN: ${{ secrets.VERIFY_TOKEN }}
run: |
ISSUE_DATA=$(jq -n \
--arg action "${{ github.event.action }}" \
--arg id "${{ github.event.issue.id }}" \
--arg num "${{ github.event.issue.number }}" \
--arg title "$RAW_TITLE" \
--arg body "$RAW_BODY" \
--arg state "${{ github.event.issue.state }}" \
--arg created_at "${{ github.event.issue.created_at }}" \
--arg updated_at "${{ github.event.issue.updated_at }}" \
--arg closed_at "${{ github.event.issue.closed_at }}" \
--arg url "${{ github.event.issue.html_url }}" \
--arg user_login "${{ github.event.issue.user.login }}" \
--arg repository "${{ github.repository }}" \
--argjson labels "$RAW_LABELS" \
--argjson assignees "$RAW_ASSIGNEES" \
'{
action: $action,
issue: {
id: ($id | tonumber),
number: ($num | tonumber),
title: $title,
body: $body,
state: $state,
created_at: $created_at,
updated_at: $updated_at,
closed_at: $closed_at,
url: $url,
user: { login: $user_login },
labels: $labels,
assignees: $assignees
},
repository: { name: $repository },
}')
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$API_URL" \
-H "Content-Type: application/json" \
-H "Token: $VERIFY_TOKEN" \
-d "$ISSUE_DATA")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo "Successfully sent issue data to API"
else
echo "Failed to send issue data to API"
exit 1
fi

View File

@@ -1,3 +1,103 @@
## 2.4.0 (2026-04-10)
### Features
* **Introduced [PicoClaw](https://github.com/sipeed/picoclaw)** — an AI-powered remote desktop assistant built into 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.
### Bug Fixes
* Added a default subnet mask for static IP configurations
* Fixed an issue where the IP address would occasionally not display
## 2.3.6 (2026-03-12)
### Features
* Implemented AP password authentication for the WiFi configuration page
* Added Korean and Japanese virtual keyboard layouts (thanks to [@klim4-bot](https://github.com/klim4-bot))
* Enabled support for 640x480 resolution (thanks to [@Voranto](https://github.com/Voranto))
* Added encryption parameters for OLED distribution network
* Added custom logo function and logo generation tools
* Added update mechanism for tailscale startup script
### Localization
* Updated Japanese translation (thanks to [@tkmsst](https://github.com/tkmsst))
## 2.3.5 (2026-02-28)
### Features
* Implemented login brute-force protection with lockout mechanism
### Bug Fixes
* Improved Tailscale error handling in UI components and refined backend state mapping
### Chores
* Updated server and web dependencies
## 2.3.4 (2026-01-26)
### Features
* Introduced `Leader Key` functionality to bypass local browser shortcut interception
### Bug Fixes
* Fixed an issue where the Right Shift key was not being recognized on Windows system
* Fixed a bug where the menu bar would fail to close correctly
### Localization
* Updated Traditional Chinese translations (thanks to [@j796160836](https://github.com/j796160836))
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
## 2.3.3 (2026-01-16)
### Features
* Added support for setting the display mode of the menu bar
### Bug Fixes
* Fixed a keyboard issue where IME composition events were not handled correctly
* Resolved an issue where the `AltGr` key was not recognized on the Windows system
* Fixed a bug where `Command` key combinations were not fully released on the macOS system
* Fixed input issues with the `IntlBackslash` key on the German virtual keyboard
### Localization
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
### Chores
* Bump react-router-dom from 6.27.0 to 6.30.3
* Updated eslint
## 2.3.2 (2026-01-08)
### Features
* Added support for custom keyboard shortcuts
* Added support for mouse Forward and Back buttons (reboot required)
* Added support for touchscreen mouse operation
* The menu bar now supports auto-hide and drag
### Bug Fixes
* Fixed an issue where the previous IP address was not released after configuring a static IP
### Performance
* Refactored the keyboard to support simultaneous keystrokes and a wider range of international layouts (reboot required)
* Refactored the mouse to improve response latency
* Refactored the WebSocket module to transmit keyboard and mouse data using the standard HID format
## 2.3.1 (2025-12-26)
### Features

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

@@ -60,7 +60,7 @@ start_usb_dev(){
fi
echo 1 > functions/hid.GS0/protocol
echo 8 > functions/hid.GS0/report_length
echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\x65\\x05\\x07\\x19\\x00\\x29\\x65\\x81\\x00\\xc0 > functions/hid.GS0/report_desc
echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\xE7\\x05\\x07\\x19\\x00\\x29\\xE7\\x81\\x00\\xc0 > functions/hid.GS0/report_desc
ln -s functions/hid.GS0 configs/c.1
# mouse
@@ -90,7 +90,7 @@ start_usb_dev(){
fi
echo 2 > functions/hid.GS2/protocol
echo 6 > functions/hid.GS2/report_length
echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x03\\x15\\x00\\x25\\x01\\x95\\x03\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x05\\x81\\x01\\x05\\x01\\x09\\x30\\x09\\x31\\x15\\x00\\x26\\xff\\x7f\\x35\\x00\\x46\\xff\\x7f\\x75\\x10\\x95\\x02\\x81\\x02\\x05\\x01\\x09\\x38\\x15\\x81\\x25\\x7f\\x35\\x00\\x45\\x00\\x75\\x08\\x95\\x01\\x81\\x06\\xc0\\xc0 > functions/hid.GS2/report_desc
echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x05\\x15\\x00\\x25\\x01\\x95\\x05\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x03\\x81\\x01\\x05\\x01\\x09\\x30\\x09\\x31\\x15\\x00\\x26\\xff\\x7f\\x35\\x00\\x46\\xff\\x7f\\x75\\x10\\x95\\x02\\x81\\x02\\x05\\x01\\x09\\x38\\x15\\x81\\x25\\x7f\\x35\\x00\\x45\\x00\\x75\\x08\\x95\\x01\\x81\\x06\\xc0\\xc0 > functions/hid.GS2/report_desc
ln -s functions/hid.GS2 configs/c.1
fi

View File

@@ -15,10 +15,15 @@ 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 ))"
}
start() {
ip addr flush dev eth0
if [ -e /boot/eth.nodhcp ]; then
while IFS= read -r line || [ -n "$line" ]; do
line=$(clean_line "$line")
@@ -27,6 +32,20 @@ start() {
inet="$1"
gw="$2"
[ -z "$inet" ] && continue
# Check if inet contains a valid netmask, if not, add default /16
case "$inet" in
*/*)
# Already has netmask, check if valid (1-32)
netid_check=${inet#*/}
if ! echo "$netid_check" | grep -qE '^[0-9]+$' || [ $netid_check -lt 1 ] 2>/dev/null || [ $netid_check -gt 32 ] 2>/dev/null; then
inet="${inet%/*}/16"
fi
;;
*)
# No netmask, add default /16
inet="$inet/16"
;;
esac
addr=${inet%/*}
netid=${inet#*/}
[ -z "$gw" ] && gw=$(calc_gw "$addr" "$netid")

View File

@@ -32,6 +32,18 @@ case "$1" in
iptables -C OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP 2>/dev/null || \
iptables -A OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP
# Logo selection
if [ -f /boot/logo.ico ]; then
if [ ! -f /kvmapp/server/web/original.ico ]; then
mv /kvmapp/server/web/sipeed.ico /kvmapp/server/web/original.ico
cp /boot/logo.ico /kvmapp/server/web/sipeed.ico
fi
else
if [ -f /kvmapp/server/web/original.ico ]; then
mv /kvmapp/server/web/original.ico /kvmapp/server/web/sipeed.ico
fi
fi
# Start services
cp -r /kvmapp/kvm_system /tmp/
/tmp/kvm_system/kvm_system &

158
kvmapp/system/init.d/S96picoclaw Executable file
View File

@@ -0,0 +1,158 @@
#!/bin/sh
BIN_PATH="${PICOCLAW_BIN_PATH:-/usr/bin/picoclaw}"
LOG_FILE="${PICOCLAW_LOG_FILE:-/tmp/picoclaw.log}"
BIN_NAME="$(basename "$BIN_PATH")"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
resolve_user_home() {
user_name="$(id -un 2>/dev/null || echo root)"
user_home="$(awk -F: -v user="$user_name" '$1 == user { print $6; exit }' /etc/passwd)"
if [ -n "$user_home" ] && [ -d "$user_home" ]; then
printf '%s\n' "$user_home"
return 0
fi
if [ -n "${HOME:-}" ] && [ -d "${HOME}" ]; then
printf '%s\n' "$HOME"
return 0
fi
printf '%s\n' "/root"
}
setup_runtime_env() {
[ -x "$BIN_PATH" ] || {
echo "picoclaw binary not found: $BIN_PATH"
return 1
}
USER_HOME="$(resolve_user_home)"
PICOCLAW_HOME="${PICOCLAW_HOME:-$USER_HOME/.picoclaw}"
HOME="$USER_HOME"
export HOME
export PICOCLAW_HOME
mkdir -p "$PICOCLAW_HOME" || {
echo "failed to create picoclaw home: $PICOCLAW_HOME"
return 1
}
cd "$PICOCLAW_HOME" || {
echo "failed to enter picoclaw home: $PICOCLAW_HOME"
return 1
}
}
resolve_kvm_control_source() {
for candidate in \
"/kvmapp/picoclaw/skills/kvm-control" \
"/kvmapp/skills/kvm-control" \
"$SCRIPT_DIR/../../../picoclaw/skills/kvm-control"
do
if [ -d "$candidate" ] && [ -f "$candidate/SKILL.md" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
sync_kvm_control_skill() {
source_dir="$(resolve_kvm_control_source)" || {
echo "kvm-control skill source not found"
return 1
}
skills_dir="$PICOCLAW_HOME/workspace/skills"
target_dir="$skills_dir/kvm-control"
mkdir -p "$skills_dir" || {
echo "failed to create skills directory: $skills_dir"
return 1
}
rm -rf "$target_dir" || {
echo "failed to remove old kvm-control skill: $target_dir"
return 1
}
cp -R "$source_dir" "$target_dir" || {
echo "failed to copy kvm-control skill to $target_dir"
return 1
}
}
sync_agent_profile() {
source_file="/kvmapp/picoclaw/AGENT_KVM.md"
target_file="$PICOCLAW_HOME/workspace/AGENT.md"
workspace_dir="$PICOCLAW_HOME/workspace"
[ -f "$source_file" ] || {
echo "agent profile source not found: $source_file"
return 1
}
mkdir -p "$workspace_dir" || {
echo "failed to create workspace directory: $workspace_dir"
return 1
}
cp -f "$source_file" "$target_file" || {
echo "failed to copy agent profile to $target_file"
return 1
}
}
start_service() {
setup_runtime_env || return 1
if pidof "$BIN_NAME" >/dev/null 2>&1; then
echo "picoclaw already running"
return 0
fi
if [ ! -f "$PICOCLAW_HOME/config.json" ]; then
echo "picoclaw config not found: $PICOCLAW_HOME/config.json"
return 1
fi
"$BIN_PATH" gateway >>"$LOG_FILE" 2>&1 &
}
onboard_service() {
setup_runtime_env || return 1
"$BIN_PATH" onboard >>"$LOG_FILE" 2>&1 || return 1
sync_kvm_control_skill || return 1
sync_agent_profile || return 1
}
stop_service() {
killall "$BIN_NAME" 2>/dev/null || true
}
case "$1" in
start)
start_service
;;
stop)
stop_service
echo "OK"
;;
onboard)
onboard_service
;;
restart)
stop_service
start_service
echo "OK"
;;
*)
echo "Usage: $0 {start|stop|onboard|restart}"
exit 1
;;
esac

View File

@@ -26,41 +26,39 @@ server
The configuration file path is `/etc/kvm/server.yaml`.
```yaml
proto: http
# Network Settings
proto: http # Access protocol. Can be changed to `https` only when certificates are configured. Default is `http`
port:
http: 80
https: 443
http: 80 # The listening port for the HTTP service. Default is `80`
https: 443 # The listening port for the HTTPS service (effective when HTTPS is enabled). Default is `443`
cert:
crt: server.crt
key: server.key
crt: server.crt # The path to the public key certificate for HTTPS
key: server.key # The path to the private key file for HTTPS
# Log level (debug/info/warn/error)
# Note: Use 'info' or 'error' in production, 'debug' only for development
# Logging Configuration
logger:
level: info
file: stdout
level: info # Global log output level. Evaluated options from highest to lowest detail: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`. Default is `info`
file: stdout # Log output destination. `stdout` outputs to the standard console. A file path directs log output to that file. Default is `stdout`
# Authentication setting (enable/disable)
# Note: Only disable authentication in development environment
authentication: enable
# Authentication & Security
authentication: enable # Whether to enable identity verification for HTTP API and Web endpoints. Options are `enable` or `disable`. Default is `enable`. Highly recommended to leave this enabled for internet-facing devices!
jwt:
# JWT secret key. If left empty, a random 64-byte key will be generated automatically.
secretKey: ""
# JWT token expiration time in seconds. Default: 2678400 (31 days)
refreshTokenDuration: 2678400
# Invalidate all JWT tokens when the user logs out. Default: true
revokeTokensOnLogout: true
secretKey: "" # The secret key used to sign and verify JWT Tokens. If left empty, a random key will be generated automatically on startup
refreshTokenDuration: 2678400 # The token refresh duration threshold in seconds before forcing a re-login. Default is `2678400` (~31 days)
revokeTokensOnLogout: true # Whether to invalidate all existing tokens upon logout by rotating the SecretKey. Default is `true`
security:
loginLockoutDuration: 0, # The duration (in seconds) to ban an IP from attempting to log in again after reaching the failure limit. If set to `0` or left empty, brute-force protection is disabled. Default is `0`
loginMaxFailures: 5, # The maximum number of continuous failed login attempts allowed per IP before triggering protection. Default is `5`
# Address for custom STUN server
# Note: You can disable the STUN service by setting it to 'disable' (e.g., in a LAN environment)
stun: stun.l.google.com:19302
# Address and authentication for custom TURN server
# WebRTC Traversal Settings
stun: stun.l.google.com:19302 # The default STUN server address used for NAT hole-punching to establish P2P streams
turn:
turnAddr: example_addr
turnUser: example_user
turnCred: example_cred
turnAddr: example_addr # The relay (TURN) server address (format `ip:port`) used as a fallback when P2P connection fails. Leave empty to disable TURN relay
turnUser: example_user # The username required for authorization to the TURN server
turnCred: example_cred # The credential/password required for authorization to the TURN server
```
## Compile & Deploy

View File

@@ -24,40 +24,39 @@ server
配置文件路径为 `/etc/kvm/server.yaml`。
```yaml
proto: http
# 网络设置
proto: http # 访问协议,默认为 `http`,仅当配置了证书时支持改为 `https`
port:
http: 80
https: 443
http: 80 # HTTP 服务的监听端口,默认为 `80`
https: 443 # HTTPS 服务的监听端口(启用 https 协议时生效),默认为 `443`
cert:
crt: server.crt
key: server.key
crt: server.crt # HTTPS 服务使用的公钥证书路径
key: server.key # HTTPS 服务使用的私钥文件路径
# 日志级别(debug/info/warn/error)
# 注意:在生产环境中使用 info 或 error。debug 模式仅在开发环境中使用。
# 日志配置
logger:
level: info
file: stdout
level: info # 全局日志打印级别,从高到底可选 `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`。默认为 `info`
file: stdout # 日志输出目标位置。若填写 `stdout` 则输出在控制台。配置为文件路径则会输出到对应的文件。默认为 `stdout`
# 鉴权设置(enable/disable)
# 注意:生产环境中请勿使用 disable。
authentication: enable
# 认证与安全
authentication: enable # 是否开启 HTTP 接口与网页的身份校验。可选 `enable` (开启) 或 `disable` (禁用)。默认为 `enable`。强烈建议公开在互联网的机器开启此项!
jwt:
# jwt 密钥。设置为空则使用随机生成的64位密钥
secretKey: ""
# jwt token 过期时间(单位:秒),默认为2678400(31天)
refreshTokenDuration: 2678400
# 在帐号登出时是否使所有 jwt token 失效。默认为 true
revokeTokensOnLogout: true
secretKey: "" # 用于签发和验证 JWT Token 的密钥。如果不填,服务启动时将自动随机生成
refreshTokenDuration: 2678400 # 登录超时的刷新周期(单位:秒)。默认为 `2678400`(约31天)
revokeTokensOnLogout: true # 退出登录时是否废除所有现存的 Token。启用此项可以在注销时轮换 SecretKey,强迫所有终端重新登录。默认为 `true`
security:
loginLockoutDuration: 0, # 达到失败上限后,禁止该 IP 再次尝试登录的持续时间(单位:秒)。如果设为 `0` 或不填,则代表不开启防暴力破解功能。默认为 `0`
loginMaxFailures: 5, # 允许触发保护前,单个 IP 连续登录失败的最大次数。默认为 `5`
# 自定义 STUN 服务器的地址
# 注意:可以设置为“disable”来禁用 STUN 服务(例如在局域网环境中使用时)
stun: stun.l.google.com:19302
# WebRTC 内网穿透
stun: stun.l.google.com:19302 # 默认使用的 STUN 服务器地址,用于打洞获取公网 IP 建立 P2P 流
turn:
turnAddr: example_addr
turnUser: example_user
turnCred: example_cred
turnAddr: example_addr # 当 P2P 直连失败时,作为备用的中继(TURN)服务器地址(格式如 `ip:port`)。留空表示不使用 TURN 中继
turnUser: example_user # TURN 服务器授权连接时使用的用户名
turnCred: example_cred # TURN 服务器授权连接时使用的凭据/密码
```
## 编译部署

View File

@@ -26,6 +26,10 @@ var defaultConfig = &Config{
TurnCred: "",
},
Authentication: "enable",
Security: Security{
LoginLockoutDuration: 0,
LoginMaxFailures: 5,
},
}
func checkDefaultValue() {

View File

@@ -0,0 +1,52 @@
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 && token != "" {
picoclawInternalToken.value = token
return token, nil
}
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 readPicoclawInternalToken() (string, error) {
data, err := os.ReadFile(picoclawInternalTokenFile)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}

View File

@@ -1,14 +1,15 @@
package config
type Config struct {
Proto string `yaml:"proto"`
Port Port `yaml:"port"`
Cert Cert `yaml:"cert"`
Logger Logger `yaml:"logger"`
Authentication string `yaml:"authentication"`
JWT JWT `yaml:"jwt"`
Stun string `yaml:"stun"`
Turn Turn `yaml:"turn"`
Proto string `yaml:"proto"`
Port Port `yaml:"port"`
Cert Cert `yaml:"cert"`
Logger Logger `yaml:"logger"`
Authentication string `yaml:"authentication"`
JWT JWT `yaml:"jwt"`
Stun string `yaml:"stun"`
Turn Turn `yaml:"turn"`
Security Security `yaml:"security"`
Hardware Hardware `yaml:"-"`
}
@@ -40,6 +41,11 @@ type Turn struct {
TurnCred string `yaml:"turnCred"`
}
type Security struct {
LoginLockoutDuration int `yaml:"loginLockoutDuration"`
LoginMaxFailures int `yaml:"loginMaxFailures"`
}
type Hardware struct {
Version HWVersion `yaml:"-"`
GPIOReset string `yaml:"-"`

View File

@@ -8,9 +8,10 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.20.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
github.com/pion/dtls/v3 v3.0.3
github.com/pion/dtls/v3 v3.1.2
github.com/pion/rtp v1.8.18
github.com/pion/webrtc/v4 v4.0.1
github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692
@@ -32,7 +33,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
@@ -46,7 +46,7 @@ require (
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.39 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
@@ -55,6 +55,7 @@ require (
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/rs/cors v1.11.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
@@ -66,7 +67,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect

View File

@@ -74,14 +74,14 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
github.com/pion/interceptor v0.1.39 h1:Y6k0bN9Y3Lg/Wb21JBWp480tohtns8ybJ037AGr9UuA=
github.com/pion/interceptor v0.1.39/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
@@ -100,6 +100,8 @@ github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.1 h1:6Unwc6JzoTsjxetcAIoWH81RUM4K5dBc1BbJGcF9WVE=
@@ -140,8 +142,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -150,8 +152,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9Og=
github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=

View File

@@ -3,10 +3,8 @@ package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -74,14 +72,20 @@ func run() {
if conf.Proto == "https" {
go func() {
r.Use(middleware.Tls())
err := r.RunTLS(httpsAddr, conf.Cert.Crt, conf.Cert.Key)
if err != nil {
panic("start https server failed")
}
}()
runRedirect(httpAddr, httpsAddr)
if err := middleware.ListenAndServeLoopbackHTTPRedirect(
httpAddr,
httpsAddr,
r,
router.PicoclawLoopbackHTTPAllowedPaths()...,
); err != nil {
panic("start http server failed")
}
} else {
if err := r.Run(httpAddr); err != nil {
panic("start http server failed")
@@ -89,26 +93,6 @@ func run() {
}
}
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,85 @@
package middleware
import (
"crypto/subtle"
"net"
"net/http"
"strings"
"NanoKVM-Server/config"
)
func ListenAndServeLoopbackHTTPRedirect(
httpPort 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(httpPort, 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
}
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)
}))
}
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

@@ -7,3 +7,33 @@ type GetHidModeRsp struct {
type SetHidModeReq struct {
Mode string `validate:"required"` // normal or hid-only
}
type ShortcutKey struct {
Code string `json:"code"`
Label string `json:"label"`
}
type Shortcut struct {
ID string `json:"id"`
Keys []ShortcutKey `json:"keys"`
}
type GetShortcutsRsp struct {
Shortcuts []Shortcut `json:"shortcuts"`
}
type AddShortcutReq struct {
Keys []ShortcutKey `validate:"required"`
}
type DeleteShortcutReq struct {
ID string `validate:"required"`
}
type SetLeaderKeyReq struct {
Key string `validate:"omitempty"`
}
type GetLeaderKeyRsp struct {
Key string `json:"key"`
}

View File

@@ -13,6 +13,13 @@ func hidRouter(r *gin.Engine) {
api.POST("/hid/paste", service.Paste) // paste
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key
api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key
api.GET("/hid/mode", service.GetHidMode) // get hid mode
api.POST("/hid/mode", service.SetHidMode) // set hid mode
api.POST("/hid/reset", service.ResetHid) // reset hid

View File

@@ -10,7 +10,8 @@ import (
func networkRouter(r *gin.Engine) {
service := network.NewService()
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
r.POST("/api/network/wifi/verify", service.VerifyApLogin) // verify ap login
api := r.Group("/api").Use(middleware.CheckToken())

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,6 +36,7 @@ func server(r *gin.Engine) {
storageRouter(r)
networkRouter(r)
hidRouter(r)
picoclawRouter(r)
wsRouter(r)
downloadRouter(r)
extensionsRouter(r)

View File

@@ -0,0 +1,147 @@
package auth
import (
"sync"
"time"
"NanoKVM-Server/config"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
type loginAttempt struct {
failures int
lastFailed time.Time
lockoutEnd time.Time
}
const (
maxLoginAttemptsRecords = 3000
cleanupInterval = 6 * time.Hour
)
var (
loginAttempts = make(map[string]*loginAttempt)
loginMutex sync.Mutex
cleanupOnce sync.Once
)
// startCleanupRoutine starts a background routine to clean up memory
func startCleanupRoutine() {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return
}
go func() {
ticker := time.NewTicker(cleanupInterval)
for range ticker.C {
loginMutex.Lock()
now := time.Now()
for ip, attempt := range loginAttempts {
// Cleanup rules: if it has been locked and the lockout time has passed,
// or (although not locked) it has been 30 minutes since the last failure,
// remove this record
if (!attempt.lockoutEnd.IsZero() && now.After(attempt.lockoutEnd)) ||
(attempt.lockoutEnd.IsZero() && now.Sub(attempt.lastFailed) > 30*time.Minute) {
delete(loginAttempts, ip)
}
}
loginMutex.Unlock()
}
}()
}
// GetClientIP gets a reliable real IP
func GetClientIP(c *gin.Context) string {
ip := c.RemoteIP()
if ip == "" {
ip = c.ClientIP()
}
return ip
}
// CheckLoginAttempt checks if a login attempt is allowed based on brute-force protection rules.
// Returning true means the IP/System is locked out, and an error string and error code are returned.
func CheckLoginAttempt(clientIP string) (bool, int, string) {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return false, 0, ""
}
cleanupOnce.Do(startCleanupRoutine)
loginMutex.Lock()
defer loginMutex.Unlock()
if attempt, exists := loginAttempts[clientIP]; exists {
if time.Now().Before(attempt.lockoutEnd) {
log.Debugf("login blocked for IP %s: account locked due to too many failed attempts (until %s)", clientIP, attempt.lockoutEnd)
return true, -5, "Account locked due to too many failed attempts, please try again later"
}
// If lockout has elapsed, then we reset the failures and lockoutEnd.
if !attempt.lockoutEnd.IsZero() {
attempt.failures = 0
attempt.lockoutEnd = time.Time{}
}
}
return false, 0, ""
}
// RecordLoginFailure records a failed login attempt for the given IP address.
func RecordLoginFailure(clientIP string) (bool, int, string) {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return false, 0, ""
}
cleanupOnce.Do(startCleanupRoutine)
loginMutex.Lock()
defer loginMutex.Unlock()
attempt, exists := loginAttempts[clientIP]
if !exists {
// When the record pool is full, clear the records instead of global lockout to prevent DDoS
if len(loginAttempts) >= maxLoginAttemptsRecords {
log.Warn("Login attempt records reached maximum limit, clearing records to prevent memory overflow")
loginAttempts = make(map[string]*loginAttempt)
}
attempt = &loginAttempt{}
loginAttempts[clientIP] = attempt
}
now := time.Now()
// Failure time window: if it has been a long time since the last failure
// (e.g., beyond the lockoutDuration window), reset the failure count
if !attempt.lastFailed.IsZero() && now.Sub(attempt.lastFailed) > time.Duration(conf.Security.LoginLockoutDuration)*time.Second {
attempt.failures = 0
}
attempt.failures++
attempt.lastFailed = now
// Reach the failure limit, lock out
if attempt.failures >= conf.Security.LoginMaxFailures {
attempt.lockoutEnd = now.Add(time.Duration(conf.Security.LoginLockoutDuration) * time.Second)
log.Debugf("login failures reached threshold for IP %s, locking out until %s", clientIP, attempt.lockoutEnd)
}
return false, 0, ""
}
// ClearLoginAttempt clears the failed login attempt record for an IP upon successful login.
func ClearLoginAttempt(clientIP string) {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return
}
loginMutex.Lock()
defer loginMutex.Unlock()
delete(loginAttempts, clientIP)
}

View File

@@ -1,10 +1,11 @@
package auth
import (
"time"
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -23,6 +24,13 @@ func (s *Service) Login(c *gin.Context) {
return
}
clientIP := GetClientIP(c)
if locked, code, msg := CheckLoginAttempt(clientIP); locked {
time.Sleep(3 * time.Second)
rsp.ErrRsp(c, code, msg)
return
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(3 * time.Second)
rsp.ErrRsp(c, -1, "invalid parameters")
@@ -31,10 +39,18 @@ func (s *Service) Login(c *gin.Context) {
if ok := CompareAccount(req.Username, req.Password); !ok {
time.Sleep(2 * time.Second)
if locked, code, msg := RecordLoginFailure(clientIP); locked {
rsp.ErrRsp(c, code, msg)
return
}
rsp.ErrRsp(c, -2, "invalid username or password")
return
}
ClearLoginAttempt(clientIP)
token, err := middleware.GenerateJWT(req.Username)
if err != nil {
time.Sleep(1 * time.Second)

View File

@@ -20,11 +20,13 @@ const (
)
var StateMap = map[string]proto.TailscaleState{
"NoState": proto.TailscaleNotRunning,
"Starting": proto.TailscaleNotRunning,
"NeedsLogin": proto.TailscaleNotLogin,
"Running": proto.TailscaleRunning,
"Stopped": proto.TailscaleStopped,
"NoState": proto.TailscaleNotRunning,
"Starting": proto.TailscaleNotRunning,
"NeedsLogin": proto.TailscaleNotLogin,
"NeedsMachineAuth": proto.TailscaleNotLogin,
"InUseOtherUser": proto.TailscaleNotLogin,
"Running": proto.TailscaleRunning,
"Stopped": proto.TailscaleStopped,
}
func NewService() *Service {

View File

@@ -18,9 +18,9 @@ type Hid struct {
}
const (
HID0 = "/dev/hidg0"
HID1 = "/dev/hidg1"
HID2 = "/dev/hidg2"
HID0 = "/dev/hidg0" // Keyboard
HID1 = "/dev/hidg1" // Mouse (Relative Mode)
HID2 = "/dev/hidg2" // Touchpad (Absolute Mode)
)
var (
@@ -95,16 +95,22 @@ func (h *Hid) Close() {
}
func (h *Hid) WriteHid0(data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
h.kbMutex.Lock()
_ = h.g0.SetWriteDeadline(deadline)
_, err := h.g0.Write(data)
h.kbMutex.Unlock()
if err != nil {
if errors.Is(err, os.ErrClosed) {
switch {
case errors.Is(err, os.ErrClosed):
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
} else {
log.Debugf("write to %s failed: %s", HID0, err)
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to %s timeout", HID0)
default:
log.Errorf("write to %s failed: %s", HID0, err)
}
return
}

View File

@@ -1,15 +1,16 @@
package hid
func (h *Hid) Keyboard(queue <-chan []int) {
for event := range queue {
code := byte(event[0])
import (
log "github.com/sirupsen/logrus"
)
var modifier byte = 0x00
if code > 0 {
modifier = byte(event[1]) | byte(event[2]) | byte(event[3]) | byte(event[4])
func (h *Hid) Keyboard(queue <-chan []byte) {
for event := range queue {
if len(event) != 8 {
log.Debugf("invalid keyboard event: %v", event)
continue
}
data := []byte{modifier, 0x00, code, 0x00, 0x00, 0x00, 0x00, 0x00}
h.WriteHid0(data)
h.WriteHid0(event)
}
}

View File

@@ -0,0 +1,63 @@
package hid
import (
"os"
"strings"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
LeaderKeyFile = "/etc/kvm/leader-key"
)
func (s *Service) SetLeaderKey(c *gin.Context) {
var req proto.SetLeaderKeyReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
if req.Key == "" {
err := os.Remove(LeaderKeyFile)
if err != nil && !os.IsNotExist(err) {
rsp.ErrRsp(c, -2, "reset failed")
return
}
} else {
err := os.WriteFile(LeaderKeyFile, []byte(req.Key), 0o644)
if err != nil {
rsp.ErrRsp(c, -3, "write failed")
return
}
}
rsp.OkRsp(c)
log.Debugf("set leader key: %s", req.Key)
}
func (s *Service) GetLeaderKey(c *gin.Context) {
var rsp proto.Response
data, err := os.ReadFile(LeaderKeyFile)
if err != nil {
if os.IsNotExist(err) {
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
Key: "",
})
return
}
rsp.ErrRsp(c, -1, "read leader key failed")
return
}
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
Key: strings.Replace(string(data), "\n", "", -1),
})
log.Debugf("get leader key successful")
}

View File

@@ -1,82 +1,18 @@
package hid
import (
"encoding/binary"
log "github.com/sirupsen/logrus"
)
const (
MouseUp = iota
MouseDown
MouseMoveAbsolute
MouseMoveRelative
MouseScroll
)
var mouseButtonMap = map[byte]bool{
0x01: true,
0x02: true,
0x04: true,
}
func (h *Hid) Mouse(queue <-chan []int) {
func (h *Hid) Mouse(queue <-chan []byte) {
for event := range queue {
switch event[0] {
case MouseDown:
h.mouseDown(event)
case MouseUp:
h.mouseUp()
case MouseMoveAbsolute:
h.mouseMoveAbsolute(event)
case MouseMoveRelative:
h.mouseMoveRelative(event)
case MouseScroll:
h.mouseScroll(event)
switch len(event) {
case 4:
h.WriteHid1(event)
case 6:
h.WriteHid2(event)
default:
log.Debugf("invalid mouse event: %v", event)
}
}
}
func (h *Hid) mouseDown(event []int) {
button := byte(event[1])
if _, ok := mouseButtonMap[button]; !ok {
log.Errorf("invalid mouse button: %v", event)
return
}
data := []byte{button, 0, 0, 0}
h.WriteHid1(data)
}
func (h *Hid) mouseUp() {
data := []byte{0, 0, 0, 0}
h.WriteHid1(data)
}
func (h *Hid) mouseScroll(event []int) {
direction := 0x01
if event[3] < 0 {
direction = -0x1
}
data := []byte{0, 0, 0, byte(direction)}
h.WriteHid1(data)
}
func (h *Hid) mouseMoveAbsolute(event []int) {
x := make([]byte, 2)
y := make([]byte, 2)
binary.LittleEndian.PutUint16(x, uint16(event[2]))
binary.LittleEndian.PutUint16(y, uint16(event[3]))
data := []byte{0, x[0], x[1], y[0], y[1], 0}
h.WriteHid2(data)
}
func (h *Hid) mouseMoveRelative(event []int) {
data := []byte{byte(event[1]), byte(event[2]), byte(event[3]), 0}
h.WriteHid1(data)
}

View File

@@ -131,6 +131,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

@@ -0,0 +1,172 @@
package hid
import (
"encoding/json"
"errors"
"os"
"sync"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
)
var (
shortcutFile = "/etc/kvm/shortcuts.json"
shortcutMutex = sync.RWMutex{}
)
type ShortcutStore struct {
Shortcuts []proto.Shortcut `json:"shortcuts"`
}
func (s *Service) GetShortcuts(c *gin.Context) {
var rsp proto.Response
shortcuts, err := listShortcuts()
if err != nil {
log.Errorf("failed to get shortcuts: %v", err)
rsp.ErrRsp(c, -1, "get shortcuts failed")
return
}
rsp.OkRspWithData(c, &proto.GetShortcutsRsp{
Shortcuts: shortcuts,
})
log.Debugf("get shortcuts success, total: %d", len(shortcuts))
}
func (s *Service) AddShortcut(c *gin.Context) {
var req proto.AddShortcutReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
shortcut, err := addShortcut(req.Keys)
if err != nil {
log.Errorf("failed to add shortcut: %v", err)
rsp.ErrRsp(c, -2, "add shortcut failed")
return
}
rsp.OkRsp(c)
log.Debugf("add shortcut %s", shortcut.ID)
}
func (s *Service) DeleteShortcut(c *gin.Context) {
var req proto.DeleteShortcutReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
err := deleteShortcut(req.ID)
if err != nil {
log.Errorf("failed to delete shortcut: %v", err)
rsp.ErrRsp(c, -2, "delete shortcut failed")
return
}
rsp.OkRsp(c)
log.Debugf("delete shortcut %s", req.ID)
}
func loadShortcuts() (*ShortcutStore, error) {
if _, err := os.Stat(shortcutFile); os.IsNotExist(err) {
return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil
}
data, err := os.ReadFile(shortcutFile)
if err != nil {
return nil, err
}
if len(data) == 0 {
return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil
}
var store ShortcutStore
if err := json.Unmarshal(data, &store); err != nil {
return nil, err
}
return &store, nil
}
func saveShortcuts(store *ShortcutStore) error {
data, err := json.Marshal(store)
if err != nil {
return err
}
return os.WriteFile(shortcutFile, data, 0644)
}
func listShortcuts() ([]proto.Shortcut, error) {
shortcutMutex.RLock()
defer shortcutMutex.RUnlock()
store, err := loadShortcuts()
if err != nil {
return nil, err
}
return store.Shortcuts, nil
}
func addShortcut(keys []proto.ShortcutKey) (*proto.Shortcut, error) {
shortcutMutex.Lock()
defer shortcutMutex.Unlock()
store, err := loadShortcuts()
if err != nil {
return nil, err
}
shortcut := proto.Shortcut{
ID: uuid.New().String(),
Keys: keys,
}
store.Shortcuts = append(store.Shortcuts, shortcut)
if err := saveShortcuts(store); err != nil {
return nil, err
}
return &shortcut, nil
}
func deleteShortcut(id string) error {
shortcutMutex.Lock()
defer shortcutMutex.Unlock()
store, err := loadShortcuts()
if err != nil {
return err
}
found := false
newShortcuts := make([]proto.Shortcut, 0, len(store.Shortcuts))
for _, shortcut := range store.Shortcuts {
if shortcut.ID == id {
found = true
continue
}
newShortcuts = append(newShortcuts, shortcut)
}
if !found {
return errors.New("shortcut not found")
}
store.Shortcuts = newShortcuts
return saveShortcuts(store)
}

View File

@@ -1,6 +1,7 @@
package network
import (
"crypto/subtle"
"fmt"
"os"
"os/exec"
@@ -21,6 +22,7 @@ const (
WiFiConnect = "/kvmapp/kvm/wifi_try_connect"
WiFiStateFile = "/kvmapp/kvm/wifi_state"
WiFiScript = "/etc/init.d/S30wifi"
WiFiApPassFile = "/kvmapp/kvm/ap.pass"
)
func (s *Service) GetWifi(c *gin.Context) {
@@ -62,6 +64,15 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
return
}
// Verify AP Password
apKey := c.GetHeader("X-AP-Key")
expectedPass := getApPassword()
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -4, "unauthorized")
return
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(1 * time.Second)
rsp.ErrRsp(c, -2, "invalid parameters")
@@ -79,6 +90,26 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
log.Debugf("set wifi ap mode successfully")
}
func (s *Service) VerifyApLogin(c *gin.Context) {
var rsp proto.Response
if !isSupported() || !isAPMode() {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -1, "invalid mode")
return
}
apKey := c.GetHeader("X-AP-Key")
expectedPass := getApPassword()
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -4, "unauthorized")
return
}
rsp.OkRsp(c)
}
func (s *Service) ConnectWifi(c *gin.Context) {
var req proto.ConnectWifiReq
var rsp proto.Response
@@ -111,7 +142,6 @@ func (s *Service) ConnectWifi(c *gin.Context) {
}
rsp.ErrRsp(c, -3, "failed to get wifi status")
return
}
func (s *Service) DisconnectWifi(c *gin.Context) {
@@ -182,3 +212,12 @@ func getWiFiSsid() string {
return strings.ReplaceAll(string(ssidByte), "\n", "")
}
func getApPassword() string {
passByte, err := os.ReadFile(WiFiApPassFile)
if err != nil {
return ""
}
return strings.ReplaceAll(string(passByte), "\n", "")
}

View File

@@ -0,0 +1,316 @@
package picoclaw
import (
"bytes"
"encoding/json"
"math"
"strings"
"time"
"NanoKVM-Server/service/hid"
"github.com/gin-gonic/gin"
)
const (
defaultClickHold = 40 * time.Millisecond
defaultKeyDelay = 30 * time.Millisecond
defaultDragSteps = 10
defaultScrollStep = 20 * time.Millisecond
)
func (s *Service) Actions(c *gin.Context) {
sessionID, sessionErr := s.requireSessionID(c)
if sessionErr != nil {
writePicoclawError(c, sessionErr)
return
}
releaseAfter, lockErr := s.lock.AcquireTemporary(sessionID)
if lockErr != nil {
writePicoclawError(c, lockErr)
return
}
if releaseAfter {
defer s.lock.Release(sessionID)
}
actions, actionErr := normalizeActions(c)
if actionErr != nil {
writePicoclawError(c, actionErr)
return
}
result, execErr := s.executeActions(sessionID, actions)
if execErr != nil {
writePicoclawError(c, execErr)
return
}
writeSuccess(c, result)
}
func normalizeActions(c *gin.Context) ([]Action, *PicoclawError) {
body := bytes.NewBuffer(nil)
if _, err := body.ReadFrom(c.Request.Body); err != nil {
return nil, newPicoclawError(CodeInvalidAction, "failed to read action payload")
}
raw := body.Bytes()
if len(raw) == 0 {
return nil, newPicoclawError(CodeInvalidAction, "empty action payload")
}
var batch ActionBatch
if err := json.Unmarshal(raw, &batch); err == nil && len(batch.Actions) > 0 {
return batch.Actions, nil
}
var action Action
if err := json.Unmarshal(raw, &action); err != nil || action.Action == "" {
return nil, newPicoclawError(CodeInvalidAction, "invalid action payload")
}
return []Action{action}, nil
}
func (s *Service) executeActions(sessionID string, actions []Action) (result ActionResult, err *PicoclawError) {
startedAt := time.Now()
if len(actions) == 0 {
return ActionResult{}, newPicoclawError(CodeInvalidAction, "empty actions")
}
defer func() {
if err != nil {
s.releaseAllHIDState()
}
}()
totalWrites := 0
for idx, action := range actions {
if lockErr := s.lock.Ensure(sessionID); lockErr != nil {
lockErr.Index = &idx
return ActionResult{}, lockErr
}
writes, execErr := s.executeAction(action)
if execErr != nil {
execErr.Index = &idx
return ActionResult{}, execErr
}
totalWrites += writes
}
result = ActionResult{
Action: actions[0].Action,
DurationMs: time.Since(startedAt).Milliseconds(),
HIDWrites: totalWrites,
ExecutedActions: len(actions),
}
if len(actions) > 1 {
result.Action = "batch"
}
return result, nil
}
func (s *Service) executeAction(action Action) (int, *PicoclawError) {
switch strings.ToLower(strings.TrimSpace(action.Action)) {
case "click":
x, y, err := normalizedPoint(action.X, action.Y)
if err != nil {
return 0, err
}
button, err := mouseButton(action.Button)
if err != nil {
return 0, err
}
writes := 0
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
writes += s.sendMousePress(x, y, button)
time.Sleep(defaultClickHold)
writes += s.sendMouseRelease(x, y)
return writes, nil
case "move":
x, y, err := normalizedPoint(action.X, action.Y)
if err != nil {
return 0, err
}
return s.sendMouseMoveWithButton(x, y, 0x00, 0), nil
case "wait":
if action.DurationMs < 0 {
return 0, newPicoclawError(CodeInvalidAction, "wait duration must be >= 0")
}
time.Sleep(time.Duration(action.DurationMs) * time.Millisecond)
return 0, nil
case "drag":
fromX, fromY, err := normalizedNestedPoint(action.From)
if err != nil {
return 0, err
}
toX, toY, err := normalizedNestedPoint(action.To)
if err != nil {
return 0, err
}
button, err := mouseButton(action.Button)
if err != nil {
return 0, err
}
writes := 0
writes += s.sendMouseMoveWithButton(fromX, fromY, 0x00, 0)
writes += s.sendMousePress(fromX, fromY, button)
for step := 1; step <= defaultDragSteps; step++ {
ratio := float64(step) / float64(defaultDragSteps)
x := fromX + (toX-fromX)*ratio
y := fromY + (toY-fromY)*ratio
writes += s.sendMouseMoveWithButton(x, y, button, 0)
}
writes += s.sendMouseRelease(toX, toY)
return writes, nil
case "scroll":
x, y := 0.5, 0.5
if action.X != nil || action.Y != nil {
var err *PicoclawError
x, y, err = normalizedPoint(action.X, action.Y)
if err != nil {
return 0, err
}
}
amount := action.Amount
if amount == 0 {
amount = 1
}
if amount < 0 {
return 0, newPicoclawError(CodeInvalidAction, "scroll amount must be > 0")
}
wheel := 1
switch strings.ToLower(strings.TrimSpace(action.Direction)) {
case "", "up":
wheel = 1
case "down":
wheel = -1
default:
return 0, newPicoclawError(CodeInvalidAction, "invalid scroll direction")
}
writes := 0
for range amount {
writes += s.sendMouseMoveWithButton(x, y, 0x00, wheel)
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
time.Sleep(defaultScrollStep)
}
return writes, nil
case "type":
if action.Text == "" {
return 0, newPicoclawError(CodeInvalidAction, "type requires text")
}
charMap := hid.GetCharMap("")
writes := 0
for _, char := range action.Text {
key, ok := charMap[char]
if !ok {
return 0, newPicoclawError(CodeInvalidAction, "unsupported character in type action")
}
writes += s.sendKeyboardReport([]byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00})
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
time.Sleep(defaultKeyDelay)
}
return writes, nil
case "hotkey":
report, err := buildHotkeyReport([]string(action.Keys))
if err != nil {
return 0, err
}
writes := 0
writes += s.sendKeyboardReport(report)
time.Sleep(defaultClickHold)
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
return writes, nil
}
return 0, newPicoclawError(CodeInvalidAction, "unknown or invalid action")
}
func toAbsoluteHidCoord(normalized float64) uint16 {
if normalized < 0 {
normalized = 0
}
if normalized > 1 {
normalized = 1
}
return uint16(math.Floor(0x7FFF*normalized)) + 1
}
func (s *Service) sendMousePress(x float64, y float64, button byte) int {
return s.sendMouseMoveWithButton(x, y, button, 0)
}
func (s *Service) sendMouseRelease(x float64, y float64) int {
return s.sendMouseMoveWithButton(x, y, 0x00, 0)
}
func (s *Service) sendMouseMoveWithButton(x float64, y float64, buttons byte, wheel int) int {
absoluteX := toAbsoluteHidCoord(x)
absoluteY := toAbsoluteHidCoord(y)
report := []byte{
buttons,
byte(absoluteX & 0xff),
byte(absoluteX >> 8),
byte(absoluteY & 0xff),
byte(absoluteY >> 8),
byte(int8(wheel)),
}
s.hid.WriteHid2(report)
return 1
}
func (s *Service) sendKeyboardReport(report []byte) int {
s.hid.WriteHid0(report)
return 1
}
func normalizedPoint(x *float64, y *float64) (float64, float64, *PicoclawError) {
if x == nil || y == nil {
return 0, 0, newPicoclawError(CodeInvalidAction, "action requires x and y")
}
if *x < 0 || *x > 1 || *y < 0 || *y > 1 {
return 0, 0, newPicoclawError(CodeInvalidAction, "coordinates must be within [0,1]")
}
return *x, *y, nil
}
func normalizedNestedPoint(point *Point) (float64, float64, *PicoclawError) {
if point == nil {
return 0, 0, newPicoclawError(CodeInvalidAction, "action requires point coordinates")
}
return normalizedPoint(point.X, point.Y)
}
func mouseButton(button string) (byte, *PicoclawError) {
switch strings.ToLower(strings.TrimSpace(button)) {
case "", "left":
return 1 << 0, nil
case "right":
return 1 << 1, nil
case "middle":
return 1 << 2, nil
case "back":
return 1 << 3, nil
case "forward":
return 1 << 4, nil
default:
return 0, newPicoclawError(CodeInvalidAction, "invalid mouse button")
}
}

View File

@@ -0,0 +1,147 @@
package picoclaw
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
const (
agentProfileDefault = "default"
agentProfileKVM = "kvm"
agentProfileFile = "AGENT.md"
)
type AgentProfileUpdateRequest struct {
Profile string `json:"profile"`
}
func resolveAgentProfileSourcePath(profile string) (string, error) {
fileName := agentProfileFile
switch profile {
case agentProfileDefault:
fileName = "AGENT.md"
case agentProfileKVM:
fileName = "AGENT_KVM.md"
default:
return "", fmt.Errorf("unsupported agent profile: %s", profile)
}
candidates := make([]string, 0, 4)
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates,
filepath.Join(wd, "picoclaw", fileName),
filepath.Join(wd, "docs", fileName),
)
}
candidates = append(candidates,
filepath.Join("/kvmapp", "picoclaw", fileName),
filepath.Join(filepath.Dir(os.Args[0]), "..", "picoclaw", fileName),
)
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", fmt.Errorf("agent profile source not found: %s", fileName)
}
func detectPicoclawAgentProfile() string {
workspacePath, err := resolvePicoclawWorkspacePath()
if err != nil {
return agentProfileKVM
}
targetPath := filepath.Join(workspacePath, agentProfileFile)
targetContent, err := os.ReadFile(targetPath)
if err != nil {
return agentProfileKVM
}
switch parseAgentProfileName(string(targetContent)) {
case "pico":
return agentProfileDefault
case "pico-kvm":
return agentProfileKVM
default:
return agentProfileKVM
}
}
func parseAgentProfileName(content string) string {
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "name:") {
continue
}
return strings.TrimSpace(strings.TrimPrefix(line, "name:"))
}
return ""
}
func applyPicoclawAgentProfile(profile string) error {
sourcePath, err := resolveAgentProfileSourcePath(profile)
if err != nil {
return err
}
workspacePath, err := resolvePicoclawWorkspacePath()
if err != nil {
return err
}
if err := os.MkdirAll(workspacePath, 0o755); err != nil {
return fmt.Errorf("failed to create picoclaw workspace: %w", err)
}
sourceContent, err := os.ReadFile(sourcePath)
if err != nil {
return fmt.Errorf("failed to read agent profile source: %w", err)
}
targetPath := filepath.Join(workspacePath, agentProfileFile)
if err := os.WriteFile(targetPath, sourceContent, 0o644); err != nil {
return fmt.Errorf("failed to write picoclaw AGENT.md: %w", err)
}
return nil
}
func withAgentProfile(status RuntimeStatus) RuntimeStatus {
status.AgentProfile = detectPicoclawAgentProfile()
return status
}
func (s *Service) UpdateAgentProfile(c *gin.Context) {
var req AgentProfileUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile payload"))
return
}
profile := strings.TrimSpace(req.Profile)
if profile != agentProfileDefault && profile != agentProfileKVM {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile"))
return
}
if err := applyPicoclawAgentProfile(profile); err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error()))
return
}
_ = s.syncConfigFromPicoclaw()
_ = s.ensureRuntimeReady()
writeSuccess(c, gin.H{
"profile": profile,
"status": withAgentProfile(s.runtime.Get()),
})
}

View File

@@ -0,0 +1,179 @@
package picoclaw
import (
"fmt"
"os"
"strings"
"time"
)
const (
defaultPicoclawGatewayHost = "127.0.0.1"
defaultPicoclawGatewayPort = 18790
picoclawGatewayPath = "/pico/ws"
)
func defaultConfig() Config {
cfg := Config{
GatewayURL: "ws://127.0.0.1:18790/pico/ws",
ConnectTimeoutMs: 10000,
ReadTimeoutMs: 60000,
WriteTimeoutMs: 10000,
PingIntervalMs: 30000,
MaxMessageBytes: 1024 * 1024,
AllowTokenQuery: false,
}
settings, err := loadPicoclawGatewaySettings()
if err != nil {
return cfg
}
cfg.GatewayURL = settings.GatewayURL
cfg.Token = settings.Token
cfg.AllowTokenQuery = settings.AllowTokenQuery
if settings.PingIntervalMs > 0 {
cfg.PingIntervalMs = settings.PingIntervalMs
}
if settings.ReadTimeoutMs > 0 {
cfg.ReadTimeoutMs = settings.ReadTimeoutMs
}
return cfg
}
func (s *Service) syncConfigFromPicoclaw() *PicoclawError {
installed, installedKnown := picoclawInstalledState()
if patchErr := preparePicoclawConfigForRead(); patchErr != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "config_error"
status.ConfigError = patchErr.Error()
status.LastError = patchErr.Error()
if installedKnown {
status.Installed = installed
}
status.CheckedAt = time.Now()
})
return newPicoclawError(CodeRuntimeUnavailable, patchErr.Error())
}
settings, err := loadPicoclawGatewaySettings()
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "config_error"
status.ConfigError = err.Error()
status.LastError = err.Error()
if installedKnown {
status.Installed = installed
}
status.CheckedAt = time.Now()
})
return newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
cfg := s.config.Get()
cfg.GatewayURL = settings.GatewayURL
cfg.Token = settings.Token
cfg.AllowTokenQuery = settings.AllowTokenQuery
if settings.PingIntervalMs > 0 {
cfg.PingIntervalMs = settings.PingIntervalMs
}
if settings.ReadTimeoutMs > 0 {
cfg.ReadTimeoutMs = settings.ReadTimeoutMs
}
s.config.Set(cfg)
s.runtime.Update(func(status *RuntimeStatus) {
status.Installed = true
status.ModelConfigured = settings.ModelConfigured
if settings.ModelConfigured {
status.ModelName = settings.ModelName
} else {
status.ModelName = settings.TargetModelName
}
status.ConfigError = ""
if status.Status == "config_error" && status.Ready {
status.Status = "ready"
}
status.CheckedAt = time.Now()
})
return nil
}
type picoclawGatewaySettings struct {
GatewayURL string
Token string
AllowTokenQuery bool
PingIntervalMs int
ReadTimeoutMs int
ModelConfigured bool
ModelName string
TargetModelName string
}
func loadPicoclawGatewaySettings() (picoclawGatewaySettings, error) {
doc, err := loadPicoclawConfigDocument()
if err != nil {
return picoclawGatewaySettings{}, err
}
if err := ensurePicoclawPicoChannelEnabled(doc); err != nil {
return picoclawGatewaySettings{}, err
}
cfg := doc.config
host := cfg.Gateway.Host
if host == "" || host == "0.0.0.0" {
host = defaultPicoclawGatewayHost
}
port := cfg.Gateway.Port
if port <= 0 {
port = defaultPicoclawGatewayPort
}
settings := picoclawGatewaySettings{
GatewayURL: fmt.Sprintf("ws://%s:%d%s", host, port, picoclawGatewayPath),
Token: doc.resolvedGatewayToken(),
AllowTokenQuery: cfg.Channels.Pico.AllowTokenQuery,
}
settings.TargetModelName = resolvePicoclawTargetModelName(cfg)
if isPicoclawModelConfigured(cfg, doc.security, settings.TargetModelName) {
settings.ModelConfigured = true
settings.ModelName = settings.TargetModelName
}
if cfg.Channels.Pico.PingInterval > 0 {
settings.PingIntervalMs = cfg.Channels.Pico.PingInterval * 1000
}
if cfg.Channels.Pico.ReadTimeout > 0 {
settings.ReadTimeoutMs = cfg.Channels.Pico.ReadTimeout * 1000
}
return settings, nil
}
func resolvePicoclawTargetModelName(cfg picoclawConfigFile) string {
return strings.TrimSpace(cfg.Agents.Defaults.ModelName)
}
func preparePicoclawConfigForRead() error {
configPath, err := resolvePicoclawConfigPath()
if err != nil {
return err
}
if _, err := os.Stat(configPath); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to stat picoclaw config: %w", err)
}
return nil
}

View File

@@ -0,0 +1,256 @@
package picoclaw
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
const (
picoclawPIDFileName = ".picoclaw.pid"
picoclawGatewayTokenPrefix = "pico-"
)
type picoclawConfigFile struct {
Agents struct {
Defaults struct {
ModelName string `json:"model_name"`
Model string `json:"model,omitempty"`
Workspace string `json:"workspace,omitempty"`
} `json:"defaults"`
} `json:"agents"`
Gateway struct {
Host string `json:"host"`
Port int `json:"port"`
} `json:"gateway"`
ModelList []struct {
ModelName string `json:"model_name"`
Model string `json:"model"`
APIBase string `json:"api_base"`
APIKey string `json:"api_key"`
APIKeys []string `json:"api_keys,omitempty"`
} `json:"model_list"`
Channels struct {
Pico struct {
Enabled bool `json:"enabled"`
Token string `json:"token"`
AllowTokenQuery bool `json:"allow_token_query"`
PingInterval int `json:"ping_interval"`
ReadTimeout int `json:"read_timeout"`
} `json:"pico"`
} `json:"channels"`
}
type picoclawConfigDocument struct {
configPath string
securityPath string
config picoclawConfigFile
raw map[string]any
security picoclawSecurityConfig
}
func loadPicoclawConfigDocument() (*picoclawConfigDocument, error) {
configPath, err := resolvePicoclawConfigPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read picoclaw config: %w", err)
}
var cfg picoclawConfigFile
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse picoclaw config: %w", err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("failed to parse picoclaw config for update: %w", err)
}
securityPath := resolvePicoclawSecurityPath(configPath)
security, err := loadPicoclawSecurityConfig(securityPath)
if err != nil {
return nil, err
}
return &picoclawConfigDocument{
configPath: configPath,
securityPath: securityPath,
config: cfg,
raw: raw,
security: security,
}, nil
}
func resolvePicoclawConfigPath() (string, error) {
home := os.Getenv("PICOCLAW_HOME")
if home == "" {
currentUser, err := user.Current()
if err != nil {
return "", fmt.Errorf("failed to resolve PICOCLAW_HOME: %w", err)
}
home = filepath.Join(currentUser.HomeDir, ".picoclaw")
}
return filepath.Join(home, "config.json"), nil
}
func expandPicoclawPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if strings.HasPrefix(path, "~/") || path == "~" {
if home, err := os.UserHomeDir(); err == nil {
if path == "~" {
return home
}
return filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
}
return path
}
func resolvePicoclawSecurityPath(configPath string) string {
return filepath.Join(filepath.Dir(configPath), ".security.yml")
}
func (d *picoclawConfigDocument) saveConfig() error {
updated, err := json.MarshalIndent(d.raw, "", " ")
if err != nil {
return fmt.Errorf("failed to encode updated picoclaw config: %w", err)
}
if err := os.WriteFile(d.configPath, updated, 0o600); err != nil {
return fmt.Errorf("failed to write picoclaw config: %w", err)
}
return nil
}
func (d *picoclawConfigDocument) saveSecurity() error {
var buf bytes.Buffer
encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2)
if err := encoder.Encode(d.security); err != nil {
return fmt.Errorf("failed to encode picoclaw security config: %w", err)
}
if err := encoder.Close(); err != nil {
return fmt.Errorf("failed to finalize picoclaw security config: %w", err)
}
if err := os.WriteFile(d.securityPath, buf.Bytes(), 0o600); err != nil {
return fmt.Errorf("failed to write picoclaw security config: %w", err)
}
return nil
}
type picoclawSecurityConfig struct {
ModelList map[string]picoclawModelSecurityEntry `yaml:"model_list,omitempty"`
Channels picoclawSecurityChannels `yaml:"channels,omitempty"`
}
type picoclawModelSecurityEntry struct {
APIKeys []string `yaml:"api_keys,omitempty"`
}
type picoclawSecurityChannels struct {
Pico *picoclawPicoSecurity `yaml:"pico,omitempty"`
}
type picoclawPicoSecurity struct {
Token string `yaml:"token,omitempty"`
}
type picoclawPIDFile struct {
Token string `json:"token"`
}
func (d *picoclawConfigDocument) resolvedPicoToken() string {
if d == nil {
return ""
}
if d.security.Channels.Pico != nil {
if token := d.security.Channels.Pico.Token; token != "" {
return token
}
}
return d.config.Channels.Pico.Token
}
func (d *picoclawConfigDocument) resolvedGatewayToken() string {
baseToken := strings.TrimSpace(d.resolvedPicoToken())
if baseToken == "" {
return ""
}
runtimeToken, err := d.resolvedPIDToken()
if err != nil {
return baseToken
}
return composePicoclawGatewayToken(baseToken, runtimeToken)
}
func (d *picoclawConfigDocument) resolvedPIDToken() (string, error) {
if d == nil || d.configPath == "" {
return "", nil
}
pidPath := filepath.Join(filepath.Dir(d.configPath), picoclawPIDFileName)
data, err := os.ReadFile(pidPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("failed to read picoclaw pid file: %w", err)
}
var pidFile picoclawPIDFile
if err := json.Unmarshal(data, &pidFile); err != nil {
return "", fmt.Errorf("failed to parse picoclaw pid file: %w", err)
}
return strings.TrimSpace(pidFile.Token), nil
}
func composePicoclawGatewayToken(baseToken, runtimeToken string) string {
baseToken = strings.TrimSpace(baseToken)
runtimeToken = strings.TrimSpace(runtimeToken)
switch {
case baseToken == "":
return ""
case strings.HasPrefix(baseToken, picoclawGatewayTokenPrefix):
return baseToken
case runtimeToken == "":
return baseToken
default:
return picoclawGatewayTokenPrefix + runtimeToken + baseToken
}
}
func loadPicoclawSecurityConfig(securityPath string) (picoclawSecurityConfig, error) {
data, err := os.ReadFile(securityPath)
if err != nil {
if os.IsNotExist(err) {
return picoclawSecurityConfig{}, nil
}
return picoclawSecurityConfig{}, fmt.Errorf("failed to read picoclaw security config: %w", err)
}
var sec picoclawSecurityConfig
if err := yaml.Unmarshal(data, &sec); err != nil {
return picoclawSecurityConfig{}, fmt.Errorf("failed to parse picoclaw security config: %w", err)
}
return sec, nil
}

View File

@@ -0,0 +1,71 @@
package picoclaw
import (
"net/http"
"github.com/gin-gonic/gin"
)
const (
CodePicoclawLockHeld = "AI_LOCK_HELD"
CodeScreenshotFailed = "SCREENSHOT_FAILED"
CodeScreenshotNoSignal = "SCREENSHOT_NO_SIGNAL"
CodeHIDWriteFailed = "HID_WRITE_FAILED"
CodeInvalidAction = "INVALID_ACTION"
CodeRuntimeUnavailable = "RUNTIME_UNAVAILABLE"
CodeRuntimeStartFailed = "RUNTIME_START_FAILED"
CodeSessionIDMissing = "SESSION_ID_MISSING"
CodeSessionIDInvalid = "SESSION_ID_INVALID"
)
type PicoclawError struct {
StatusCode int
Code string
Message string
SessionID string
Index *int
}
func (e *PicoclawError) Error() string {
return e.Code + ": " + e.Message
}
func newPicoclawError(code string, message string) *PicoclawError {
return &PicoclawError{
StatusCode: http.StatusOK,
Code: code,
Message: message,
}
}
func writeSuccess(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": data,
})
}
func writePicoclawError(c *gin.Context, err *PicoclawError) {
if err == nil {
return
}
payload := gin.H{
"code": err.Code,
"message": err.Message,
}
if err.SessionID != "" {
payload["session_id"] = err.SessionID
}
if err.Index != nil {
payload["index"] = *err.Index
}
statusCode := err.StatusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
c.JSON(statusCode, payload)
}

View File

@@ -0,0 +1,107 @@
package picoclaw
import (
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/gorilla/websocket"
)
func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawError) {
cfg := s.config.Get()
gatewayURL, err := buildGatewayURL(cfg, sessionID)
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "config_error"
status.ConfigError = err.Error()
status.LastError = err.Error()
status.CheckedAt = time.Now()
status.CurrentSession = s.lock.Owner()
})
return nil, newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
header := http.Header{}
if cfg.Token != "" {
header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Token))
}
dialer := websocket.Dialer{
HandshakeTimeout: time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond,
NetDialContext: (&net.Dialer{
Timeout: time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond,
}).DialContext,
}
upstream, response, err := dialer.Dial(gatewayURL, header)
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "unavailable"
status.ConfigError = ""
status.LastError = err.Error()
status.CheckedAt = time.Now()
status.CurrentSession = s.lock.Owner()
if response != nil && response.StatusCode == http.StatusUnauthorized {
status.Status = "config_error"
status.ConfigError = "gateway authentication failed"
}
})
return nil, newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
}
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = true
status.Status = "ready"
status.ConfigError = ""
status.LastError = ""
status.CheckedAt = time.Now()
status.CurrentSession = s.lock.Owner()
})
return upstream, nil
}
func buildGatewayURL(cfg Config, sessionID string) (string, error) {
parsed, err := url.Parse(cfg.GatewayURL)
if err != nil {
return "", fmt.Errorf("invalid gateway url: %w", err)
}
if parsed.Scheme != "ws" && parsed.Scheme != "wss" {
return "", fmt.Errorf("invalid gateway url scheme: %s", parsed.Scheme)
}
if parsed.Host == "" {
return "", fmt.Errorf("invalid gateway url host")
}
query := parsed.Query()
query.Set("session_id", sessionID)
if cfg.Token != "" && cfg.AllowTokenQuery {
query.Set("token", cfg.Token)
}
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func gatewayHostPort(parsed *url.URL) (string, error) {
if parsed == nil || parsed.Host == "" {
return "", fmt.Errorf("invalid gateway url host")
}
if _, _, err := net.SplitHostPort(parsed.Host); err == nil {
return parsed.Host, nil
}
switch parsed.Scheme {
case "ws":
return net.JoinHostPort(parsed.Host, "80"), nil
case "wss":
return net.JoinHostPort(parsed.Host, "443"), nil
default:
return "", fmt.Errorf("invalid gateway url scheme: %s", parsed.Scheme)
}
}

View File

@@ -0,0 +1,262 @@
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", downstream, cfg, &wg)
go s.runPingLoop("upstream", 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
}
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, 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 err := conn.WriteControl(
websocket.PingMessage,
[]byte(name),
time.Now().Add(time.Duration(cfg.WriteTimeoutMs)*time.Millisecond),
); err != nil {
return
}
}
}
func (s *Service) configureRelayConn(conn *websocket.Conn, cfg Config) {
readTimeout := time.Duration(cfg.ReadTimeoutMs) * time.Millisecond
conn.SetReadLimit(int64(cfg.MaxMessageBytes))
_ = conn.SetReadDeadline(time.Now().Add(readTimeout))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(readTimeout))
})
}
func (s *Service) closeGatewaySession(session *GatewaySession, closeCode int, reason string) {
if session == nil {
return
}
session.closeOnce.Do(func() {
hadDownstream := session.Downstream != nil
mjpeg.DisableLatestFrameCache()
GetSessionManager().SetState(session.SessionID, SessionStateClosing)
if session.Upstream != nil {
writeGatewayClose(session.Upstream, websocket.CloseNormalClosure, "relay closing")
_ = session.Upstream.Close()
}
if session.Downstream != nil {
writeGatewayClose(session.Downstream, closeCode, reason)
_ = session.Downstream.Close()
}
if hadDownstream {
cleanupPicoclawMediaTempDir()
}
ReleaseSession(session.SessionID)
GetSessionManager().SetState(session.SessionID, SessionStateClosed)
GetSessionManager().Remove(session.SessionID)
status := s.runtime.Get()
status.CurrentSession = s.lock.Owner()
if closeCode == CloseCodeUpstreamClosed || closeCode == CloseCodeRuntimeUnavailable {
status.Ready = false
if closeCode == CloseCodeUpstreamClosed {
status.Status = "unavailable"
}
status.LastError = reason
status.CheckedAt = time.Now()
s.runtime.Set(status)
}
})
}
func writeGatewayClose(conn *websocket.Conn, code int, reason string) {
if conn == nil {
return
}
message := websocket.FormatCloseMessage(code, reason)
_ = conn.WriteControl(websocket.CloseMessage, message, time.Now().Add(2*time.Second))
}
func closeCodeFromError(source string, err error) int {
if closeErr, ok := err.(*websocket.CloseError); ok {
if closeErr.Code == websocket.CloseNormalClosure || closeErr.Code == websocket.CloseGoingAway {
return websocket.CloseNormalClosure
}
if closeErr.Code == websocket.CloseAbnormalClosure || closeErr.Code == websocket.CloseNoStatusReceived {
if source == "upstream" {
return CloseCodeUpstreamClosed
}
return websocket.CloseNormalClosure
}
return closeErr.Code
}
if source == "upstream" {
return CloseCodeUpstreamClosed
}
return websocket.CloseNormalClosure
}
func closeReasonFromError(err error) string {
if err == nil {
return "closed"
}
if closeErr, ok := err.(*websocket.CloseError); ok {
if closeErr.Text != "" {
return closeErr.Text
}
}
return fmt.Sprintf("relay closed: %v", err)
}

View File

@@ -0,0 +1,86 @@
package picoclaw
import (
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
type picoGatewayMessage struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
SessionID string `json:"session_id,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
func newPicoGatewayMessage(sessionID string, payload map[string]any) picoGatewayMessage {
return picoGatewayMessage{
Type: "message.send",
ID: uuid.NewString(),
SessionID: sessionID,
Timestamp: time.Now().UnixMilli(),
Payload: payload,
}
}
func newPicoGatewayObservationMessage(sessionID string, payload map[string]any) picoGatewayMessage {
return picoGatewayMessage{
Type: "observation",
ID: uuid.NewString(),
SessionID: sessionID,
Timestamp: time.Now().UnixMilli(),
Payload: payload,
}
}
func (session *GatewaySession) writeUpstreamJSON(cfg Config, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
return session.writeUpstreamMessage(cfg, websocket.TextMessage, raw)
}
func (session *GatewaySession) writeDownstreamJSON(cfg Config, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
return session.writeDownstreamMessage(cfg, websocket.TextMessage, raw)
}
func (session *GatewaySession) writeUpstreamMessage(cfg Config, messageType int, data []byte) error {
if session == nil || session.Upstream == nil {
return fmt.Errorf("upstream connection is unavailable")
}
session.upstreamWriteMu.Lock()
defer session.upstreamWriteMu.Unlock()
_ = session.Upstream.SetWriteDeadline(gatewayWriteDeadline(cfg))
return session.Upstream.WriteMessage(messageType, data)
}
func (session *GatewaySession) writeDownstreamMessage(cfg Config, messageType int, data []byte) error {
if session == nil || session.Downstream == nil {
return fmt.Errorf("downstream connection is unavailable")
}
session.downstreamWriteMu.Lock()
defer session.downstreamWriteMu.Unlock()
_ = session.Downstream.SetWriteDeadline(gatewayWriteDeadline(cfg))
return session.Downstream.WriteMessage(messageType, data)
}
func gatewayWriteDeadline(cfg Config) time.Time {
timeout := time.Duration(cfg.WriteTimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 10 * time.Second
}
return time.Now().Add(timeout)
}

View File

@@ -0,0 +1,271 @@
package picoclaw
import (
"fmt"
"strings"
)
const (
modifierLeftCtrl = 1 << 0
modifierLeftShift = 1 << 1
modifierLeftAlt = 1 << 2
modifierLeftMeta = 1 << 3
modifierRightCtrl = 1 << 4
modifierRightShift = 1 << 5
modifierRightAlt = 1 << 6
modifierRightMeta = 1 << 7
)
var modifierMap = map[string]byte{
"ControlLeft": modifierLeftCtrl,
"ShiftLeft": modifierLeftShift,
"AltLeft": modifierLeftAlt,
"MetaLeft": modifierLeftMeta,
"ControlRight": modifierRightCtrl,
"ShiftRight": modifierRightShift,
"AltRight": modifierRightAlt,
"MetaRight": modifierRightMeta,
}
var keyAliases = map[string]string{
"CTRL": "ControlLeft",
"CONTROL": "ControlLeft",
"SHIFT": "ShiftLeft",
"ALT": "AltLeft",
"OPTION": "AltLeft",
"META": "MetaLeft",
"WIN": "MetaLeft",
"WINDOWS": "MetaLeft",
"COMMAND": "MetaLeft",
"CMD": "MetaLeft",
"SUPER": "MetaLeft",
"ESC": "Escape",
"RETURN": "Enter",
"DEL": "Delete",
"INS": "Insert",
"UP": "ArrowUp",
"DOWN": "ArrowDown",
"LEFT": "ArrowLeft",
"RIGHT": "ArrowRight",
"SPACEBAR": "Space",
"SPACE": "Space",
"TAB": "Tab",
"ENTER": "Enter",
"BACKSPACE": "Backspace",
"PGUP": "PageUp",
"PGDN": "PageDown",
}
var keycodeMap = map[string]byte{
"KeyA": 0x04, "KeyB": 0x05, "KeyC": 0x06, "KeyD": 0x07, "KeyE": 0x08,
"KeyF": 0x09, "KeyG": 0x0a, "KeyH": 0x0b, "KeyI": 0x0c, "KeyJ": 0x0d,
"KeyK": 0x0e, "KeyL": 0x0f, "KeyM": 0x10, "KeyN": 0x11, "KeyO": 0x12,
"KeyP": 0x13, "KeyQ": 0x14, "KeyR": 0x15, "KeyS": 0x16, "KeyT": 0x17,
"KeyU": 0x18, "KeyV": 0x19, "KeyW": 0x1a, "KeyX": 0x1b, "KeyY": 0x1c,
"KeyZ": 0x1d,
"Digit1": 0x1e, "Digit2": 0x1f, "Digit3": 0x20, "Digit4": 0x21, "Digit5": 0x22,
"Digit6": 0x23, "Digit7": 0x24, "Digit8": 0x25, "Digit9": 0x26, "Digit0": 0x27,
"Enter": 0x28,
"Escape": 0x29,
"Backspace": 0x2a,
"Tab": 0x2b,
"Space": 0x2c,
"Minus": 0x2d,
"Equal": 0x2e,
"BracketLeft": 0x2f,
"BracketRight": 0x30,
"Backslash": 0x31,
"Semicolon": 0x33,
"Quote": 0x34,
"Backquote": 0x35,
"Comma": 0x36,
"Period": 0x37,
"Slash": 0x38,
"CapsLock": 0x39,
"F1": 0x3a, "F2": 0x3b, "F3": 0x3c, "F4": 0x3d, "F5": 0x3e, "F6": 0x3f,
"F7": 0x40, "F8": 0x41, "F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
"PrintScreen": 0x46,
"ScrollLock": 0x47,
"Pause": 0x48,
"Insert": 0x49,
"Home": 0x4a,
"PageUp": 0x4b,
"Delete": 0x4c,
"End": 0x4d,
"PageDown": 0x4e,
"ArrowRight": 0x4f,
"ArrowLeft": 0x50,
"ArrowDown": 0x51,
"ArrowUp": 0x52,
"NumLock": 0x53,
"NumpadDivide": 0x54,
"NumpadMultiply": 0x55,
"NumpadSubtract": 0x56,
"NumpadAdd": 0x57,
"NumpadEnter": 0x58,
"Numpad1": 0x59,
"Numpad2": 0x5a,
"Numpad3": 0x5b,
"Numpad4": 0x5c,
"Numpad5": 0x5d,
"Numpad6": 0x5e,
"Numpad7": 0x5f,
"Numpad8": 0x60,
"Numpad9": 0x61,
"Numpad0": 0x62,
"NumpadDecimal": 0x63,
"IntlBackslash": 0x64,
"ContextMenu": 0x65,
"Power": 0x66,
"NumpadEqual": 0x67,
"F13": 0x68, "F14": 0x69, "F15": 0x6a, "F16": 0x6b, "F17": 0x6c, "F18": 0x6d,
"F19": 0x6e, "F20": 0x6f, "F21": 0x70, "F22": 0x71, "F23": 0x72, "F24": 0x73,
"Execute": 0x74,
"Help": 0x75,
"Props": 0x76,
"Select": 0x77,
"Stop": 0x78,
"Again": 0x79,
"Undo": 0x7a,
"Cut": 0x7b,
"Copy": 0x7c,
"Paste": 0x7d,
"Find": 0x7e,
"AudioVolumeMute": 0x7f,
"AudioVolumeUp": 0x80,
"AudioVolumeDown": 0x81,
"VolumeMute": 0x7f,
"VolumeUp": 0x80,
"VolumeDown": 0x81,
"LockingCapsLock": 0x82,
"LockingNumLock": 0x83,
"LockingScrollLock": 0x84,
"NumpadComma": 0x85,
"NumpadEqual2": 0x86,
"IntlRo": 0x87,
"KanaMode": 0x88,
"IntlYen": 0x89,
"Convert": 0x8a,
"NonConvert": 0x8b,
"International6": 0x8c,
"International7": 0x8d,
"International8": 0x8e,
"International9": 0x8f,
"Lang1": 0x90, "Lang2": 0x91, "Lang3": 0x92, "Lang4": 0x93, "Lang5": 0x94,
"Lang6": 0x95, "Lang7": 0x96, "Lang8": 0x97, "Lang9": 0x98,
"IntlHash": 0x32,
"NumpadParenLeft": 0xb6,
"NumpadParenRight": 0xb7,
"NumpadBackspace": 0xbb,
"NumpadMemoryStore": 0xd0,
"NumpadMemoryRecall": 0xd1,
"NumpadMemoryClear": 0xd2,
"NumpadMemoryAdd": 0xd3,
"NumpadMemorySubtract": 0xd4,
"NumpadClear": 0xd8,
"NumpadClearEntry": 0xd9,
"BrowserSearch": 0xf0,
"BrowserHome": 0xf1,
"BrowserBack": 0xf2,
"BrowserForward": 0xf3,
"BrowserStop": 0xf4,
"BrowserRefresh": 0xf5,
"BrowserFavorites": 0xf6,
"MediaPlayPause": 0xe8,
"MediaStop": 0xe9,
"MediaTrackPrevious": 0xea,
"MediaTrackNext": 0xeb,
"Eject": 0xec,
"MediaSelect": 0xed,
"LaunchMail": 0xee,
"LaunchApp1": 0xef,
"LaunchApp2": 0xf0,
"Sleep": 0xf8,
"Wake": 0xf9,
"MediaRewind": 0xfa,
"MediaFastForward": 0xfb,
}
func resolveKey(key string) (string, byte, bool, error) {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return "", 0, false, fmt.Errorf("empty key")
}
if modifier, ok := modifierMap[trimmed]; ok {
return trimmed, modifier, true, nil
}
if _, ok := keycodeMap[trimmed]; ok {
return trimmed, keycodeMap[trimmed], false, nil
}
upper := strings.ToUpper(trimmed)
if alias, ok := keyAliases[upper]; ok {
if modifier, ok := modifierMap[alias]; ok {
return alias, modifier, true, nil
}
return alias, keycodeMap[alias], false, nil
}
if len(upper) == 1 && upper[0] >= 'A' && upper[0] <= 'Z' {
code := "Key" + upper
return code, keycodeMap[code], false, nil
}
if len(upper) == 1 && upper[0] >= '0' && upper[0] <= '9' {
code := "Digit" + upper
return code, keycodeMap[code], false, nil
}
if modifier, ok := modifierMap[upper]; ok {
return upper, modifier, true, nil
}
if code, ok := keycodeMap[trimmed]; ok {
return trimmed, code, false, nil
}
if code, ok := keycodeMap[upper]; ok {
return upper, code, false, nil
}
return "", 0, false, fmt.Errorf("unsupported key: %s", key)
}
func buildHotkeyReport(keys []string) ([]byte, *PicoclawError) {
if len(keys) == 0 {
return nil, newPicoclawError(CodeInvalidAction, "hotkey requires at least one key")
}
report := make([]byte, 8)
nextIndex := 2
for _, key := range keys {
_, code, isModifier, err := resolveKey(key)
if err != nil {
return nil, newPicoclawError(CodeInvalidAction, err.Error())
}
if isModifier {
report[0] |= code
continue
}
if nextIndex >= len(report) {
return nil, newPicoclawError(CodeInvalidAction, "hotkey supports at most 6 non-modifier keys")
}
report[nextIndex] = code
nextIndex++
}
return report, nil
}

View File

@@ -0,0 +1,102 @@
package picoclaw
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
defaultImagePrompt = "Describe the image briefly."
)
func (s *Service) LoadImage(c *gin.Context) {
var req LoadImageRequest
if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid load image payload"))
return
}
sessionID, session, sessionErr := s.requireActiveGatewaySession(c)
if sessionErr != nil {
writePicoclawError(c, sessionErr)
return
}
sourcePath, pathErr := normalizeLoadImagePath(req.Path)
if pathErr != nil {
writePicoclawError(c, pathErr)
return
}
instruction := buildLoadImageInstruction(sourcePath, req.Prompt)
message := newPicoGatewayMessage(sessionID, map[string]any{
"content": instruction,
})
if err := session.writeUpstreamJSON(s.config.Get(), message); err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to deliver image instruction to picoclaw"))
return
}
writeSuccess(c, gin.H{
"accepted": true,
"session_id": sessionID,
"message_id": message.ID,
"path": sourcePath,
"user_prompt": normalizeImagePrompt(req.Prompt),
"instructed_at": time.Now(),
})
}
func (s *Service) requireActiveGatewaySession(c *gin.Context) (string, *GatewaySession, *PicoclawError) {
sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader))
if sessionID == "" {
sessionID = strings.TrimSpace(s.lock.Owner())
}
if sessionID == "" {
return "", nil, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID")
}
session, ok := GetSessionManager().Get(sessionID)
if !ok || session == nil {
err := newPicoclawError(CodeRuntimeUnavailable, "picoclaw session is not connected")
err.SessionID = sessionID
return "", nil, err
}
if session.State != SessionStateActive || session.Upstream == nil {
err := newPicoclawError(CodeRuntimeUnavailable, "picoclaw session is not active")
err.SessionID = sessionID
return "", nil, err
}
return sessionID, session, nil
}
func normalizeLoadImagePath(sourcePath string) (string, *PicoclawError) {
sourcePath = strings.TrimSpace(sourcePath)
if sourcePath == "" {
return "", newPicoclawError(CodeInvalidAction, "image path is required")
}
return sourcePath, nil
}
func buildLoadImageInstruction(sourcePath string, prompt string) string {
argsJSON, _ := json.Marshal(map[string]string{"path": sourcePath})
return fmt.Sprintf(
"Call `load_image` first with this exact argument object:\n```json\n%s\n```\n\nDo not ask the user to re-upload the image. Do not modify the path. Do not use `read_file` or any other tool to inspect this image directly.\n\nAfter `load_image` succeeds, treat the loaded image as the current task input and continue with this request:\n%s",
string(argsJSON),
normalizeImagePrompt(prompt),
)
}
func normalizeImagePrompt(prompt string) string {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return defaultImagePrompt
}
return prompt
}

View File

@@ -0,0 +1,299 @@
package picoclaw
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
// JSON-RPC 2.0 types for MCP HTTP transport
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
}
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// MCP tool definitions
var mcpToolDefinitions = []map[string]interface{}{
{
"name": "kvm_screenshot",
"description": "Capture the current HDMI frame from the downstream remote host as a base64-encoded JPEG image.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"width": map[string]interface{}{"type": "integer", "description": "Target width in pixels (optional, default: 960)"},
"height": map[string]interface{}{"type": "integer", "description": "Target height in pixels (optional)"},
"quality": map[string]interface{}{"type": "integer", "description": "JPEG quality 1-100 (optional, default: 60)"},
},
},
},
{
"name": "kvm_actions",
"description": "Send one or more HID actions (click, type, hotkey, scroll, drag, move, wait) to the downstream remote host. Use normalized [0,1] coordinates for mouse actions.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"actions": map[string]interface{}{
"type": "array",
"description": "Array of action objects. Each requires an 'action' field (click, move, type, hotkey, scroll, drag, wait).",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string"},
"x": map[string]interface{}{"type": "number"},
"y": map[string]interface{}{"type": "number"},
"button": map[string]interface{}{"type": "string"},
"text": map[string]interface{}{"type": "string"},
"keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"direction": map[string]interface{}{"type": "string"},
"amount": map[string]interface{}{"type": "integer"},
"duration_ms": map[string]interface{}{"type": "integer"},
"from": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
"to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
},
"required": []string{"action"},
},
},
},
"required": []string{"actions"},
},
},
}
// MCPHandler handles MCP HTTP transport (JSON-RPC 2.0 over POST).
func (s *Service) MCPHandler(c *gin.Context) {
var req jsonRPCRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32700, Message: "parse error"},
})
return
}
if req.JSONRPC != "2.0" {
c.JSON(http.StatusOK, jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32600, Message: "invalid request: jsonrpc must be 2.0"},
})
return
}
var resp jsonRPCResponse
switch req.Method {
case "initialize":
resp = s.mcpInitialize(req)
case "tools/list":
resp = s.mcpToolsList(req)
case "tools/call":
resp = s.mcpToolsCall(req, c)
case "ping":
resp = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{}}
default:
resp = jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)},
}
}
c.JSON(http.StatusOK, resp)
}
func (s *Service) mcpInitialize(req jsonRPCRequest) jsonRPCResponse {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"protocolVersion": "2024-11-05",
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": "nanokvm",
"version": "1.0.0",
},
},
}
}
func (s *Service) mcpToolsList(req jsonRPCRequest) jsonRPCResponse {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"tools": mcpToolDefinitions,
},
}
}
func (s *Service) mcpToolsCall(req jsonRPCRequest, c *gin.Context) jsonRPCResponse {
var params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32602, Message: "invalid params"},
}
}
switch params.Name {
case "kvm_screenshot":
return s.mcpScreenshot(req, params.Arguments, c)
case "kvm_actions":
return s.mcpActions(req, params.Arguments, c)
default:
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32602, Message: fmt.Sprintf("unknown tool: %s", params.Name)},
}
}
}
func (s *Service) mcpScreenshot(req jsonRPCRequest, args json.RawMessage, c *gin.Context) jsonRPCResponse {
var params struct {
Width uint16 `json:"width"`
Height uint16 `json:"height"`
Quality uint16 `json:"quality"`
}
if args != nil {
_ = json.Unmarshal(args, &params)
}
query := ScreenshotQuery{
Format: "base64",
Width: params.Width,
Height: params.Height,
Quality: params.Quality,
}
data, meta, err := s.captureScreenshot(query)
if err != nil {
return mcpToolError(req, err.Message)
}
b64 := base64.StdEncoding.EncodeToString(data)
s.publishMCPObservation(c, "screenshot captured", b64)
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "text",
"text": "screenshot captured",
},
{
"type": "image",
"data": b64,
"mimeType": "image/jpeg",
},
},
"meta": map[string]interface{}{
"source_width": meta.SourceWidth,
"source_height": meta.SourceHeight,
"capture_width": meta.CaptureWidth,
"capture_height": meta.CaptureHeight,
},
},
}
}
func (s *Service) publishMCPObservation(c *gin.Context, text string, imageBase64 string) {
if strings.TrimSpace(imageBase64) == "" {
return
}
sessionID, session, err := s.requireActiveGatewaySession(c)
if err != nil || session == nil || session.Downstream == nil {
return
}
message := newPicoGatewayObservationMessage(sessionID, map[string]any{
"content": text,
"image_base64": imageBase64,
"mime_type": "image/jpeg",
})
if writeErr := session.writeDownstreamJSON(s.config.Get(), message); writeErr != nil {
log.Warnf("failed to deliver picoclaw observation message: %v", writeErr)
}
}
func (s *Service) mcpActions(req jsonRPCRequest, args json.RawMessage, c *gin.Context) jsonRPCResponse {
var params struct {
Actions []Action `json:"actions"`
}
if err := json.Unmarshal(args, &params); err != nil {
return mcpToolError(req, "invalid actions payload")
}
if len(params.Actions) == 0 {
return mcpToolError(req, "empty actions array")
}
sessionID := c.GetHeader(sessionIDHeader)
if sessionID == "" {
sessionID = s.lock.Owner()
}
result, err := s.executeActions(sessionID, params.Actions)
if err != nil {
return mcpToolError(req, err.Message)
}
resultJSON, _ := json.Marshal(result)
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "text",
"text": string(resultJSON),
},
},
},
}
}
func mcpToolError(req jsonRPCRequest, message string) jsonRPCResponse {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"isError": true,
"content": []map[string]interface{}{
{
"type": "text",
"text": message,
},
},
},
}
}

View File

@@ -0,0 +1,225 @@
package picoclaw
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
func extractPicoclawModelName(model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
if index := strings.LastIndex(model, "/"); index >= 0 && index < len(model)-1 {
return strings.TrimSpace(model[index+1:])
}
return model
}
func isPicoclawModelConfigured(cfg picoclawConfigFile, security picoclawSecurityConfig, modelName string) bool {
if modelName == "" {
return false
}
for _, model := range cfg.ModelList {
if strings.TrimSpace(model.ModelName) != modelName {
continue
}
if model.APIBase == "" {
continue
}
if securityHasModelAPIKeys(security, modelName) {
return true
}
return configHasModelAPIKeys(model.APIKey, model.APIKeys)
}
return false
}
func configHasModelAPIKeys(apiKey string, apiKeys []string) bool {
return strings.TrimSpace(apiKey) != "" || len(apiKeys) > 0
}
func securityHasModelAPIKeys(security picoclawSecurityConfig, modelName string) bool {
if modelName == "" || len(security.ModelList) == 0 {
return false
}
if entry, ok := security.ModelList[modelName]; ok && len(entry.APIKeys) > 0 {
return true
}
prefix := modelName + ":"
for key, entry := range security.ModelList {
if strings.HasPrefix(key, prefix) && len(entry.APIKeys) > 0 {
return true
}
}
return false
}
type ModelConfigUpdateRequest struct {
Model string `json:"model"`
APIBase string `json:"api_base"`
APIKey string `json:"api_key"`
}
func (s *Service) UpdateModelConfig(c *gin.Context) {
var req ModelConfigUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid model config payload"))
return
}
currentStatus := s.runtime.Get()
shouldRestart := currentStatus.Ready || currentStatus.Status == "ready"
modelName, err := updatePicoclawModelConfig(
strings.TrimSpace(req.APIBase),
strings.TrimSpace(req.APIKey),
strings.TrimSpace(req.Model),
)
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error()))
return
}
if err := ensurePicoclawStartupDefaults(); err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to persist picoclaw defaults: "+err.Error()))
return
}
if shouldRestart {
if _, _, stopErr := s.stopRuntime(); stopErr != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+stopErr.Message))
return
}
if _, _, startErr := s.startRuntime(); startErr != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+startErr.Message))
return
}
} else {
_ = s.syncConfigFromPicoclaw()
_ = s.ensureRuntimeReady()
}
writeSuccess(c, gin.H{
"model_name": modelName,
"status": s.runtime.Get(),
})
}
func updatePicoclawModelConfig(apiBase string, apiKey string, model string) (string, error) {
if apiBase == "" {
return "", fmt.Errorf("model api_base is required")
}
if apiKey == "" {
return "", fmt.Errorf("model api_key is required")
}
if model == "" {
return "", fmt.Errorf("model identifier is required")
}
modelName := extractPicoclawModelName(model)
if modelName == "" {
return "", fmt.Errorf("model identifier is required")
}
doc, err := loadPicoclawConfigDocument()
if err != nil {
return "", err
}
modelListValue, ok := doc.raw["model_list"].([]any)
if !ok {
modelListValue = []any{}
}
modelUpdated := false
updatedModelIndex := -1
for index, item := range modelListValue {
modelMap, ok := item.(map[string]any)
if !ok {
continue
}
currentModelName := strings.TrimSpace(fmt.Sprintf("%v", modelMap["model_name"]))
if currentModelName != modelName {
continue
}
modelMap["model_name"] = modelName
modelMap["model"] = model
modelMap["api_base"] = apiBase
delete(modelMap, "api_key")
delete(modelMap, "api_keys")
modelUpdated = true
updatedModelIndex = index
break
}
if !modelUpdated {
modelListValue = append(modelListValue, map[string]any{
"model_name": modelName,
"model": model,
"api_base": apiBase,
})
doc.raw["model_list"] = modelListValue
updatedModelIndex = len(modelListValue) - 1
}
agents, ok := doc.raw["agents"].(map[string]any)
if !ok {
agents = map[string]any{}
doc.raw["agents"] = agents
}
defaults, ok := agents["defaults"].(map[string]any)
if !ok {
defaults = map[string]any{}
agents["defaults"] = defaults
}
defaults["model_name"] = modelName
delete(defaults, "model")
if err := doc.saveConfig(); err != nil {
return "", err
}
if doc.security.ModelList == nil {
doc.security.ModelList = map[string]picoclawModelSecurityEntry{}
}
securityModelName := indexedModelName(modelListValue, updatedModelIndex, modelName)
doc.security.ModelList[securityModelName] = picoclawModelSecurityEntry{
APIKeys: []string{apiKey},
}
if err := doc.saveSecurity(); err != nil {
return "", err
}
return modelName, nil
}
func indexedModelName(modelList []any, targetIndex int, modelName string) string {
if targetIndex < 0 {
return modelName
}
currentIndex := 0
for index, item := range modelList {
modelMap, ok := item.(map[string]any)
if !ok {
continue
}
currentModelName := strings.TrimSpace(fmt.Sprintf("%v", modelMap["model_name"]))
if currentModelName != modelName {
continue
}
if index == targetIndex {
return fmt.Sprintf("%s:%d", modelName, currentIndex)
}
currentIndex++
}
return modelName
}

View File

@@ -0,0 +1,22 @@
package picoclaw
import (
"time"
)
const (
picoclawBinaryPath = "/usr/bin/picoclaw"
picoclawCacheDir = "/root/.picoclaw-cache"
picoclawDownloadURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.6/picoclaw_Linux_riscv64.tar.gz"
picoclawChecksumURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.6/sha512.txt"
etcInitPicoclawScript = "/etc/init.d/S96picoclaw"
kvmappPicoclawScript = "/kvmapp/system/init.d/S96picoclaw"
picoclawStartTimeout = 15 * time.Second
picoclawStopTimeout = 15 * time.Second
picoclawOnboardTimeout = 60 * time.Second
picoclawInstallTimeout = 12 * time.Minute
picoclawDownloadTimeout = 10 * time.Minute
picoclawStartWaitPeriod = 1500 * time.Millisecond
picoclawStopWaitPeriod = 500 * time.Millisecond
picoclawReadyPollPeriod = 500 * time.Millisecond
)

View File

@@ -0,0 +1,227 @@
package picoclaw
import (
"crypto/rand"
"encoding/hex"
"fmt"
"reflect"
"strings"
serverConfig "NanoKVM-Server/config"
)
const (
defaultPicoclawPingSec = 30
defaultPicoclawReadSec = 60
defaultPicoclawWriteSec = 10
defaultPicoclawMaxConns = 100
)
type picoclawConfigDefault struct {
path []string
value any
}
var picoclawNanoKVMDefaults = []picoclawConfigDefault{
{path: []string{"agents", "defaults", "restrict_to_workspace"}, value: false},
{path: []string{"agents", "defaults", "allow_read_outside_workspace"}, value: true},
{path: []string{"agents", "defaults", "tool_feedback", "enabled"}, value: true},
{path: []string{"gateway", "host"}, value: defaultPicoclawGatewayHost},
{path: []string{"gateway", "port"}, value: defaultPicoclawGatewayPort},
{path: []string{"gateway", "hot_reload"}, value: false},
{path: []string{"tools", "cron", "allow_command"}, value: true},
{path: []string{"tools", "exec", "allow_remote"}, value: true},
{path: []string{"tools", "exec", "enable_deny_patterns"}, value: false},
{path: []string{"channels", "pico", "allow_token_query"}, value: false},
{path: []string{"channels", "pico", "ping_interval"}, value: defaultPicoclawPingSec},
{path: []string{"channels", "pico", "read_timeout"}, value: defaultPicoclawReadSec},
{path: []string{"channels", "pico", "write_timeout"}, value: defaultPicoclawWriteSec},
{path: []string{"channels", "pico", "max_connections"}, value: defaultPicoclawMaxConns},
{path: []string{"tools", "mcp", "enabled"}, value: true},
}
func ensurePicoclawStartupDefaults() error {
doc, err := loadPicoclawConfigDocument()
if err != nil {
return err
}
editor := &picoclawConfigEditor{raw: doc.raw}
forceEnablePicoclawPicoChannel(doc, editor)
if err := applyPicoclawStartupDefaults(editor); err != nil {
return err
}
tokenChanged, err := ensurePicoclawPicoToken(doc)
if err != nil {
return err
}
if tokenChanged {
if err := doc.saveSecurity(); err != nil {
return err
}
}
if editor.changed {
if err := doc.saveConfig(); err != nil {
return err
}
}
return nil
}
func ensurePicoclawPicoChannelEnabled(doc *picoclawConfigDocument) error {
if doc == nil || doc.config.Channels.Pico.Enabled {
return nil
}
editor := &picoclawConfigEditor{raw: doc.raw}
forceEnablePicoclawPicoChannel(doc, editor)
if !editor.changed {
return nil
}
return doc.saveConfig()
}
func applyPicoclawStartupDefaults(editor *picoclawConfigEditor) error {
for _, entry := range picoclawNanoKVMDefaults {
editor.setValue(entry.value, entry.path...)
}
server, err := defaultPicoclawMCPServer()
if err != nil {
return err
}
editor.setMCPServer("nanokvm", server)
return nil
}
func forceEnablePicoclawPicoChannel(doc *picoclawConfigDocument, editor *picoclawConfigEditor) {
if doc == nil || editor == nil || doc.config.Channels.Pico.Enabled {
return
}
editor.setValue(true, "channels", "pico", "enabled")
doc.config.Channels.Pico.Enabled = true
}
func defaultPicoclawMCPServer() (map[string]any, error) {
internalToken, err := serverConfig.GetPicoclawInternalToken()
if err != nil {
return nil, err
}
return map[string]any{
"enabled": true,
"type": "http",
"url": nanoKVMMCPURL(),
"headers": map[string]any{
serverConfig.PicoclawInternalTokenHeader: internalToken,
},
}, nil
}
func nanoKVMMCPURL() string {
port := serverConfig.GetInstance().Port.Http
if port <= 0 {
port = 80
}
return fmt.Sprintf("http://%s:%d/api/picoclaw/mcp", defaultPicoclawGatewayHost, port)
}
type picoclawConfigEditor struct {
raw map[string]any
changed bool
}
func (e *picoclawConfigEditor) setValue(value any, path ...string) {
if len(path) == 0 {
return
}
target := e.ensureObject(path[:len(path)-1]...)
key := path[len(path)-1]
if current, exists := target[key]; exists && reflect.DeepEqual(current, value) {
return
}
target[key] = value
e.changed = true
}
func (e *picoclawConfigEditor) ensureObject(path ...string) map[string]any {
current := e.raw
for _, key := range path {
next, ok := current[key].(map[string]any)
if !ok {
next = map[string]any{}
current[key] = next
e.changed = true
}
current = next
}
return current
}
func (e *picoclawConfigEditor) setMCPServer(name string, fields map[string]any) {
servers := e.ensureObject("tools", "mcp", "servers")
entry, ok := servers[name].(map[string]any)
if !ok {
entry = map[string]any{}
servers[name] = entry
e.changed = true
}
for key, value := range fields {
if current, exists := entry[key]; exists && reflect.DeepEqual(current, value) {
continue
}
entry[key] = value
e.changed = true
}
}
func generatePicoclawToken() (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("failed to generate picoclaw pico token: %w", err)
}
return hex.EncodeToString(buf), nil
}
func ensurePicoclawPicoToken(doc *picoclawConfigDocument) (bool, error) {
token, err := picoSecurityToken(doc)
if err != nil {
return false, err
}
if doc.security.Channels.Pico == nil {
doc.security.Channels.Pico = &picoclawPicoSecurity{}
}
if strings.TrimSpace(doc.security.Channels.Pico.Token) == token {
return false, nil
}
doc.security.Channels.Pico.Token = token
return true, nil
}
func picoSecurityToken(doc *picoclawConfigDocument) (string, error) {
if doc == nil {
return "", nil
}
if doc.security.Channels.Pico != nil {
if token := strings.TrimSpace(doc.security.Channels.Pico.Token); token != "" {
return token, nil
}
}
if token := strings.TrimSpace(doc.config.Channels.Pico.Token); token != "" {
return token, nil
}
return generatePicoclawToken()
}

View File

@@ -0,0 +1,110 @@
package picoclaw
import (
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
)
func (s *Service) StartRuntime(c *gin.Context) {
command, output, err := s.startRuntime()
if err != nil {
writePicoclawError(c, err)
return
}
writeSuccess(c, RuntimeStartResult{
Started: true,
Command: command,
Output: output,
Status: s.runtime.Get(),
})
}
func (s *Service) StopRuntime(c *gin.Context) {
command, output, err := s.stopRuntime()
if err != nil {
writePicoclawError(c, err)
return
}
writeSuccess(c, RuntimeStartResult{
Started: false,
Command: command,
Output: output,
Status: s.runtime.Get(),
})
}
func (s *Service) InstallRuntime(c *gin.Context) {
output, err := s.installRuntime()
if err != nil {
writePicoclawError(c, err)
return
}
currentStatus := s.runtime.Get()
writeSuccess(c, RuntimeInstallResult{
Installed: currentStatus.Installed && !currentStatus.Installing,
Binary: picoclawBinaryPath,
Download: picoclawDownloadURL,
Output: output,
Status: currentStatus,
})
}
func (s *Service) UninstallRuntime(c *gin.Context) {
currentStatus := s.runtime.Get()
uninstallOutput := "picoclaw uninstalled successfully"
if currentStatus.Installing {
writePicoclawError(c, newPicoclawError(CodeRuntimeStartFailed, "cannot uninstall while installation is in progress"))
return
}
running, err := isRuntimeRunning()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw runtime status"))
return
}
if running || currentStatus.Ready || currentStatus.Status == "ready" {
if _, _, stopErr := s.stopRuntime(); stopErr != nil {
uninstallOutput = "picoclaw uninstalled successfully (stop failed before uninstall: " + stopErr.Message + ")"
}
}
if configPath, err := resolvePicoclawConfigPath(); err == nil {
_ = os.RemoveAll(filepath.Dir(configPath))
} else {
home, err := os.UserHomeDir()
if err == nil {
_ = os.RemoveAll(filepath.Join(home, ".picoclaw"))
} else {
_ = os.RemoveAll("/root/.picoclaw")
}
}
_ = os.Remove(picoclawBinaryPath)
_ = os.RemoveAll(picoclawCacheDir)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallStage: "",
InstallPath: picoclawBinaryPath,
Status: "not_installed",
CheckedAt: time.Now(),
CurrentSession: "",
})
writeSuccess(c, RuntimeInstallResult{
Installed: false,
Binary: picoclawBinaryPath,
Download: picoclawDownloadURL,
Output: uninstallOutput,
Status: s.runtime.Get(),
})
}

View File

@@ -0,0 +1,441 @@
package picoclaw
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
func (s *Service) installRuntime() (string, *PicoclawError) {
log.Debugf("picoclaw install: start, binary=%s, cache=%s", picoclawBinaryPath, picoclawCacheDir)
currentStatus := s.runtime.Get()
if currentStatus.Installing {
log.Debugf("picoclaw install: install already in progress")
return "picoclaw installation is already in progress", nil
}
if installed, err := isPicoclawInstalled(); err == nil && installed {
settings, _ := loadPicoclawGatewaySettings()
log.Debugf("picoclaw install: binary already exists at %s", picoclawBinaryPath)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 100,
InstallStage: "installed",
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "installed",
CheckedAt: time.Now(),
})
return "picoclaw is already installed", nil
}
ctx, cancel := context.WithTimeout(context.Background(), picoclawInstallTimeout)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: true,
InstallProgress: 0,
InstallStage: "preparing",
InstallPath: picoclawBinaryPath,
Status: "installing",
CheckedAt: time.Now(),
})
go s.runInstallRuntime(ctx, cancel)
return "picoclaw installation started", nil
}
func (s *Service) runInstallRuntime(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
_ = os.RemoveAll(picoclawCacheDir)
if err := os.MkdirAll(picoclawCacheDir, 0o755); err != nil {
log.Errorf("picoclaw install: failed to create cache directory %s: %v", picoclawCacheDir, err)
s.finishInstallFailure("install_failed", fmt.Sprintf("failed to create cache directory: %v", err))
return
}
log.Debugf("picoclaw install: cache directory ready at %s", picoclawCacheDir)
defer func() {
if err := os.RemoveAll(picoclawCacheDir); err != nil {
log.Errorf("picoclaw install: failed to clean cache directory %s: %v", picoclawCacheDir, err)
return
}
log.Debugf("picoclaw install: cleaned cache directory %s", picoclawCacheDir)
}()
s.setInstallProgress("downloading", 5, "")
log.Debugf("picoclaw install: downloading checksum from %s", picoclawChecksumURL)
expectedDigest, err := downloadPicoclawChecksum(ctx)
if err != nil {
log.Errorf("picoclaw install: checksum download failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debug("picoclaw install: checksum file downloaded")
archivePath := filepath.Join(picoclawCacheDir, "picoclaw.tar.gz")
log.Debugf("picoclaw install: downloading archive from %s to %s", picoclawDownloadURL, archivePath)
if err := downloadPicoclawArchive(ctx, archivePath, func(downloaded int64, total int64) {
progress := 10
if total > 0 {
progress = 10 + int(float64(downloaded)*70/float64(total))
if progress > 80 {
progress = 80
}
}
s.setInstallProgress("downloading", progress, "")
}); err != nil {
log.Errorf("picoclaw install: download failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: archive download completed")
s.setInstallProgress("verifying", 82, "")
if err := verifyFileSHA512(archivePath, expectedDigest); err != nil {
log.Errorf("picoclaw install: checksum verification failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: archive checksum verified for %s", archivePath)
s.setInstallProgress("extracting", 85, "")
log.Debugf("picoclaw install: extracting binary from %s", archivePath)
extractedPath, err := extractPicoclawBinary(archivePath, picoclawCacheDir)
if err != nil {
log.Errorf("picoclaw install: extract failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: extracted binary to %s", extractedPath)
s.setInstallProgress("installing", 95, "")
log.Debugf("picoclaw install: installing binary to %s", picoclawBinaryPath)
if err := installPicoclawBinary(extractedPath, picoclawBinaryPath); err != nil {
log.Errorf("picoclaw install: install failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: install completed successfully")
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 100,
InstallStage: "installed",
InstallPath: picoclawBinaryPath,
Status: "installed",
CheckedAt: time.Now(),
})
}
func downloadPicoclawArchive(ctx context.Context, destination string, onProgress func(downloaded int64, total int64)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, picoclawDownloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
client := &http.Client{
Timeout: picoclawDownloadTimeout,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download picoclaw: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download picoclaw: unexpected status %s", resp.Status)
}
file, err := os.Create(destination)
if err != nil {
return fmt.Errorf("failed to create archive file: %w", err)
}
defer file.Close()
if err := copyWithProgress(ctx, file, resp.Body, resp.ContentLength, onProgress); err != nil {
return fmt.Errorf("failed to save archive: %w", err)
}
return nil
}
func downloadPicoclawChecksum(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, picoclawChecksumURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create checksum request: %w", err)
}
client := &http.Client{
Timeout: picoclawDownloadTimeout,
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to download picoclaw checksum: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to download picoclaw checksum: unexpected status %s", resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 16*1024))
if err != nil {
return "", fmt.Errorf("failed to read checksum file: %w", err)
}
digest, err := parseSHA512Digest(string(data), filepath.Base(picoclawDownloadURL))
if err != nil {
return "", err
}
return digest, nil
}
func copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader, total int64, onProgress func(downloaded int64, total int64)) error {
buffer := make([]byte, 32*1024)
var downloaded int64
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
n, readErr := src.Read(buffer)
if n > 0 {
if _, writeErr := dst.Write(buffer[:n]); writeErr != nil {
return writeErr
}
downloaded += int64(n)
if onProgress != nil {
onProgress(downloaded, total)
}
}
if readErr == io.EOF {
if onProgress != nil {
onProgress(downloaded, total)
}
return nil
}
if readErr != nil {
return readErr
}
}
}
func (s *Service) setInstallProgress(stage string, progress int, lastError string) {
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
s.runtime.UpdateInstallStatus(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = false
status.Installing = true
status.InstallProgress = progress
status.InstallStage = stage
status.Status = "installing"
status.LastError = lastError
status.CheckedAt = time.Now()
})
}
func (s *Service) finishInstallFailure(status string, message string) {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallStage: status,
InstallPath: picoclawBinaryPath,
Status: status,
LastError: message,
CheckedAt: time.Now(),
})
}
func installFailureStatus(err error) string {
if err == nil {
return "install_failed"
}
if errors.Is(err, context.DeadlineExceeded) {
return "install_timeout"
}
return "install_failed"
}
func parseSHA512Digest(raw string, expectedName string) (string, error) {
lines := strings.Split(raw, "\n")
var fallback string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
fields := strings.Fields(trimmed)
if len(fields) == 0 {
continue
}
digest := fields[0]
if !isValidSHA512Digest(digest) {
continue
}
if len(fields) == 1 {
if fallback == "" {
fallback = strings.ToLower(digest)
}
continue
}
name := strings.TrimPrefix(fields[len(fields)-1], "*")
if expectedName == "" || name == expectedName {
return strings.ToLower(digest), nil
}
}
if fallback != "" {
return fallback, nil
}
return "", fmt.Errorf("failed to parse sha512 digest from checksum file")
}
func isValidSHA512Digest(value string) bool {
if len(value) != sha512.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func verifyFileSHA512(filePath string, expectedDigest string) error {
expectedDigest = strings.ToLower(strings.TrimSpace(expectedDigest))
if !isValidSHA512Digest(expectedDigest) {
return fmt.Errorf("invalid expected sha512 digest")
}
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open file for sha512 verification: %w", err)
}
defer file.Close()
hasher := sha512.New()
if _, err := io.Copy(hasher, file); err != nil {
return fmt.Errorf("failed to hash file for sha512 verification: %w", err)
}
actualDigest := hex.EncodeToString(hasher.Sum(nil))
if actualDigest != expectedDigest {
return fmt.Errorf("sha512 mismatch: got %s", actualDigest)
}
return nil
}
func extractPicoclawBinary(archivePath string, destinationDir string) (string, error) {
file, err := os.Open(archivePath)
if err != nil {
return "", fmt.Errorf("failed to open archive: %w", err)
}
defer file.Close()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return "", fmt.Errorf("failed to read archive: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("failed to extract archive: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
if filepath.Base(header.Name) != "picoclaw" {
continue
}
extractedPath := filepath.Join(destinationDir, "picoclaw")
outFile, err := os.OpenFile(extractedPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return "", fmt.Errorf("failed to create extracted binary: %w", err)
}
if _, err := io.Copy(outFile, tarReader); err != nil {
_ = outFile.Close()
return "", fmt.Errorf("failed to extract picoclaw binary: %w", err)
}
if err := outFile.Close(); err != nil {
return "", fmt.Errorf("failed to finalize extracted binary: %w", err)
}
return extractedPath, nil
}
return "", fmt.Errorf("picoclaw binary not found in archive")
}
func installPicoclawBinary(source string, destination string) error {
inFile, err := os.Open(source)
if err != nil {
return fmt.Errorf("failed to open extracted picoclaw binary: %w", err)
}
defer inFile.Close()
tempDestination := destination + ".tmp"
outFile, err := os.OpenFile(tempDestination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return fmt.Errorf("failed to create destination binary: %w", err)
}
if _, err := io.Copy(outFile, inFile); err != nil {
_ = outFile.Close()
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to write destination binary: %w", err)
}
if err := outFile.Close(); err != nil {
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to finalize destination binary: %w", err)
}
if err := os.Chmod(tempDestination, 0o755); err != nil {
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to set destination mode: %w", err)
}
if err := os.Rename(tempDestination, destination); err != nil {
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to install picoclaw binary: %w", err)
}
return nil
}

View File

@@ -0,0 +1,31 @@
package picoclaw
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func (s *Service) ReleaseRuntimeSession(c *gin.Context) {
sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader))
if sessionID == "" {
writePicoclawError(c, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID"))
return
}
if session, ok := GetSessionManager().Get(sessionID); ok {
s.closeGatewaySession(session, websocket.CloseNormalClosure, "session released")
} else {
ReleaseSession(sessionID)
}
status := s.runtime.Get()
status.CurrentSession = s.lock.Owner()
s.runtime.Set(status)
writeSuccess(c, gin.H{
"released": true,
"current_session": status.CurrentSession,
})
}

View File

@@ -0,0 +1,228 @@
package picoclaw
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
func (s *Service) startRuntime() (string, string, *PicoclawError) {
if installed, statErr := isPicoclawInstalled(); statErr != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = false
status.ModelConfigured = false
status.ModelName = ""
status.Status = "not_installed"
status.LastError = statErr.Error()
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, statErr.Error())
} else if !installed {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = false
status.ModelConfigured = false
status.ModelName = ""
status.Status = "not_installed"
status.LastError = "picoclaw is not installed"
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw is not installed")
}
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
status.Status = "unavailable"
status.LastError = err.Error()
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
}
if err := ensurePicoclawStartupDefaults(); err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
status.Status = "config_error"
status.ConfigError = err.Error()
status.LastError = err.Error()
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
}
command := scriptPath + " start"
ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
status.Status = "unavailable"
status.LastError = trimmedOutput
if status.LastError == "" {
status.LastError = execErr.Error()
}
status.CheckedAt = time.Now()
})
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to start picoclaw runtime")
}
time.Sleep(picoclawStartWaitPeriod)
if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil {
startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message)
return command, trimmedOutput, startErr
}
return command, trimmedOutput, nil
}
func (s *Service) stopRuntime() (string, string, *PicoclawError) {
settings, _ := loadPicoclawGatewaySettings()
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "unavailable",
LastError: err.Error(),
CheckedAt: time.Now(),
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
}
command := scriptPath + " stop"
ctx, cancel := context.WithTimeout(context.Background(), picoclawStopTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
status := RuntimeStatus{
Ready: false,
Installed: true,
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "error",
LastError: trimmedOutput,
CheckedAt: time.Now(),
}
if trimmedOutput == "" {
status.LastError = execErr.Error()
}
s.runtime.Set(status)
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to stop picoclaw runtime")
}
time.Sleep(picoclawStopWaitPeriod)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "stopped",
CheckedAt: time.Now(),
CurrentSession: "",
})
return command, trimmedOutput, nil
}
func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
deadline := time.Now().Add(timeout)
var lastErr *PicoclawError
for {
if runtimeErr := s.ensureRuntimeReady(); runtimeErr == nil {
return nil
} else {
lastErr = runtimeErr
}
if time.Now().After(deadline) {
break
}
time.Sleep(picoclawReadyPollPeriod)
}
if lastErr != nil {
return lastErr
}
return newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
}
func resolvePicoclawStartScript() (string, error) {
if _, err := os.Stat(etcInitPicoclawScript); err == nil {
return etcInitPicoclawScript, nil
}
if _, err := os.Stat(kvmappPicoclawScript); err == nil {
return kvmappPicoclawScript, nil
}
return "", fmt.Errorf("picoclaw start script not found: %s or %s", etcInitPicoclawScript, kvmappPicoclawScript)
}
func runPicoclawOnboard() (string, *PicoclawError) {
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
return "", newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
command := scriptPath + " onboard"
ctx, cancel := context.WithTimeout(context.Background(), picoclawOnboardTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
if trimmedOutput == "" {
trimmedOutput = execErr.Error()
}
return trimmedOutput, newPicoclawError(CodeRuntimeUnavailable, "failed to initialize picoclaw config")
}
return trimmedOutput, nil
}
func isPicoclawInstalled() (bool, error) {
info, err := os.Stat(picoclawBinaryPath)
if err == nil {
return !info.IsDir(), nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func isRuntimeRunning() (bool, error) {
binName := filepath.Base(picoclawBinaryPath)
if binName == "" || binName == "." || binName == string(filepath.Separator) {
return false, fmt.Errorf("invalid picoclaw binary path: %s", picoclawBinaryPath)
}
command := exec.Command("pidof", binName)
if err := command.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, err
}
return true, nil
}

View File

@@ -0,0 +1,173 @@
package picoclaw
import (
"encoding/base64"
"net/http"
"time"
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream/mjpeg"
"github.com/gin-gonic/gin"
)
var screenshotRetryDelay = 100 * time.Millisecond
const (
screenshotRetryCount = 3
cachedFrameMaxAge = 2 * time.Second
defaultPicoclawScreenshotWidth = 960
defaultPicoclawScreenshotHeight = 540
defaultPicoclawScreenshotQuality = 60
)
func (s *Service) Screenshot(c *gin.Context) {
var query ScreenshotQuery
if err := c.ShouldBindQuery(&query); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid screenshot query"))
return
}
sessionID, sessionErr := s.requireSessionID(c)
if sessionErr != nil {
writePicoclawError(c, sessionErr)
return
}
releaseAfter, lockErr := s.lock.AcquireTemporary(sessionID)
if lockErr != nil {
writePicoclawError(c, lockErr)
return
}
if releaseAfter {
defer s.lock.Release(sessionID)
}
data, meta, err := s.captureScreenshot(query)
if err != nil {
writePicoclawError(c, err)
return
}
if query.Format == "base64" {
meta.ImageBase64 = base64.StdEncoding.EncodeToString(data)
writeSuccess(c, meta)
return
}
c.Data(http.StatusOK, "image/jpeg", data)
}
func (s *Service) captureScreenshot(query ScreenshotQuery) ([]byte, ScreenshotMeta, *PicoclawError) {
width, height, quality := resolveScreenshotRequest(query)
if canUseCachedFrame(query) {
if frame, ok := mjpeg.GetLatestFrame(); ok && time.Since(frame.CapturedAt) <= cachedFrameMaxAge {
return frame.Data, ScreenshotMeta{
SourceWidth: frame.Width,
SourceHeight: frame.Height,
CaptureWidth: frame.Width,
CaptureHeight: frame.Height,
Format: "jpeg",
}, nil
}
}
screen := common.GetScreen()
common.CheckScreen()
for attempt := 0; attempt < screenshotRetryCount; attempt++ {
data, result := s.vision.ReadMjpeg(width, height, quality)
switch {
case result == 5:
if attempt < screenshotRetryCount-1 {
time.Sleep(screenshotRetryDelay)
continue
}
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotNoSignal, "no HDMI signal or frame unavailable")
case result < 0 || len(data) == 0:
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "failed to capture screenshot")
default:
return data, ScreenshotMeta{
SourceWidth: screen.Width,
SourceHeight: screen.Height,
CaptureWidth: width,
CaptureHeight: height,
Format: "jpeg",
}, nil
}
}
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "failed to capture screenshot")
}
func canUseCachedFrame(query ScreenshotQuery) bool {
return query.Width == 0 && query.Height == 0 && query.Quality == 0
}
func resolveScreenshotRequest(query ScreenshotQuery) (uint16, uint16, uint16) {
screen := common.GetScreen()
width := screen.Width
height := screen.Height
quality := screen.Quality
if query.Format == "base64" {
width, height = fitWithinBounds(width, height, defaultPicoclawScreenshotWidth, defaultPicoclawScreenshotHeight)
if quality == 0 || quality > defaultPicoclawScreenshotQuality {
quality = defaultPicoclawScreenshotQuality
}
}
width, height = applyRequestedDimensions(width, height, query.Width, query.Height)
if query.Quality > 0 {
quality = query.Quality
}
return width, height, quality
}
func applyRequestedDimensions(defaultWidth uint16, defaultHeight uint16, requestedWidth uint16, requestedHeight uint16) (uint16, uint16) {
switch {
case requestedWidth > 0 && requestedHeight > 0:
return requestedWidth, requestedHeight
case requestedWidth > 0:
return fitWithinBounds(defaultWidth, defaultHeight, requestedWidth, 0)
case requestedHeight > 0:
return fitWithinBounds(defaultWidth, defaultHeight, 0, requestedHeight)
default:
return defaultWidth, defaultHeight
}
}
func fitWithinBounds(sourceWidth uint16, sourceHeight uint16, maxWidth uint16, maxHeight uint16) (uint16, uint16) {
if sourceWidth == 0 || sourceHeight == 0 {
return sourceWidth, sourceHeight
}
if maxWidth == 0 && maxHeight == 0 {
return sourceWidth, sourceHeight
}
width := int(sourceWidth)
height := int(sourceHeight)
limitedWidth := width
limitedHeight := height
if maxWidth > 0 && limitedWidth > int(maxWidth) {
limitedWidth = int(maxWidth)
limitedHeight = height * limitedWidth / width
}
if maxHeight > 0 && limitedHeight > int(maxHeight) {
limitedHeight = int(maxHeight)
limitedWidth = width * limitedHeight / height
}
if limitedWidth <= 0 {
limitedWidth = 1
}
if limitedHeight <= 0 {
limitedHeight = 1
}
return uint16(limitedWidth), uint16(limitedHeight)
}

View File

@@ -0,0 +1,385 @@
package picoclaw
import (
"net"
"net/url"
"os"
"sync"
"time"
"NanoKVM-Server/common"
"NanoKVM-Server/service/hid"
"github.com/gin-gonic/gin"
)
const sessionIDHeader = "X-PicoClaw-Session-ID"
var (
configStoreOnce sync.Once
configStore *ConfigStore
runtimeStoreOnce sync.Once
runtimeStore *RuntimeStore
probeLoopOnce sync.Once
)
const runtimeStatusRefreshInterval = 2 * time.Second
func NewService() *Service {
return &Service{
vision: common.GetKvmVision(),
hid: hid.GetHid(),
config: getConfigStore(),
lock: GetSessionLock(),
runtime: getRuntimeStore(),
}
}
func getConfigStore() *ConfigStore {
configStoreOnce.Do(func() {
configStore = &ConfigStore{
config: defaultConfig(),
}
})
return configStore
}
func getRuntimeStore() *RuntimeStore {
runtimeStoreOnce.Do(func() {
runtimeStore = &RuntimeStore{
status: RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "checking",
},
}
})
return runtimeStore
}
func (s *Service) GetRuntimeStatus(c *gin.Context) {
s.startRuntimeProbeLoop()
status := s.runtime.Get()
if shouldRefreshRuntimeStatus(status) {
_ = s.ensureRuntimeReady()
status = s.runtime.Get()
}
if installed, ok := picoclawInstalledState(); ok {
status.Installed = installed
s.runtime.Update(func(current *RuntimeStatus) {
current.Installed = installed
})
}
writeSuccess(c, withAgentProfile(status))
}
func (s *Service) GetRuntimeSession(c *gin.Context) {
writeSuccess(c, gin.H{
"current_session": s.lock.Owner(),
"checked_at": time.Now(),
})
}
func (s *Service) requireSessionID(c *gin.Context) (string, *PicoclawError) {
sessionID := c.GetHeader(sessionIDHeader)
if sessionID == "" {
return "", newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID")
}
return sessionID, nil
}
func (s *ConfigStore) Get() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return s.config
}
func (s *ConfigStore) Set(cfg Config) {
s.mu.Lock()
defer s.mu.Unlock()
s.config = cfg
}
func (s *RuntimeStore) Get() RuntimeStatus {
s.mu.RLock()
defer s.mu.RUnlock()
return s.status
}
func (s *RuntimeStore) Set(status RuntimeStatus) {
s.mu.Lock()
defer s.mu.Unlock()
status.InstallPath = picoclawBinaryPath
s.status = status
}
func (s *RuntimeStore) Update(update func(*RuntimeStatus)) {
s.mu.Lock()
defer s.mu.Unlock()
update(&s.status)
s.status.InstallPath = picoclawBinaryPath
}
func (s *RuntimeStore) UpdateInstallStatus(update func(*RuntimeStatus)) {
s.mu.Lock()
defer s.mu.Unlock()
update(&s.status)
s.status.InstallPath = picoclawBinaryPath
}
func (s *Service) ensureRuntimeReady() *PicoclawError {
currentStatus := s.runtime.Get()
if currentStatus.Installing {
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress")
}
installed, statErr := isPicoclawInstalled()
if statErr != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
Status: "not_installed",
LastError: statErr.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw installation")
}
if !installed {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "not_installed",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw is not installed")
}
configPath, pathErr := resolvePicoclawConfigPath()
if pathErr != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "model_not_configured",
LastError: pathErr.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
if _, err := os.Stat(configPath); err != nil {
if os.IsNotExist(err) {
if _, onboardErr := runPicoclawOnboard(); onboardErr != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "model_not_configured",
LastError: onboardErr.Message,
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
if _, statErr := os.Stat(configPath); statErr == nil {
goto configReady
}
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "model_not_configured",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "config_error",
ConfigError: err.Error(),
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
configReady:
if syncErr := s.syncConfigFromPicoclaw(); syncErr != nil {
return syncErr
}
settings, settingsErr := loadPicoclawGatewaySettings()
if settingsErr != nil {
return newPicoclawError(CodeRuntimeUnavailable, settingsErr.Error())
}
if !settings.ModelConfigured {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
ModelName: settings.TargetModelName,
Status: "model_not_configured",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
cfg := s.config.Get()
parsed, err := url.Parse(cfg.GatewayURL)
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "config_error",
ConfigError: "invalid gateway url",
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
if parsed.Scheme != "ws" && parsed.Scheme != "wss" {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "config_error",
ConfigError: "invalid gateway url scheme",
LastError: parsed.Scheme,
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
timeout := time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond
hostPort, err := gatewayHostPort(parsed)
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "config_error",
ConfigError: err.Error(),
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
conn, err := net.DialTimeout("tcp", hostPort, timeout)
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "unavailable",
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
}
_ = conn.Close()
s.runtime.Set(RuntimeStatus{
Ready: true,
Installed: true,
Installing: false,
InstallProgress: 100,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "ready",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return nil
}
func (s *Service) startRuntimeProbeLoop() {
probeLoopOnce.Do(func() {
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
_ = s.ensureRuntimeReady()
}
}()
})
}
func shouldRefreshRuntimeStatus(status RuntimeStatus) bool {
if status.Installing {
return false
}
if status.CheckedAt.IsZero() {
return true
}
return time.Since(status.CheckedAt) >= runtimeStatusRefreshInterval
}
func picoclawInstalledState() (bool, bool) {
installed, err := isPicoclawInstalled()
if err != nil {
return false, false
}
return installed, true
}

View File

@@ -0,0 +1,52 @@
package picoclaw
import (
"os"
"path/filepath"
"NanoKVM-Server/service/hid"
log "github.com/sirupsen/logrus"
)
const picoclawMediaTempDirName = "picoclaw_media"
func ReleaseSession(sessionID string) {
GetSessionLock().Release(sessionID)
releaseAllHIDState()
}
func releaseAllHIDState() {
h := hid.GetHid()
h.WriteHid0([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
h.WriteHid1([]byte{0x00, 0x00, 0x00, 0x00})
h.WriteHid2([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
}
func (s *Service) releaseAllHIDState() {
s.hid.WriteHid0([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
s.hid.WriteHid1([]byte{0x00, 0x00, 0x00, 0x00})
s.hid.WriteHid2([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
}
func picoclawMediaTempDir() string {
return filepath.Join(os.TempDir(), picoclawMediaTempDirName)
}
func cleanupPicoclawMediaTempDir() {
mediaDir := picoclawMediaTempDir()
if _, err := os.Stat(mediaDir); err != nil {
if !os.IsNotExist(err) {
log.Warnf("failed to stat picoclaw media directory %s: %v", mediaDir, err)
}
return
}
if err := os.RemoveAll(mediaDir); err != nil {
log.Warnf("failed to remove picoclaw media directory %s: %v", mediaDir, err)
return
}
log.Infof("removed picoclaw media directory %s", mediaDir)
}

View File

@@ -0,0 +1,474 @@
package picoclaw
import (
"bufio"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
picoSessionPrefix = "agent:main:pico:direct:pico:"
sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
maxSessionJSONLLineSize = 10 * 1024 * 1024
maxSessionPreviewRunes = 60
defaultSessionLimit = 20
)
type sessionStoredMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type sessionStoredFile struct {
Key string `json:"key"`
Messages []sessionStoredMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
type sessionMetaFile struct {
Key string `json:"key"`
Summary string `json:"summary"`
Skip int `json:"skip"`
Count int `json:"count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type sessionListItem struct {
ID string `json:"id"`
Title string `json:"title"`
Preview string `json:"preview"`
MessageCount int `json:"message_count"`
Created string `json:"created"`
Updated string `json:"updated"`
}
type sessionDetailMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type sessionDetail struct {
ID string `json:"id"`
Messages []sessionDetailMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
Created string `json:"created"`
Updated string `json:"updated"`
}
func resolvePicoclawWorkspacePath() (string, error) {
configPath, err := resolvePicoclawConfigPath()
if err != nil {
return "", err
}
if doc, err := loadPicoclawConfigDocument(); err == nil {
if workspace := expandPicoclawPath(doc.config.Agents.Defaults.Workspace); workspace != "" {
return workspace, nil
}
}
return filepath.Join(filepath.Dir(configPath), "workspace"), nil
}
func resolvePicoclawSessionsPath() (string, error) {
workspacePath, err := resolvePicoclawWorkspacePath()
if err != nil {
return "", err
}
return filepath.Join(workspacePath, "sessions"), nil
}
func sanitizeSessionKey(key string) string {
return strings.ReplaceAll(key, ":", "_")
}
func extractPicoSessionID(key string) (string, bool) {
if strings.HasPrefix(key, picoSessionPrefix) {
return strings.TrimPrefix(key, picoSessionPrefix), true
}
return "", false
}
func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
if strings.HasPrefix(key, sanitizedPicoSessionPrefix) {
return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true
}
return "", false
}
func (s *Service) ListSessions(c *gin.Context) {
dir, err := resolvePicoclawSessionsPath()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
return
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
writeSuccess(c, []sessionListItem{})
return
}
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to read sessions directory"))
return
}
items := make([]sessionListItem, 0)
seen := make(map[string]struct{})
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
var (
sessionID string
sess sessionStoredFile
loadErr error
ok bool
)
switch {
case strings.HasSuffix(name, ".jsonl"):
sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
if !ok {
continue
}
sess, loadErr = readJSONLSession(dir, sessionID)
if loadErr == nil && isEmptySession(sess) {
continue
}
case strings.HasSuffix(name, ".meta.json"):
continue
case strings.HasSuffix(name, ".json"):
base := strings.TrimSuffix(name, ".json")
if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found {
if jsonlSess, jsonlErr := readJSONLSession(dir, jsonlSessionID); jsonlErr == nil && !isEmptySession(jsonlSess) {
continue
}
}
}
sess, loadErr = readLegacySession(dir, name)
if loadErr == nil && isEmptySession(sess) {
continue
}
if loadErr == nil {
sessionID, ok = extractPicoSessionID(sess.Key)
if !ok {
continue
}
}
default:
continue
}
if loadErr != nil {
continue
}
if _, exists := seen[sessionID]; exists {
continue
}
seen[sessionID] = struct{}{}
items = append(items, buildSessionListItem(sessionID, sess))
}
sort.Slice(items, func(i, j int) bool {
return items[i].Updated > items[j].Updated
})
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", strconv.Itoa(defaultSessionLimit)))
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = defaultSessionLimit
}
if offset >= len(items) {
writeSuccess(c, []sessionListItem{})
return
}
end := offset + limit
if end > len(items) {
end = len(items)
}
writeSuccess(c, items[offset:end])
}
func (s *Service) GetSession(c *gin.Context) {
sessionID := strings.TrimSpace(c.Param("id"))
if sessionID == "" {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "missing session id"))
return
}
dir, err := resolvePicoclawSessionsPath()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
return
}
sess, err := readJSONLSession(dir, sessionID)
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
if err != nil {
if errors.Is(err, os.ErrNotExist) {
sess, err = readLegacySession(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json")
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
}
if err != nil {
sessionErr := newPicoclawError(CodeRuntimeUnavailable, "session not found")
sessionErr.StatusCode = http.StatusNotFound
writePicoclawError(c, sessionErr)
return
}
}
messages := make([]sessionDetailMessage, 0, len(sess.Messages))
for _, msg := range sess.Messages {
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
messages = append(messages, sessionDetailMessage{
Role: msg.Role,
Content: msg.Content,
})
}
}
writeSuccess(c, sessionDetail{
ID: sessionID,
Messages: messages,
Summary: sess.Summary,
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
})
}
func (s *Service) DeleteSession(c *gin.Context) {
sessionID := strings.TrimSpace(c.Param("id"))
if sessionID == "" {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "missing session id"))
return
}
dir, err := resolvePicoclawSessionsPath()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
return
}
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
paths := []string{base + ".jsonl", base + ".meta.json", base + ".json"}
removed := false
for _, path := range paths {
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
continue
}
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to delete session"))
return
}
removed = true
}
if !removed {
sessionErr := newPicoclawError(CodeRuntimeUnavailable, "session not found")
sessionErr.StatusCode = http.StatusNotFound
writePicoclawError(c, sessionErr)
return
}
writeSuccess(c, gin.H{
"id": sessionID,
"deleted": true,
})
}
func readLegacySession(dir, fileName string) (sessionStoredFile, error) {
data, err := os.ReadFile(filepath.Join(dir, fileName))
if err != nil {
return sessionStoredFile{}, err
}
var sess sessionStoredFile
if err := json.Unmarshal(data, &sess); err != nil {
return sessionStoredFile{}, err
}
return sess, nil
}
func readSessionMeta(path, sessionKey string) (sessionMetaFile, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return sessionMetaFile{Key: sessionKey}, nil
}
if err != nil {
return sessionMetaFile{}, err
}
var meta sessionMetaFile
if err := json.Unmarshal(data, &meta); err != nil {
return sessionMetaFile{}, err
}
if meta.Key == "" {
meta.Key = sessionKey
}
return meta, nil
}
func readSessionMessages(path string, skip int) ([]sessionStoredMessage, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
messages := make([]sessionStoredMessage, 0)
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize)
seen := 0
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
seen++
if seen <= skip {
continue
}
var msg sessionStoredMessage
if err := json.Unmarshal(line, &msg); err != nil {
continue
}
messages = append(messages, msg)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return messages, nil
}
func readJSONLSession(dir, sessionID string) (sessionStoredFile, error) {
sessionKey := picoSessionPrefix + sessionID
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
meta, err := readSessionMeta(metaPath, sessionKey)
if err != nil {
return sessionStoredFile{}, err
}
messages, err := readSessionMessages(jsonlPath, meta.Skip)
if err != nil {
return sessionStoredFile{}, err
}
created := meta.CreatedAt
updated := meta.UpdatedAt
if created.IsZero() || updated.IsZero() {
if info, statErr := os.Stat(jsonlPath); statErr == nil {
if created.IsZero() {
created = info.ModTime()
}
if updated.IsZero() {
updated = info.ModTime()
}
}
}
return sessionStoredFile{
Key: meta.Key,
Messages: messages,
Summary: meta.Summary,
Created: created,
Updated: updated,
}, nil
}
func buildSessionListItem(sessionID string, sess sessionStoredFile) sessionListItem {
preview := ""
for _, msg := range sess.Messages {
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
preview = msg.Content
break
}
}
title := strings.TrimSpace(sess.Summary)
if title == "" {
title = preview
}
title = truncateRunes(title, maxSessionPreviewRunes)
preview = truncateRunes(preview, maxSessionPreviewRunes)
if preview == "" {
preview = "(empty)"
}
if title == "" {
title = preview
}
messageCount := 0
for _, msg := range sess.Messages {
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
messageCount++
}
}
return sessionListItem{
ID: sessionID,
Title: title,
Preview: preview,
MessageCount: messageCount,
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
}
}
func isEmptySession(sess sessionStoredFile) bool {
return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == ""
}
func truncateRunes(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(strings.TrimSpace(s))
if len(runes) <= maxLen {
return string(runes)
}
return string(runes[:maxLen]) + "..."
}

View File

@@ -0,0 +1,97 @@
package picoclaw
import (
"sync"
"time"
)
var (
sessionLockOnce sync.Once
sessionLock *SessionLock
)
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) acquire(sessionID string) (bool, *PicoclawError) {
if sessionID == "" {
return false, newPicoclawError(CodeSessionIDInvalid, "invalid PicoClaw session")
}
l.mu.Lock()
defer l.mu.Unlock()
if l.ownerSessionID == "" || l.ownerSessionID == sessionID {
acquired := l.ownerSessionID == ""
now := time.Now()
l.ownerSessionID = sessionID
if l.acquiredAt.IsZero() {
l.acquiredAt = now
}
l.expiresAt = now.Add(30 * time.Minute)
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()
l.ownerSessionID = sessionID
l.acquiredAt = time.Now()
l.expiresAt = time.Now().Add(30 * time.Minute)
}
func (l *SessionLock) Owner() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.ownerSessionID
}
func (l *SessionLock) BlocksManualInput() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.ownerSessionID != ""
}

View File

@@ -0,0 +1,94 @@
package picoclaw
import (
"sync"
"time"
"github.com/gorilla/websocket"
)
var (
sessionManagerOnce sync.Once
sessionManager *SessionManager
)
func GetSessionManager() *SessionManager {
sessionManagerOnce.Do(func() {
sessionManager = &SessionManager{
sessions: make(map[string]*GatewaySession),
}
})
return sessionManager
}
func (m *SessionManager) Register(sessionID string, downstream *websocket.Conn) (*GatewaySession, *PicoclawError) {
m.mu.Lock()
defer m.mu.Unlock()
if existing, ok := m.sessions[sessionID]; ok {
switch existing.State {
case SessionStateClosing, SessionStateClosed:
delete(m.sessions, sessionID)
default:
err := newPicoclawError(CodePicoclawLockHeld, "session is already connected")
err.SessionID = sessionID
return nil, err
}
}
now := time.Now()
session := &GatewaySession{
SessionID: sessionID,
State: SessionStateCreated,
Downstream: downstream,
CreatedAt: now,
UpdatedAt: now,
}
m.sessions[sessionID] = session
return session, nil
}
func (m *SessionManager) Get(sessionID string) (*GatewaySession, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
session, ok := m.sessions[sessionID]
return session, ok
}
func (m *SessionManager) SetState(sessionID string, state SessionState) {
m.mu.Lock()
defer m.mu.Unlock()
if session, ok := m.sessions[sessionID]; ok {
session.State = state
session.UpdatedAt = time.Now()
}
}
func (m *SessionManager) AttachUpstream(sessionID string, upstream *websocket.Conn) {
m.mu.Lock()
defer m.mu.Unlock()
if session, ok := m.sessions[sessionID]; ok {
session.Upstream = upstream
session.UpdatedAt = time.Now()
}
}
func (m *SessionManager) AttachDownstream(sessionID string, downstream *websocket.Conn) {
m.mu.Lock()
defer m.mu.Unlock()
if session, ok := m.sessions[sessionID]; ok {
session.Downstream = downstream
session.UpdatedAt = time.Now()
}
}
func (m *SessionManager) Remove(sessionID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.sessions, sessionID)
}

View File

@@ -0,0 +1,217 @@
package picoclaw
import (
"encoding/json"
"reflect"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
type VisionReader interface {
ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int)
}
type HIDWriter interface {
WriteHid0(data []byte)
WriteHid1(data []byte)
WriteHid2(data []byte)
}
type Service struct {
vision VisionReader
hid HIDWriter
config *ConfigStore
lock *SessionLock
runtime *RuntimeStore
}
type ConfigStore struct {
mu sync.RWMutex
config Config
}
type RuntimeStore struct {
mu sync.RWMutex
status RuntimeStatus
}
type Config struct {
GatewayURL string `json:"gateway_url"`
ConnectTimeoutMs int `json:"connect_timeout_ms"`
ReadTimeoutMs int `json:"read_timeout_ms"`
WriteTimeoutMs int `json:"write_timeout_ms"`
PingIntervalMs int `json:"ping_interval_ms"`
MaxMessageBytes int `json:"max_message_bytes"`
AllowTokenQuery bool `json:"allow_token_query"`
Token string `json:"token,omitempty"`
}
type RuntimeStatus struct {
Ready bool `json:"ready"`
Installed bool `json:"installed"`
Installing bool `json:"installing"`
InstallProgress int `json:"install_progress,omitempty"`
InstallStage string `json:"install_stage,omitempty"`
InstallPath string `json:"install_path,omitempty"`
AgentProfile string `json:"agent_profile,omitempty"`
ModelConfigured bool `json:"model_configured"`
ModelName string `json:"model_name,omitempty"`
Status string `json:"status"`
ConfigError string `json:"config_error,omitempty"`
LastError string `json:"last_error,omitempty"`
CheckedAt time.Time `json:"checked_at,omitempty"`
CurrentSession string `json:"current_session,omitempty"`
}
type RuntimeStartResult struct {
Started bool `json:"started"`
Command string `json:"command"`
Output string `json:"output,omitempty"`
Status RuntimeStatus `json:"status"`
}
type RuntimeInstallResult struct {
Installed bool `json:"installed"`
Binary string `json:"binary"`
Download string `json:"download"`
Output string `json:"output,omitempty"`
Status RuntimeStatus `json:"status"`
}
type ScreenshotMeta struct {
ImageBase64 string `json:"image_base64,omitempty"`
SourceWidth uint16 `json:"source_width"`
SourceHeight uint16 `json:"source_height"`
CaptureWidth uint16 `json:"capture_width"`
CaptureHeight uint16 `json:"capture_height"`
Format string `json:"format"`
}
type ScreenshotQuery struct {
Format string `form:"format"`
Width uint16 `form:"width"`
Height uint16 `form:"height"`
Quality uint16 `form:"quality"`
}
type Point struct {
X *float64 `json:"x"`
Y *float64 `json:"y"`
}
type HotkeyKeys []string
func (k *HotkeyKeys) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
*k = nil
return nil
}
var list []string
if err := json.Unmarshal(data, &list); err == nil {
*k = normalizeHotkeyKeys(list)
return nil
}
var csv string
if err := json.Unmarshal(data, &csv); err == nil {
if strings.TrimSpace(csv) == "" {
*k = nil
return nil
}
*k = normalizeHotkeyKeys(strings.Split(csv, ","))
return nil
}
return &json.UnmarshalTypeError{Value: "keys", Type: hotkeyKeysType}
}
var hotkeyKeysType = reflect.TypeOf("")
func normalizeHotkeyKeys(input []string) HotkeyKeys {
keys := make([]string, 0, len(input))
for _, item := range input {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
keys = append(keys, trimmed)
}
return HotkeyKeys(keys)
}
type Action struct {
Action string `json:"action"`
X *float64 `json:"x"`
Y *float64 `json:"y"`
From *Point `json:"from"`
To *Point `json:"to"`
Button string `json:"button"`
Text string `json:"text"`
Keys HotkeyKeys `json:"keys"`
Direction string `json:"direction"`
Amount int `json:"amount"`
DurationMs int `json:"duration_ms"`
}
type ActionBatch struct {
Actions []Action `json:"actions"`
}
type ActionResult struct {
Action string `json:"action"`
DurationMs int64 `json:"duration_ms"`
HIDWrites int `json:"hid_writes"`
ExecutedActions int `json:"executed_actions,omitempty"`
}
type cachedFrame struct {
data []byte
width uint16
height uint16
capturedAt time.Time
}
type SessionState string
const (
SessionStateCreated SessionState = "created"
SessionStateConnecting SessionState = "connecting"
SessionStateActive SessionState = "active"
SessionStateClosing SessionState = "closing"
SessionStateClosed SessionState = "closed"
)
const (
CloseCodePicoclawLockHeld = 4001
CloseCodeRuntimeUnavailable = 4002
CloseCodeAuthFailed = 4003
CloseCodePicoclawTakenOver = 4004
CloseCodeUpstreamClosed = 4005
)
type GatewaySession struct {
SessionID string
State SessionState
Downstream *websocket.Conn
Upstream *websocket.Conn
CreatedAt time.Time
UpdatedAt time.Time
closeOnce sync.Once
upstreamWriteMu sync.Mutex
downstreamWriteMu sync.Mutex
}
type SessionManager struct {
mu sync.RWMutex
sessions map[string]*GatewaySession
}
type LoadImageRequest struct {
Path string `json:"path"`
Prompt string `json:"prompt"`
Filename string `json:"filename"`
}

View File

@@ -8,6 +8,13 @@ import (
var streamer = NewStreamer()
type LatestFrame struct {
Data []byte
Width uint16
Height uint16
CapturedAt time.Time
}
func Connect(c *gin.Context) {
c.Header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
c.Header("Cache-Control", "no-cache")
@@ -20,3 +27,15 @@ func Connect(c *gin.Context) {
<-c.Request.Context().Done()
}
func GetLatestFrame() (LatestFrame, bool) {
return streamer.getLatestFrame()
}
func EnableLatestFrameCache() {
streamer.enableLatestFrameCache()
}
func DisableLatestFrameCache() {
streamer.disableLatestFrameCache()
}

View File

@@ -14,9 +14,12 @@ import (
)
type Streamer struct {
mutex sync.RWMutex
clients map[*gin.Context]bool
running int32
mutex sync.RWMutex
clients map[*gin.Context]bool
running int32
frameMutex sync.RWMutex
latestFrame LatestFrame
cacheRefs int32
}
func NewStreamer() *Streamer {
@@ -86,6 +89,10 @@ func (s *Streamer) run() {
continue
}
if s.frameCacheEnabled() {
s.setLatestFrame(data, screen.Width, screen.Height)
}
clients := s.getClients()
for _, client := range clients {
if err := writeFrame(client, data); err != nil {
@@ -103,6 +110,71 @@ func (s *Streamer) run() {
}
}
func (s *Streamer) setLatestFrame(data []byte, width uint16, height uint16) {
frameCopy := append([]byte(nil), data...)
s.frameMutex.Lock()
defer s.frameMutex.Unlock()
s.latestFrame = LatestFrame{
Data: frameCopy,
Width: width,
Height: height,
CapturedAt: time.Now(),
}
}
func (s *Streamer) clearLatestFrame() {
s.frameMutex.Lock()
defer s.frameMutex.Unlock()
s.latestFrame = LatestFrame{}
}
func (s *Streamer) enableLatestFrameCache() {
atomic.AddInt32(&s.cacheRefs, 1)
}
func (s *Streamer) disableLatestFrameCache() {
for {
current := atomic.LoadInt32(&s.cacheRefs)
if current <= 0 {
return
}
if atomic.CompareAndSwapInt32(&s.cacheRefs, current, current-1) {
if current == 1 {
s.clearLatestFrame()
}
return
}
}
}
func (s *Streamer) frameCacheEnabled() bool {
return atomic.LoadInt32(&s.cacheRefs) > 0
}
func (s *Streamer) getLatestFrame() (LatestFrame, bool) {
if !s.frameCacheEnabled() {
return LatestFrame{}, false
}
s.frameMutex.RLock()
defer s.frameMutex.RUnlock()
if len(s.latestFrame.Data) == 0 {
return LatestFrame{}, false
}
return LatestFrame{
Data: append([]byte(nil), s.latestFrame.Data...),
Width: s.latestFrame.Width,
Height: s.latestFrame.Height,
CapturedAt: s.latestFrame.CapturedAt,
}, true
}
func writeFrame(c *gin.Context, data []byte) (err error) {
defer func() {
if r := recover(); r != nil {

View File

@@ -1,11 +1,12 @@
package vm
import (
"NanoKVM-Server/config"
"fmt"
"os"
"strings"
"NanoKVM-Server/config"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -19,6 +20,7 @@ var imageVersionMap = map[string]string{
"2024-11-13-09-59-9c961a.img": "v1.3.0",
"2025-02-17-19-08-3649fe.img": "v1.4.0",
"2025-04-17-14-21-98d17d.img": "v1.4.1",
"2026-01-05-1_4_1.img": "v1.4.2",
}
func (s *Service) GetInfo(c *gin.Context) {

122
server/service/ws/client.go Normal file
View File

@@ -0,0 +1,122 @@
package ws
import (
"encoding/json"
"time"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/picoclaw"
"NanoKVM-Server/service/vm/jiggler"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
const (
Heartbeat = iota
KeyboardEvent
MouseEvent
)
func NewClient(ws *websocket.Conn) *Client {
client := &Client{
ws: ws,
hid: hid.GetHid(),
keyboard: make(chan []byte, 200),
mouse: make(chan []byte, 200),
lastHeartbeat: time.Time{},
}
client.hid.Open()
return client
}
func (c *Client) Start() {
defer c.Close()
go c.hid.Keyboard(c.keyboard)
go c.hid.Mouse(c.mouse)
_ = c.Read()
}
func (c *Client) Read() error {
var zeroTime time.Time
_ = c.ws.SetReadDeadline(zeroTime)
for {
messageType, data, err := c.ws.ReadMessage()
if err != nil {
return err
}
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:])
}
}
}
func (c *Client) Write(event string, data string) error {
message := &Message{
Type: event,
Data: data,
}
messageByte, err := json.Marshal(message)
if err != nil {
log.Errorf("failed to marshal message: %s", err)
return err
}
c.mutex.Lock()
defer c.mutex.Unlock()
_ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
return c.ws.WriteMessage(websocket.TextMessage, messageByte)
}
func (c *Client) UpdateHeartbeat() {
c.mutex.Lock()
defer c.mutex.Unlock()
c.lastHeartbeat = time.Now()
}
func (c *Client) Close() {
_ = c.ws.Close()
closeQueue(c.keyboard)
closeQueue(c.mouse)
log.Debug("websocket disconnected")
}
func writeQueue(queue chan []byte, data []byte) {
queue <- data
jiggler.GetJiggler().Update()
}
func closeQueue(queue chan []byte) {
for range queue {
}
close(queue)
}

View File

@@ -0,0 +1,46 @@
package ws
import (
"sync"
"github.com/gorilla/websocket"
)
var (
globalManager *Manager
managerOnce sync.Once
)
func GetManager() *Manager {
managerOnce.Do(func() {
globalManager = &Manager{
clients: make(map[*websocket.Conn]*Client),
mutex: sync.RWMutex{},
}
})
return globalManager
}
func (m *Manager) AddClient(ws *websocket.Conn, client *Client) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.clients[ws] = client
}
func (m *Manager) RemoveClient(ws *websocket.Conn) {
m.mutex.Lock()
defer m.mutex.Unlock()
delete(m.clients, ws)
}
func (m *Manager) GetClients() []*Client {
m.mutex.Lock()
defer m.mutex.Unlock()
clients := make([]*Client, 0, len(m.clients))
for _, c := range m.clients {
clients = append(clients, c)
}
return clients
}

View File

@@ -1,6 +0,0 @@
package ws
type Stream struct {
Type string `json:"type"`
State int `json:"state"`
}

View File

@@ -1,7 +1,41 @@
package ws
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
type Service struct{}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func NewService() *Service {
return &Service{}
}
func (s *Service) Connect(c *gin.Context) {
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("create websocket failed: %s", err)
return
}
log.Debug("websocket connected")
client := NewClient(ws)
manager := GetManager()
manager.AddClient(ws, client)
defer manager.RemoveClient(ws)
client.Start()
}

View File

@@ -0,0 +1,29 @@
package ws
import (
"sync"
"time"
"github.com/gorilla/websocket"
"NanoKVM-Server/service/hid"
)
type Manager struct {
clients map[*websocket.Conn]*Client
mutex sync.RWMutex
}
type Client struct {
ws *websocket.Conn
hid *hid.Hid
keyboard chan []byte
mouse chan []byte
lastHeartbeat time.Time
mutex sync.Mutex
}
type Message struct {
Type string `json:"type"`
Data string `json:"data"`
}

View File

@@ -1,117 +0,0 @@
package ws
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/vm/jiggler"
)
const (
KeyboardEvent int = 1
MouseEvent int = 2
)
type WsClient struct {
conn *websocket.Conn
hid *hid.Hid
keyboard chan []int
mouse chan []int
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func (s *Service) Connect(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("create websocket failed: %s", err)
return
}
log.Debug("websocket connected")
client := &WsClient{
hid: hid.GetHid(),
conn: conn,
keyboard: make(chan []int, 200),
mouse: make(chan []int, 200),
}
go client.Start()
}
func (c *WsClient) Start() {
defer c.Clean()
c.hid.Open()
go c.hid.Keyboard(c.keyboard)
go c.hid.Mouse(c.mouse)
_ = c.Read()
}
func (c *WsClient) Read() error {
var zeroTime time.Time
_ = c.conn.SetReadDeadline(zeroTime)
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
return err
}
log.Debugf("received message: %s", message)
var event []int
err = json.Unmarshal(message, &event)
if err != nil {
log.Debugf("received invalid message: %s", message)
continue
}
if event[0] == KeyboardEvent {
c.keyboard <- event[1:]
} else if event[0] == MouseEvent {
c.mouse <- event[1:]
}
// update latest HID operation time
jiggler.GetJiggler().Update()
}
}
func (c *WsClient) Write(message []byte) error {
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
return c.conn.WriteMessage(websocket.TextMessage, message)
}
func (c *WsClient) Clean() {
_ = c.conn.Close()
go clearQueue(c.keyboard)
close(c.keyboard)
go clearQueue(c.mouse)
close(c.mouse)
c.hid.Close()
log.Debug("websocket disconnected")
}
func clearQueue(queue chan []int) {
for range queue {
}
}

View File

@@ -164,6 +164,7 @@ uint16_t hdmi_res_list[][2] = {
{1152, 864},
{1024, 768},
{800, 600},
{640, 480},
};
uint16_t hdmi_unsupported_res_list[][2] = {

File diff suppressed because it is too large Load Diff

View File

@@ -356,8 +356,8 @@ void OLED_ShowStringTurn(uint8_t x, uint8_t y, char *chr, uint8_t sizey);
void OLED_ShowStringtoend(uint8_t x, uint8_t y, char *chr, uint8_t sizey, uint8_t end);
void OLED_ShowString_AlignRight(uint8_t x_end, uint8_t y, char *chr, uint8_t size);
void OLED_ROW(uint8_t _EN);
void OLED_ShowKVMLogo();
void OLED_ShowLogo();
void OLED_ShowSipeedLogo();
void OLED_Showline();
void OLED_Showline_1();
void OLED_ShowIMG(uint8_t x,uint8_t y,char *chr,uint8_t width,uint8_t height);

View File

@@ -13,8 +13,8 @@ void kvm_init_cube_ui(void)
OLED_Clear();
// OLED_Revolve();
OLED_ShowKVMLogo();
OLED_ShowLogo();
OLED_ShowSipeedLogo();
OLED_ShowKVMState(HDMI_STATE, 0);
OLED_ShowKVMState(HID_STATE, 0);
OLED_ShowKVMState(ETH_STATE, 0);
@@ -37,7 +37,7 @@ void kvm_init_pcie_ui(void)
{
OLED_Revolve();
OLED_Showline_1();
OLED_ShowSipeedLogo();
OLED_ShowLogo();
OLED_ShowKVMState(HDMI_STATE, 0);
OLED_ShowKVMState(HID_STATE, 0);
OLED_ShowKVMState(ETH_STATE, 0);
@@ -209,7 +209,14 @@ void kvm_eth_state_disp(ip_addr_t _ip_type, uint8_t first_disp)
(_ip_type_old != _ip_type) ||
first_disp || ip_changed(ETH_IP))
{
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
// ensure eth_addr is updated
if (kvm_sys_state.eth_state >= 2) {
if (kvm_sys_state.eth_addr[0] != 0)
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
} else {
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
}
_ip_type_old = _ip_type;
switch(kvm_oled_state.eth_state){
case -1:
@@ -412,8 +419,15 @@ void show_wifi_config_ip(void)
{
OLED_Clear();
get_ip_addr(WiFi_IP);
OLED_ShowString(0, 1, "Config IP:", 8);
OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
OLED_ShowString(1, 0, "Config URL", 8);
OLED_ShowString_AlignRight(63, 1, "----------------", 4);
// OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
static char wifi_addr_with_path[30];
static char wifi_addr_with_key[30];
sprintf(wifi_addr_with_path, "%s/#/", kvm_sys_state.wifi_addr);
sprintf(wifi_addr_with_key, "WIFI?P=%s", kvm_sys_state.wifi_ap_pass);
OLED_ShowString_AlignRight(63, 2, wifi_addr_with_path, 4);
OLED_ShowString_AlignRight(63, 3, wifi_addr_with_key, 4);
}
void show_wifi_config_QR(void)
@@ -421,8 +435,7 @@ void show_wifi_config_QR(void)
static char cmd[70];
OLED_Clear();
get_ip_addr(WiFi_IP);
printf("http://%s/#/wifi\n", kvm_sys_state.wifi_addr);
sprintf(cmd, "http://%s/#/wifi", kvm_sys_state.wifi_addr);
sprintf(cmd, "http://%s/#/WIFI?P=%s", kvm_sys_state.wifi_addr, kvm_sys_state.wifi_ap_pass);
qrencode(cmd);
}

View File

@@ -53,6 +53,7 @@ void Production_testing_patch(void)
void new_app_init(void)
{
// Update the necessary scripts
system("rm -f /boot/logo.jpeg");
system("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/");
system("rm -f /etc/init.d/S02udisk");
system("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/");
@@ -66,6 +67,11 @@ void new_app_init(void)
} else {
system("rm -f /etc/init.d/S30wifi");
}
// if exit /etc/init.d/S98tailscaled then cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/
if(access("/etc/init.d/S98tailscaled", F_OK) == 0){
system("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/");
}
// rmmod soph_saradc
system("rmmod soph_saradc");

View File

@@ -0,0 +1,605 @@
from PIL import Image
import numpy as np
import sys
import os
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Button, Input, Label, Static
from textual.containers import Container, Horizontal, Vertical, Center, Middle
from textual.binding import Binding
from textual import events
from textual.message import Message
# Language dictionary for i18n support
LANGUAGES = {
'zh': {
'app_title': '🖼️ [ NanoKVM Logo 生成器 ]',
'app_title_editor': '✏️ NanoKVM Logo 编辑器',
'contrast_minus': '对比度-',
'contrast_plus': '对比度+',
'current_contrast': '当前对比度:{}',
'click_hint': '💡 点击图片以修改像素',
'btn_invert': '🔃 反转',
'btn_reset': '🔄 重置',
'btn_export': '💾 导出',
'btn_quit': '❌ 退出',
'help_text_with_image': '快捷键:↑↓ 调整±10 | ←→ 调整±1 | I 反转 | R 重置 | D 导出 | Q 退出',
'help_text_editor': '快捷键:I 反转 | R 清空 | D 导出 | Q 退出',
'status_blank_canvas': '空白画布 - 点击像素开始绘制',
'status_loaded': '已加载:{}',
'status_load_failed': '无法加载图片:{}',
'status_contrast': '对比度:{}',
'status_inverted': '已反转所有像素',
'status_reset_image': '已重置为原始图片',
'status_reset_canvas': '已清空画布',
'status_export_error': '错误:没有可导出的数据',
'status_exported': '✅ 已导出 logo 文件',
'status_pixel_toggled': '切换像素 ({}, {})',
'lang_select_title': '选择语言 / Select Language',
'btn_chinese': '中文 (Chinese)',
'btn_english': 'English',
'binding_quit': '退出',
'binding_export': '导出',
'binding_reset': '重置',
'binding_invert': '反转',
'binding_threshold_up': '阈值 +10',
'binding_threshold_down': '阈值 -10',
'binding_threshold_up_small': '阈值 +1',
'binding_threshold_down_small': '阈值 -1',
},
'en': {
'app_title': '🖼️ [ NanoKVM Logo Generator ]',
'app_title_editor': '✏️ NanoKVM Logo Editor',
'contrast_minus': 'Contrast-',
'contrast_plus': 'Contrast+',
'current_contrast': 'Contrast: {}',
'click_hint': '💡 Click image to toggle pixels',
'btn_invert': '🔃 Invert',
'btn_reset': '🔄 Reset',
'btn_export': '💾 Export',
'btn_quit': '❌ Quit',
'help_text_with_image': 'Shortcuts: ↑↓ ±10 | ←→ ±1 | I Invert | R Reset | D Export | Q Quit',
'help_text_editor': 'Shortcuts: I Invert | R Clear | D Export | Q Quit',
'status_blank_canvas': 'Blank canvas - Click pixels to draw',
'status_loaded': 'Loaded: {}',
'status_load_failed': 'Failed to load image: {}',
'status_contrast': 'Contrast: {}',
'status_inverted': 'All pixels inverted',
'status_reset_image': 'Reset to original image',
'status_reset_canvas': 'Canvas cleared',
'status_export_error': 'Error: No data to export',
'status_exported': '✅ Exported logo file',
'status_pixel_toggled': 'Toggled pixel ({}, {})',
'lang_select_title': '选择语言 / Select Language',
'btn_chinese': '中文 (Chinese)',
'btn_english': 'English',
'binding_quit': 'Quit',
'binding_export': 'Export',
'binding_reset': 'Reset',
'binding_invert': 'Invert',
'binding_threshold_up': 'Threshold +10',
'binding_threshold_down': 'Threshold -10',
'binding_threshold_up_small': 'Threshold +1',
'binding_threshold_down_small': 'Threshold -1',
}
}
def reverse_byte(byte):
"""Reverse the bits in a byte"""
result = 0
for i in range(8):
if (byte >> i) & 1:
result |= (1 << (7 - i))
return result
def process_image(image_path, threshold=128):
"""
Process image to 16x16 binary data
Args:
image_path: Path to the image file
threshold: Binarization threshold (0-255)
Returns:
16x16 numpy array of binary values
"""
if not os.path.exists(image_path):
return None
try:
img = Image.open(image_path).convert('L')
except Exception as e:
print(f"Error: Failed to open image - {e}")
return None
# If not square, crop the center region
if img.width != img.height:
size = min(img.width, img.height)
left = (img.width - size) // 2
top = (img.height - size) // 2
img = img.crop((left, top, left + size, top + size))
# Resize to 16x16 and binarize
img = img.resize((16, 16), Image.Resampling.LANCZOS)
binary = (np.array(img) < threshold).astype(np.uint8)
return binary
def create_blank_image():
"""Create a blank 16x16 image (all black)"""
return np.zeros((16, 16), dtype=np.uint8)
def binary_to_bytes(binary):
"""Convert 16x16 binary data to 32 bytes"""
data = []
for x in range(16):
for page in range(2):
byte = 0
start_row = page * 8
for bit in range(8):
row = start_row + bit
if row < 16 and binary[row][x] == 1:
byte |= (1 << (7 - bit))
data.append(reverse_byte(byte))
return bytes(data)
def binary_to_image(binary_data, size=64):
"""
Convert 16x16 binary data to 64x64 RGBA image
Args:
binary_data: 16x16 numpy array of binary values
size: Size to scale the image to (default 64)
Returns:
PIL Image in RGBA mode
"""
# Create 16x16 RGBA image from binary data
img_16 = Image.new('RGBA', (16, 16), (0, 0, 0, 255)) # Black for 1
pixels = img_16.load()
for y in range(16):
for x in range(16):
if binary_data[y][x] == 1:
pixels[x, y] = (255, 255, 255, 255) # White
else:
pixels[x, y] = (0, 0, 0, 255) # Black
# Scale to 64x64 using NEAREST for pixel-perfect scaling
img_64 = img_16.resize((size, size), Image.Resampling.NEAREST)
return img_64
def image_to_ico(image_path=None, binary_data=None, output_path="logo.ico", size=64):
"""
Convert image or binary data to 64x64 32-bit ICO file
Args:
image_path: Path to the source image (optional if binary_data is provided)
binary_data: 16x16 numpy array of binary values (optional if image_path is provided)
output_path: Output ICO file path
size: Size to scale the icon to (default 64)
"""
try:
if image_path and os.path.exists(image_path):
img = Image.open(image_path).convert('RGBA')
# If not square, crop the center region
if img.width != img.height:
min_size = min(img.width, img.height)
left = (img.width - min_size) // 2
top = (img.height - min_size) // 2
img = img.crop((left, top, left + min_size, top + min_size))
# Resize to 64x64 using LANCZOS for high quality
img_64 = img.resize((size, size), Image.Resampling.LANCZOS)
elif binary_data is not None:
# Convert binary data to image and scale
img_64 = binary_to_image(binary_data, size)
else:
print("Error: No image source provided")
return False
except Exception as e:
print(f"Error: Failed to create ICO - {e}")
return False
# Save as ICO with 32-bit color depth
img_64.save(output_path, format='ICO', sizes=[(size, size)])
return True
class PixelGrid(Static):
"""16x16 pixel grid component"""
class PixelClick(Message):
def __init__(self, x: int, y: int):
super().__init__()
self.x = x
self.y = y
DEFAULT_CSS = """
PixelGrid {
width: auto;
height: auto;
padding: 0;
background: $surface;
}
"""
def __init__(self, binary_data=None, id=None):
super().__init__(id=id)
self.binary_data = binary_data if binary_data is not None else np.zeros((16, 16), dtype=np.uint8)
self.can_focus = True
def update_data(self, binary_data):
self.binary_data = binary_data
self.refresh()
def render(self):
"""Render the pixel grid"""
lines = []
# Top border
lines.append("┌" + "─" * 32 + "┐")
for y in range(16):
line = "│"
for x in range(16):
if self.binary_data[y][x] == 1:
line += "██"
else:
line += " "
line += "│"
lines.append(line)
# Bottom border
lines.append("└" + "─" * 32 + "┘")
return "\n".join(lines)
def on_click(self, event: events.Click):
"""Handle click event"""
# Get coordinates relative to the component
# Use event.offset to get coordinates relative to the component
try:
local_x = event.offset_x
local_y = event.offset_y
except AttributeError:
# If offset attribute doesn't exist, use event coordinates
local_x = event.x if hasattr(event, 'x') else 0
local_y = event.y if hasattr(event, 'y') else 0
# Render format:
# Row 0: ┌────────────────────────────────┐ (top border)
# Rows 1-16: │██ ██ ... │ (pixel rows, 16 pixels per row, 2 chars per pixel)
# Row 17: └────────────────────────────────┘ (bottom border)
# Column positions: 0 is left border │, 1-32 is pixel area (2 chars per pixel), 33 is right border │
# Check if within pixel grid area (y from 1 to 16, since row 0 is top border)
if local_y >= 1 and local_y <= 16:
grid_y = local_y - 1
# Check if x is within valid range (1 to 32, since there are borders on both sides)
if local_x >= 1 and local_x <= 32:
# Each pixel takes 2 chars, (1,2) is column 0, (3,4) is column 1, etc.
grid_x = (local_x - 1) // 2
if 0 <= grid_x < 16 and 0 <= grid_y < 16:
self.post_message(self.PixelClick(grid_x, grid_y))
class LanguageSelectApp(App):
"""Language selection screen"""
CSS = """
Screen {
background: $surface;
}
#lang-container {
width: 100%;
height: 100%;
content-align: center middle;
}
#lang-title {
text-align: center;
text-style: bold;
padding: 2 0;
}
#lang-buttons {
width: auto;
height: auto;
content-align: center middle;
}
Button {
margin: 1 2;
min-width: 20;
}
"""
def __init__(self):
super().__init__()
self.selected_language = 'zh' # Default to Chinese
def compose(self) -> ComposeResult:
with Container(id="lang-container"):
yield Label("选择语言 / Select Language", id="lang-title")
with Horizontal(id="lang-buttons"):
yield Button("中文 (Chinese)", id="btn-chinese", variant="primary")
yield Button("English", id="btn-english", variant="success")
def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn-chinese":
self.selected_language = 'zh'
self.exit('zh')
elif event.button.id == "btn-english":
self.selected_language = 'en'
self.exit('en')
class LogoApp(App):
"""Logo conversion TUI application"""
CSS = """
Screen {
background: $surface;
}
#main-container {
width: 100%;
height: 100%;
padding: 1 2;
}
#title {
text-align: center;
text-style: bold;
padding: 1 0;
}
#threshold-container {
width: 100%;
height: auto;
padding: 1 2;
content-align: center middle;
}
#threshold-value {
text-align: center;
padding: 0 1;
width: 20;
}
#button-container {
width: 100%;
height: auto;
content-align: center middle;
padding: 1 0;
}
Button {
margin: 0 1;
}
#status {
text-align: center;
padding: 1 0;
text-style: italic;
}
#help-text {
text-align: center;
padding: 1 0;
color: $text-muted;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("d", "export", "Export"),
Binding("r", "reset", "Reset"),
Binding("i", "invert", "Invert"),
Binding("up", "threshold_up", "Threshold +10"),
Binding("down", "threshold_down", "Threshold -10"),
Binding("left", "threshold_down_small", "Threshold -1"),
Binding("right", "threshold_up_small", "Threshold +1"),
]
def __init__(self, image_path=None, language='zh'):
super().__init__()
self.image_path = image_path
self.language = language
self.lang = LANGUAGES[language]
self.original_binary = None
self.current_binary = None
self.threshold = 128
self.has_image = image_path is not None
# Update bindings with selected language
self.BINDINGS = [
Binding("q", "quit", self.lang['binding_quit']),
Binding("d", "export", self.lang['binding_export']),
Binding("r", "reset", self.lang['binding_reset']),
Binding("i", "invert", self.lang['binding_invert']),
Binding("up", "threshold_up", self.lang['binding_threshold_up']),
Binding("down", "threshold_down", self.lang['binding_threshold_down']),
Binding("left", "threshold_down_small", self.lang['binding_threshold_down_small']),
Binding("right", "threshold_up_small", self.lang['binding_threshold_up_small']),
]
def compose(self) -> ComposeResult:
yield Header()
with Container(id="main-container"):
if self.has_image:
yield Label(self.lang['app_title'], id="title")
# Contrast control area: [Contrast-] Current value [Contrast+]
with Horizontal(id="threshold-container"):
yield Button(self.lang['contrast_minus'], id="threshold-minus-btn", variant="primary")
yield Label(self.lang['current_contrast'].format(self.threshold), id="threshold-value-label")
yield Button(self.lang['contrast_plus'], id="threshold-plus-btn", variant="primary")
else:
yield Label(self.lang['app_title_editor'], id="title")
yield PixelGrid(id="pixel-grid")
yield Label(self.lang['click_hint'], id="click-hint")
# Action button area: Invert | Reset | Export | Quit
with Horizontal(id="button-container"):
yield Button(self.lang['btn_invert'], id="invert-btn", variant="default")
yield Button(self.lang['btn_reset'], id="reset-btn", variant="warning")
yield Button(self.lang['btn_export'], id="export-btn", variant="success")
yield Button(self.lang['btn_quit'], id="quit-btn", variant="error")
yield Label("", id="status")
if self.has_image:
yield Label(self.lang['help_text_with_image'], id="help-text")
else:
yield Label(self.lang['help_text_editor'], id="help-text")
yield Footer()
def on_mount(self):
"""When app starts"""
self.pixel_grid = self.query_one("#pixel-grid", PixelGrid)
self.threshold_value_label = self.query_one("#threshold-value-label", Label) if self.has_image else None
self.status = self.query_one("#status", Label)
if self.has_image:
self.load_image(self.image_path)
else:
self.current_binary = create_blank_image()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_blank_canvas'])
def load_image(self, path):
"""Load image"""
self.original_binary = process_image(path, self.threshold)
if self.original_binary is not None:
self.current_binary = self.original_binary.copy()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_loaded'].format(path))
else:
self.status.update(self.lang['status_load_failed'].format(path))
def update_threshold(self, new_value: int):
"""Update threshold"""
new_value = max(0, min(255, new_value))
if new_value != self.threshold:
self.threshold = new_value
if self.threshold_value_label:
self.threshold_value_label.update(self.lang['current_contrast'].format(self.threshold))
if self.original_binary is not None:
self.current_binary = process_image(self.image_path, self.threshold)
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_contrast'].format(self.threshold))
def on_button_pressed(self, event: Button.Pressed):
"""Button press event"""
button_id = event.button.id
if button_id == "invert-btn":
self.action_invert()
elif button_id == "reset-btn":
self.action_reset()
elif button_id == "export-btn":
self.action_export()
elif button_id == "quit-btn":
self.action_quit()
elif button_id == "threshold-plus-btn":
self.action_threshold_up()
elif button_id == "threshold-minus-btn":
self.action_threshold_down()
def on_pixel_grid_pixel_click(self, event: PixelGrid.PixelClick):
"""Pixel click event"""
if self.current_binary is not None:
# Toggle pixel value
self.current_binary[event.y][event.x] = 1 - self.current_binary[event.y][event.x]
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_pixel_toggled'].format(event.x, event.y))
def action_invert(self):
"""Invert all pixels"""
if self.current_binary is not None:
self.current_binary = 1 - self.current_binary
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_inverted'])
def action_reset(self):
"""Reset to original image or clear"""
if self.has_image and self.original_binary is not None:
self.current_binary = self.original_binary.copy()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_reset_image'])
else:
self.current_binary = create_blank_image()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_reset_canvas'])
def action_export(self):
"""Export logo.bin and logo.ico"""
if self.current_binary is None:
self.status.update(self.lang['status_export_error'])
return
data = binary_to_bytes(self.current_binary)
with open("logo.bin", "wb") as f:
f.write(data)
# Export 64x64 32-bit ICO file
if self.has_image and self.image_path:
image_to_ico(self.image_path, "logo.ico", size=64)
else:
# Export from current canvas (16x16 -> 64x64)
image_to_ico(binary_data=self.current_binary, output_path="logo.ico", size=64)
self.status.update(self.lang['status_exported'])
def action_threshold_up(self):
"""Increase threshold by 10"""
if self.has_image:
self.update_threshold(self.threshold + 10)
def action_threshold_down(self):
"""Decrease threshold by 10"""
if self.has_image:
self.update_threshold(self.threshold - 10)
def action_threshold_up_small(self):
"""Increase threshold by 1"""
if self.has_image:
self.update_threshold(self.threshold + 1)
def action_threshold_down_small(self):
"""Decrease threshold by 1"""
if self.has_image:
self.update_threshold(self.threshold - 1)
def action_quit(self):
self.exit()
def main():
image_path = None
# First, show language selection screen
lang_app = LanguageSelectApp()
selected_language = lang_app.run()
if len(sys.argv) >= 2:
image_path = sys.argv[1]
if not os.path.exists(image_path):
print(f"Error: File not found - {image_path}")
return
app = LogoApp(image_path, language=selected_language)
app.run()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,39 @@
# NanoKVM Logo Generator
**Compatible Hardware:** NanoKVM-Cube, NanoKVM-PCIe
🛠️ **Tool Description:**
This is a logo generator for the NanoKVM OLED display and Web. You can use it to create a `logo.bin` and `logo.ico` file and place them in the `/boot` directory of your NanoKVM system.
📋 **Instructions:**
1. **Install required libraries:**
```bash
pip install Pillow numpy textual
```
2. **Prepare your logo image:**
Make sure it's as square as possible for best results 🖼️
3. **Run the script:**
```bash
python logo_generator.py path/to/your_logo.png
```
4. **Choose your language:**
Select either English or Chinese
5. **Adjust the contrast:**
Fine-tune the contrast based on the preview below until you're happy with how it looks ✨
6. **Fine-tune pixel by pixel:**
Click on individual pixels to adjust details. If you prefer an inverted display, just click the invert button 🔄
7. **Export and deploy:**
Export the `logo.bin` & `logo.ico`, then copy it to the `/boot` directory of your NanoKVM system. You can do this by:
* Enabling SSH and using SCP to copy the file 💻
* Or directly placing it in the boot partition of your TF card 💾
8. **Reboot and enjoy:**
Make sure your NanoKVM application version is greater than 2.3.6 ✅
Reboot your NanoKVM, and you should see your custom logo on the OLED display! 🎉

View File

@@ -1,20 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

32
web/eslint.config.js Normal file
View File

@@ -0,0 +1,32 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
export default tseslint.config(
{ ignores: ['dist', 'public/mockServiceWorker.js'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
eslintConfigPrettier
)

View File

@@ -7,7 +7,8 @@
"dev": "vite",
"mocked": "vite --mode mocked",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint": "eslint . --report-unused-disable-directives",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
"preview": "vite preview"
},
"dependencies": {
@@ -16,51 +17,56 @@
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"antd": "^5.29.1",
"axios": "1.12.0",
"antd": "^5.29.3",
"axios": "1.13.6",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"i18next": "^23.16.4",
"jotai": "^2.10.1",
"i18next": "^23.16.8",
"jotai": "^2.18.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.562.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-draggable": "^4.4.6",
"react-draggable": "^4.5.0",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^14.1.3",
"react-responsive": "^10.0.0",
"react-router-dom": "^6.27.0",
"react-simple-keyboard": "^3.8.19",
"semver": "^7.6.3",
"react-markdown": "^10.1.0",
"react-responsive": "^10.0.1",
"react-router-dom": "^6.30.3",
"react-simple-keyboard": "^3.8.164",
"remark-gfm": "^4.0.1",
"semver": "^7.7.4",
"vaul": "^0.9.9",
"websocket": "^1.0.35",
"yocto-queue": "^1.2.1"
"yocto-queue": "^1.2.2"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@eslint/js": "^9.39.3",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/semver": "^7.5.8",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@types/semver": "^7.7.1",
"@types/websocket": "^1.0.10",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.39.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"msw": "^2.6.0",
"postcss": "^8.4.47",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.18",
"typescript": "^5.6.3",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.3.0",
"msw": "^2.12.10",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "7.1.11",
"vite-tsconfig-paths": "^4.3.2"
},

4471
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,3 +22,34 @@ export function setHidMode(mode: string) {
};
return http.post('/api/hid/mode', data);
}
// get shortcuts
export function getShortcuts() {
return http.get('/api/hid/shortcuts');
}
// add shortcut
export function addShortcut(keys: any[]) {
const data = {
keys
};
return http.post('/api/hid/shortcut', data);
}
// delete shortcut
export function deleteShortcut(id: string) {
const data = {
id
};
return http.delete('/api/hid/shortcut', data);
}
// get shortcut leader key
export function getLeaderKey() {
return http.get('/api/hid/shortcut/leader-key');
}
// set shortcut leader key
export function setLeaderKey(key: string) {
return http.post('/api/hid/shortcut/leader-key', { key });
}

View File

@@ -14,11 +14,10 @@ export function getWolMacs() {
}
export function deleteWolMac(mac: string) {
return http.request({
method: 'delete',
url: '/api/network/wol/mac',
data: { mac }
});
const data = {
mac
};
return http.delete('/api/network/wol/mac', data);
}
// set Mac name
@@ -32,12 +31,29 @@ export function getWiFi() {
}
// connect wifi without auth (only available in wifi configuration mode)
export function connectWifiNoAuth(ssid: string, password: string) {
export function connectWifiNoAuth(ssid: string, password: string, apPassword?: string) {
const data = {
ssid,
password
};
return http.post('/api/network/wifi', data);
return http.post('/api/network/wifi', data, {
headers: {
'X-AP-Key': apPassword || ''
}
});
}
// verify ap login
export function verifyApLogin(apPassword: string) {
return http.post(
'/api/network/wifi/verify',
{},
{
headers: {
'X-AP-Key': apPassword || ''
}
}
);
}
// connect wifi

130
web/src/api/picoclaw.ts Normal file
View File

@@ -0,0 +1,130 @@
import { http } from '@/lib/http.ts';
import {
picoclawGateway,
type GatewayRunState,
type GatewayTransportState
} from '@/lib/picoclaw-gateway.ts';
const sessionIDHeader = 'X-PicoClaw-Session-ID';
type ModelConfigRequest = {
model: string;
api_base: string;
api_key: string;
};
type AgentProfileRequest = {
profile: string;
};
export type PicoclawSessionListItem = {
id: string;
title: string;
preview: string;
message_count: number;
created: string;
updated: string;
};
export type PicoclawSessionDetailMessage = {
role: 'user' | 'assistant';
content: string;
};
export type PicoclawSessionDetail = {
id: string;
messages: PicoclawSessionDetailMessage[];
summary?: string;
created: string;
updated: string;
};
export function setPicoclawModelConfig(data: ModelConfigRequest) {
return http.post('/api/picoclaw/model/config', data);
}
export function setPicoclawAgentProfile(data: AgentProfileRequest) {
return http.post('/api/picoclaw/agent/profile', data);
}
export function listPicoclawSessions(params?: { offset?: number; limit?: number }) {
return http.get('/api/picoclaw/sessions', { params });
}
export function getPicoclawSession(id: string) {
return http.get(`/api/picoclaw/sessions/${id}`);
}
export function deletePicoclawSession(id: string) {
return http.delete(`/api/picoclaw/sessions/${id}`);
}
export function getRuntimeStatus() {
return http.get('/api/picoclaw/runtime/status');
}
export function startRuntime() {
return http.post('/api/picoclaw/runtime/start');
}
export function stopRuntime() {
return http.post('/api/picoclaw/runtime/stop');
}
export function installRuntime() {
return http.post('/api/picoclaw/runtime/install');
}
export function uninstallRuntime() {
return http.post('/api/picoclaw/runtime/uninstall');
}
export function connectGateway(sessionId?: string) {
return picoclawGateway.connect({ sessionId });
}
export function releaseRuntimeSession(sessionId?: string) {
const activeSessionId = sessionId || picoclawGateway.getSessionId();
if (!activeSessionId) {
return Promise.resolve(null);
}
return http.request({
method: 'delete',
url: '/api/picoclaw/runtime/session',
headers: {
[sessionIDHeader]: activeSessionId
}
});
}
export function sendChatMessage(
content: string,
options?: { id?: string; maxSteps?: number; maxRuntimeMs?: number; trackState?: boolean }
) {
return picoclawGateway.sendChatMessage(content, options);
}
export function sendStopMessage() {
return picoclawGateway.sendStopMessage();
}
export function closeGateway() {
const activeSessionId = picoclawGateway.getSessionId();
picoclawGateway.close();
if (!activeSessionId) {
return;
}
void releaseRuntimeSession(activeSessionId).catch(() => undefined);
}
export function onGatewayConnectionState(listener: (state: GatewayTransportState) => void) {
return picoclawGateway.on('transport_state', listener);
}
export function onGatewayRunState(listener: (state: GatewayRunState) => void) {
return picoclawGateway.on('run_state', listener);
}
export { picoclawGateway };

View File

@@ -20,9 +20,8 @@ export function getScripts() {
}
export function deleteScript(name: string) {
return http.request({
url: '/api/vm/script',
method: 'delete',
data: { name }
});
const data = {
name
};
return http.delete('/api/vm/script', data);
}

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

@@ -1,7 +1,10 @@
import { ReactNode, useState } from 'react';
import { Popover, Tooltip } from 'antd';
import { useSetAtom } from 'jotai';
import { useMediaQuery } from 'react-responsive';
import { submenuOpenCountAtom } from '@/jotai/settings.ts';
type MenuItemProps = {
title: string;
icon: ReactNode;
@@ -20,6 +23,7 @@ export const MenuItem = ({
onOpenChange
}: MenuItemProps) => {
const isBigScreen = useMediaQuery({ minWidth: 640 });
const setSubmenuOpenCount = useSetAtom(submenuOpenCountAtom);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
@@ -28,6 +32,9 @@ export const MenuItem = ({
setIsTooltipOpen(false);
setIsPopoverOpen(open);
// Update global submenu count
setSubmenuOpenCount((count) => (open ? count + 1 : Math.max(0, count - 1)));
if (onOpenChange) {
onOpenChange(open);
}

View File

@@ -0,0 +1,28 @@
import clsx from 'clsx';
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="kbd"
className={clsx(
'pointer-events-none inline-flex h-5 w-fit min-w-9 select-none items-center justify-center gap-1 rounded border-b border-b-neutral-600 bg-neutral-700/70 px-1 font-sans text-xs font-medium text-neutral-300',
"[&_svg:not([class*='size-'])]:size-3",
'[[data-slot=tooltip-content]_&]:text-background [[data-slot=tooltip-content]_&]:bg-background/10',
className
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<kbd
data-slot="kbd-group"
className={clsx('inline-flex items-center gap-1', className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };

View File

@@ -0,0 +1,55 @@
import { RefObject, useCallback, useEffect, useState } from 'react';
import { useAtomValue } from 'jotai';
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
export interface MenuBounds {
left: number;
right: number;
top: number;
bottom: number;
}
export function useMenuBounds(
nodeRef: RefObject<HTMLDivElement | null>,
isMenuExpanded: boolean
): MenuBounds {
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
const [menuBounds, setMenuBounds] = useState<MenuBounds>({
left: 0,
right: 0,
top: 0,
bottom: 0
});
const handleResize = useCallback(() => {
if (!nodeRef.current) return;
const elementRect = nodeRef.current.getBoundingClientRect();
const width = (window.innerWidth - elementRect.width) / 2;
setMenuBounds({
left: -width,
top: -10,
right: width,
bottom: window.innerHeight - elementRect.height - 10
});
}, [nodeRef]);
// Update bounds on mount and resize
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
// Update bounds when menu items or expansion state change
useEffect(() => {
handleResize();
}, [menuDisabledItems, isMenuExpanded, handleResize]);
return menuBounds;
}

View File

@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { getMenuDisabledItems, getMenuDisplayMode } from '@/lib/localstorage.ts';
import {
menuDisabledItemsAtom,
menuDisplayModeAtom,
submenuOpenCountAtom
} from '@/jotai/settings.ts';
const HIDE_TIMEOUT = 5000;
export interface MenuVisibilityState {
isInitialized: boolean;
isMenuExpanded: boolean;
isMenuHidden: boolean;
isMenuMoved: boolean;
handleHovered: (hovered: boolean) => void;
handleMoved: () => void;
setIsMenuExpanded: (expanded: boolean) => void;
}
export function useMenuVisibility(): MenuVisibilityState {
const [menuDisplayMode, setMenuDisplayMode] = useAtom(menuDisplayModeAtom);
const setMenuDisabledItems = useSetAtom(menuDisabledItemsAtom);
const submenuOpenCount = useAtomValue(submenuOpenCountAtom);
const [isMenuExpanded, setIsMenuExpanded] = useState(true);
const [isMenuMoved, setIsMenuMoved] = useState(false);
const [isMenuHovered, setIsMenuHovered] = useState(false);
const [isMenuHidden, setIsMenuHidden] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startCountdown = useCallback(() => {
if (menuDisplayMode !== 'auto' || submenuOpenCount > 0 || !isMenuExpanded || isMenuMoved) {
return;
}
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setIsMenuHidden(true);
}, HIDE_TIMEOUT);
}, [menuDisplayMode, submenuOpenCount, isMenuExpanded, isMenuMoved]);
const stopCountdown = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
// Initialize menu settings
useEffect(() => {
const displayMode = getMenuDisplayMode();
setMenuDisplayMode(displayMode);
const items = getMenuDisabledItems();
setMenuDisabledItems(items);
if (displayMode === 'off') {
setIsMenuExpanded(false);
}
setIsInitialized(true);
return () => {
stopCountdown();
};
}, []);
// Handle display mode changes
useEffect(() => {
setIsMenuHidden(false);
if (menuDisplayMode === 'auto') {
startCountdown();
} else {
stopCountdown();
}
}, [menuDisplayMode, startCountdown, stopCountdown]);
// Handle hover and submenu state
useEffect(() => {
if (submenuOpenCount === 0 && !isMenuHovered) {
startCountdown();
} else {
stopCountdown();
}
}, [submenuOpenCount, isMenuHovered, startCountdown, stopCountdown]);
// Handle hover state
const handleHovered = useCallback((hovered: boolean) => {
setIsMenuHovered(hovered);
if (hovered) {
setIsMenuHidden(false);
}
}, []);
const handleMoved = useCallback(() => {
if (!isMenuMoved) {
setIsMenuMoved(true);
}
}, [isMenuMoved]);
return {
isMenuExpanded,
isMenuHidden,
isMenuMoved,
isInitialized,
handleHovered,
handleMoved,
setIsMenuExpanded
};
}

View File

@@ -16,6 +16,8 @@ const en = {
noEmptyPassword: 'Password required',
noAccount: 'Failed to get user information, please refresh web page or reset password',
invalidUser: 'Invalid username or password',
locked: 'Too many logins, please try again later',
globalLocked: 'System under protection, please try again later',
error: 'Unexpected error',
changePassword: 'Change Password',
changePasswordDesc: 'For the security of your device, please change the password!',
@@ -46,7 +48,14 @@ const en = {
invalidMode:
'The current mode does not support network setup. Please go to your device and enable Wi-Fi configuration mode.',
confirmBtn: 'Ok',
finishBtn: 'Finished'
finishBtn: 'Finished',
ap: {
authTitle: 'Authentication Required',
authDescription: 'Please enter the AP password to continue',
authFailed: 'Invalid AP password',
passPlaceholder: 'AP password',
verifyBtn: 'Verify'
}
},
screen: {
scale: 'Scale',
@@ -76,14 +85,51 @@ const en = {
placeholder: 'Please input',
submit: 'Submit',
virtual: 'Keyboard',
ctrlaltdel: 'Ctrl+Alt+Del',
readClipboard: 'Read from Clipboard',
clipboardPermissionDenied:
'Clipboard permission denied. Please allow clipboard access in your browser.',
clipboardReadError: 'Failed to read clipboard',
dropdownEnglish: 'English',
dropdownGerman: 'German',
dropdownRussian: 'Russian'
dropdownRussian: 'Russian',
shortcut: {
title: 'Shortcuts',
custom: 'Custom',
capture: 'Click here to capture shortcut',
clear: 'Clear',
save: 'Save',
captureTips:
'Capturing system-level keys (such as the Windows key) requires full-screen permission.',
enterFullScreen: 'Toggle full-screen mode.'
},
leaderKey: {
title: 'Leader Key',
desc: 'Bypass browser restrictions and send system shortcuts directly to the remote host.',
howToUse: 'How to Use',
simultaneous: {
title: 'Simultaneous Mode',
desc1: 'Press and hold the Leader Key, then press the shortcut.',
desc2: 'Intuitive, but may conflict with system shortcuts.'
},
sequential: {
title: 'Sequential Mode',
desc1:
'Press the Leader Key → press the shortcut in sequence → press the Leader Key again.',
desc2: 'Requires more steps, but completely avoids system conflicts.'
},
enable: 'Enable Leader Key',
tip: 'When assigned as a Leader Key, this key functions exclusively as a shortcut trigger and loses its default behavior.',
placeholder: 'Please press the Leader Key',
shiftRight: 'Right Shift',
ctrlRight: 'Right Ctrl',
metaRight: 'Right Win',
submit: 'Submit',
recorder: {
rec: 'REC',
activate: 'Activate keys',
input: 'Please press the shortcut...'
}
}
},
mouse: {
title: 'Mouse',
@@ -234,10 +280,18 @@ const en = {
display: 'Display',
language: 'Language',
languageDesc: 'Select the language for the interface',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar',
webTitle: 'Web Title',
webTitleDesc: 'Customize the web page title'
webTitleDesc: 'Customize the web page title',
menuBar: {
title: 'Menu Bar',
mode: 'Display Mode',
modeDesc: 'Display menu bar on the screen',
modeOff: 'Off',
modeAuto: 'Auto hide',
modeAlways: 'Always visible',
icons: 'Submenu Icons',
iconsDesc: 'Display submenu icons in the menu bar'
}
},
device: {
title: 'Device',
@@ -395,6 +449,127 @@ const en = {
cancelBtn: 'No'
}
},
picoclaw: {
title: 'PicoClaw Assistant',
empty: 'Open the panel and start a task to begin.',
inputPlaceholder: 'Describe what you want the PicoClaw to do',
newConversation: 'New conversation',
processing: 'Processing...',
agent: {
defaultTitle: 'General Assistant',
defaultDescription: 'General chat, search, and workspace help.',
kvmTitle: 'Remote Control',
kvmDescription: 'Operate the remote host through NanoKVM.',
switched: 'Agent role switched',
switchFailed: 'Failed to switch agent role'
},
send: 'Send',
cancel: 'Cancel',
status: {
connecting: 'Connecting to gateway...',
connected: 'PicoClaw session connected',
disconnected: 'PicoClaw session closed',
stopped: 'Stop request sent',
runtimeStarted: 'PicoClaw runtime started',
runtimeStartFailed: 'Failed to start PicoClaw runtime',
runtimeStopped: 'PicoClaw runtime stopped',
runtimeStopFailed: 'Failed to stop PicoClaw runtime'
},
connection: {
runtime: {
checking: 'Checking',
ready: 'Runtime ready',
stopped: 'Runtime stopped',
unavailable: 'Runtime unavailable',
configError: 'Configuration error'
},
transport: {
connecting: 'Connecting',
connected: 'Connected'
},
run: {
idle: 'Idle',
busy: 'Busy'
}
},
message: {
toolAction: 'Action',
observation: 'Observation',
screenshot: 'Screenshot'
},
overlay: {
locked: 'PicoClaw is controlling the device. Manual input is paused.'
},
install: {
install: 'Install PicoClaw',
installing: 'Installing PicoClaw',
success: 'PicoClaw installed successfully',
failed: 'Failed to install PicoClaw',
uninstalling: 'Uninstalling runtime...',
uninstalled: 'Runtime uninstalled successfully.',
uninstallFailed: 'Uninstall failed.',
requiredTitle: 'PicoClaw is not installed',
requiredDescription: 'Install PicoClaw before starting the PicoClaw runtime.',
progressDescription: 'PicoClaw is being downloaded and installed.',
stages: {
preparing: 'Preparing',
downloading: 'Downloading',
extracting: 'Extracting',
installing: 'Installing',
installed: 'Installed',
install_timeout: 'Timed Out',
install_failed: 'Failed'
}
},
model: {
requiredTitle: 'Model configuration is required',
requiredDescription: 'Configure the PicoClaw model before using PicoClaw chat.',
docsTitle: 'Configuration Guide',
docsDesc: 'Supported models and protocols',
menuLabel: 'Configure model',
modelIdentifier: 'Model Identifier',
modelIdentifierPlaceholder: 'openai/gpt-5.4',
apiBase: 'API Base URL',
apiBasePlaceholder: 'https://api.example.com/v1',
apiKey: 'API Key',
apiKeyPlaceholder: 'Enter the model API key',
save: 'Save',
saving: 'Saving',
saved: 'Model configuration saved',
saveFailed: 'Failed to save model configuration',
invalid: 'Model identifier, API base URL, and API key are required'
},
uninstall: {
menuLabel: 'Uninstall',
confirmTitle: 'Uninstall PicoClaw',
confirmContent:
'Are you sure you want to uninstall PicoClaw? This will delete the executable and all configuration files.',
confirmOk: 'Uninstall',
confirmCancel: 'Cancel'
},
history: {
title: 'History',
loading: 'Loading sessions...',
emptyTitle: 'No history yet',
emptyDescription: 'Previous PicoClaw sessions will appear here.',
loadFailed: 'Failed to load session history',
deleteFailed: 'Failed to delete session',
deleteConfirmTitle: 'Delete session',
deleteConfirmContent: 'Are you sure you want to delete "{{title}}"?',
deleteConfirmOk: 'Delete',
deleteConfirmCancel: 'Cancel',
messageCount_one: '{{count}} message',
messageCount_other: '{{count}} messages'
},
config: {
startRuntime: 'Start PicoClaw',
stopRuntime: 'Stop PicoClaw'
},
start: {
title: 'Start PicoClaw',
description: 'Start the runtime to begin using the PicoClaw assistant.'
}
},
error: {
title: "We've ran into an issue",
refresh: 'Refresh'

View File

@@ -11,16 +11,14 @@ const ja = {
login: 'ログイン',
placeholderUsername: 'ユーザー名を入力してください',
placeholderPassword: 'パスワードを入力してください',
placeholderPassword2: '再度パスワードを入力してください',
placeholderPassword2: 'パスワードをもう一度入力してください',
noEmptyUsername: 'ユーザー名は空にできません',
noEmptyPassword: 'パスワードは空にできません',
noAccount:
'ユーザー情報の取得に失敗しました。ウェブページをリフレッシュするか、パスワードをリセットしてください。',
invalidUser: '無効なユーザー名またはパスワード',
error: '予期しないエラー',
noAccount: 'ユーザー情報の取得に失敗しました。ページを更新してもう一度お試しいただくか、パスワードをリセットしてください。',
invalidUser: 'ユーザー名またはパスワードが正しくありません',
error: '不明なエラー',
changePassword: 'パスワード変更',
changePasswordDesc:
'デバイスのセキュリティのために、ウェブログインのパスワードを変更してください。',
changePasswordDesc: 'デバイスのセキュリティのために、パスワードを変更してください!',
differentPassword: 'パスワードが一致しません',
illegalUsername: 'ユーザー名に不正な文字が含まれています',
illegalPassword: 'パスワードに不正な文字が含まれています',
@@ -29,86 +27,147 @@ const ja = {
cancel: 'キャンセル',
loginButtonText: 'ログイン',
tips: {
reset1:
'To reset the passwords, pressing and holding the BOOT button on the NanoKVM for 10 seconds.',
reset2: 'For detailed steps, please consult this document:',
reset3: 'Web default account:',
reset4: 'SSH default account:',
change1: 'Please note that this action will change the following passwords:',
change2: 'Web login password',
change3: 'System root password (SSH login password)',
change4: 'To reset the passwords, press and hold the BOOT button on the NanoKVM.'
reset1: 'パスワードをリセットするには、NanoKVM の BOOT ボタンを 10 秒間押し続けます。',
reset2: '詳細な手順については、次のドキュメントを参照してください:',
reset3: 'ウェブデフォルトアカウント:',
reset4: 'SSH デフォルトアカウント:',
change1: 'この操作により、以下のパスワードも更新されることに注意してください:',
change2: 'ウェブログインパスワード',
change3: 'システム root パスワード(SSH ログインパスワード)',
change4: 'パスワードを忘れた場合は、NanoKVM の BOOT ボタンを長押ししてパスワードをリセットする必要があります。'
}
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi for NanoKVM',
success: 'Please check the network status of NanoKVM and visit the new IP address.',
failed: 'Operation failed, please try again.',
confirmBtn: 'Ok',
finishBtn: 'Finished'
description: 'NanoKVM の Wi-Fi を設定する',
success: 'NanoKVM のネットワークステータスを確認するにはデバイスにアクセスしてください。',
failed: '操作に失敗しました。もう一度お試しください。',
invalidMode: '現在のモードではネットワーク設定はサポートされていません。デバイスで Wi-Fi 設定モードを有効にしてください。',
confirmBtn: 'OK',
finishBtn: '完了'
},
screen: {
scale: '倍率',
title: '画面',
video: 'ビデオモード',
videoDirectTips: 'このモードを使用するには「設定 - デバイス」で HTTPS を有効にしてください',
resolution: '解像度',
auto: '自動',
autoTips:
'特定の解像度で画面のティアリングやマウスのオフセットが発生する可能性があります。リモートホストの解像度を調整するか、自動モードを無効にすることを検討してください。',
fps: 'FPS',
'特定の解像度で画面のちらつきやマウスカーソルのずれが発生する場合があります。リモートホストの解像度を調整するか、自動モードを無効にしてください。',
fps: 'フレームレート',
customizeFps: 'カスタマイズ',
quality: '品質',
quality: '画質',
qualityLossless: 'ロスレス',
qualityHigh: '高い',
qualityMedium: '中くらい',
qualityLow: '低い',
frameDetect: 'フレーム検出',
frameDetectTip:
'フレーム間の差を計算します。リモートホストの画面で変更が検出されない場合、ビデオストリームの送信を停止します。',
resetHdmi: 'Reset HDMI'
qualityHigh: '高',
qualityMedium: '中',
qualityLow: '低',
frameDetect: 'フレーム差分検出',
frameDetectTip: 'フレーム間の差異を計算し、リモートホストの画面が変更されない場合はビデオストリームの送信を停止します',
resetHdmi: 'HDMI をリセット'
},
keyboard: {
title: 'キーボード',
paste: '貼り付け',
tips: '標準的なキーボードの文字と記号のみがサポートされています',
tips: '標準的なキーボードの文字と記号のみをサポートしています',
placeholder: '入力してください',
submit: '送信',
virtual: 'キーボード',
ctrlaltdel: 'Ctrl+Alt+Del'
virtual: '仮想キーボード',
shortcut: {
title: 'ショートカット',
custom: 'カスタム',
capture: 'ショートカットをキャプチャするにはここをクリックしてください',
clear: 'クリア',
save: '保存',
captureTips: 'システムレベルのキー(Windows キーなど)をキャプチャするには、全画面表示の権限が必要です。',
enterFullScreen: '全画面モードに切り替えます。'
},
leaderKey: {
title: 'リーダーキー',
desc: 'ブラウザの制限を回避して、システムによってブロックされているショートカットキーをリモートホストに送信します。',
howToUse: '使用方法',
simultaneous: {
title: '同時モード',
desc1: 'リーダーキーを押したまま、ショートカットキーを押します。',
desc2: '操作は直感的ですが、システムの使用状況により一部のショートカットキーが機能しない場合があります。'
},
sequential: {
title: 'シーケンシャルモード',
desc1: '【リーダーキー】をクリックして開始 →【ショートカットキー】を順にクリック →【リーダーキー】をクリックして終了します。',
desc2: 'いくつかの手順が必要ですが、システムキーの競合を完全に回避します。'
},
enable: 'リーダーキーを有効化',
tip: 'リーダーキーとして設定すると、このキーはショートカットキーをトリガーするためにのみ使用され、通常のキーとしては使用できなくなります。',
placeholder: 'リーダーキーを押してください',
shiftRight: '右 Shift',
ctrlRight: '右 Ctrl',
metaRight: '右 Win',
submit: '送信',
recorder: {
rec: 'REC',
activate: 'キーを有効にする',
input: 'ショートカットキーを押してください...'
}
}
},
mouse: {
default: 'デフォルトカーソル',
title: 'マウス',
cursor: 'ポインター形状',
default: 'デフォルトポインター',
pointer: 'ポインターカーソル',
cell: 'セルカーソル',
cell: 'セルポインター',
text: 'テキストカーソル',
grab: 'つかむカーソル',
hide: 'カーソルを非表示',
grab: 'つかむポインター',
hide: 'ポインターを非表示',
mode: 'マウスモード',
absolute: '絶対モード',
relative: '相対モード',
requestPointer:
'相対モードを使用中です。デスクトップをクリックしてマウスポインタを取得してください。',
resetHid: 'HIDをリセット'
direction: 'ホイール方向',
scrollUp: '上',
scrollDown: '下',
speed: 'ホイール速度',
fast: '速い',
slow: '遅い',
requestPointer: '相対モードを使用中です。マウスポインターを取得するには、デスクトップをクリックしてください。',
resetHid: 'HID をリセット',
hidOnly: {
title: 'HID-Only モード',
desc: '使用中にマウスとキーボードが反応しなくなり、HID をリセットしても効果がない場合は、NanoKVM とデバイス間の互換性に問題がある可能性があります。互換性を向上させるために、HID-Only モードを有効にすることをお勧めします。',
tip1: 'このモードを有効にすると仮想 USB ドライブと仮想ネットワークアダプターが無効になります。',
tip2: 'このモードではイメージマウント機能は使用できません。',
tip3: 'モードを切り替えると、NanoKVM は自動的に再起動します。',
enable: 'HID-Only モードを有効化',
disable: 'HID-Only モードを無効化'
}
},
image: {
title: 'イメージ',
loading: '読み込み中...',
empty: '見つかりませんでした',
loading: '読み込み中',
empty: 'イメージファイルがありません',
mountMode: 'マウントモード',
mountFailed: 'マウントに失敗しました',
mountDesc:
'一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。',
mountDesc: '一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。',
unmountFailed: 'アンマウントに失敗しました',
unmountDesc: '一部のシステムでは、イメージをアンマウントする前にリモートホストから手動で取り出す必要があります。',
refresh: 'イメージリストを更新',
attention: '注意',
deleteConfirm: 'このイメージを削除してもよろしいですか?',
okBtn: 'はい',
cancelBtn: 'いいえ',
tips: {
title: 'アップロード方法',
usb1: 'NanoKVMをUSB経由でコンピュータに接続します。',
usb2: '仮想ディスクがマウントされていることを確認します(設定 - 仮想ディスク)。',
usb1: 'NanoKVM を USB 経由でコンピュータに接続します;',
usb2: '仮想ディスクがマウントされていることを確認します(設定 - 仮想ディスク);',
usb3: 'コンピュータ上で仮想ディスクを開き、イメージファイルを仮想ディスクのルートディレクトリにコピーします。',
scp1: 'NanoKVMとコンピュータが同じローカルネットワークに接続されていることを確認します。',
scp2: 'コンピュータのターミナルを開き、SCPコマンドを使用してイメージファイルをNanoKVMの/dataディレクトリにアップロードします。',
scp3: '例: scp あなたのイメージパス root@あなたのナノKVMのIP:/data',
tfCard: 'TFカード',
tf1: 'この方法はLinuxシステムでサポートされています',
tf2: 'NanoKVMからTFカードを取り出します(フルバージョンの場合、まずケースを分解してください)。',
tf3: 'TFカードをカードリーダーに挿入し、コンピュータに接続します。',
tf4: 'TFカードの/dataディレクトリにイメージファイルをコピーします。',
tf5: 'TFカードをNanoKVMに挿入します。'
scp1: 'NanoKVM とコンピュータが同じローカルエリアネットワークに接続されていることを確認します;',
scp2: 'コンピュータでターミナルを開き、SCP コマンドを使用してイメージファイルを NanoKVM の /data ディレクトリにアップロードします。',
scp3: '例:scp your-image-path root@your-nanokvm-ip:/data',
tfCard: 'TF カード',
tf1: 'この方法は Linux システムでサポートされています',
tf2: 'NanoKVM から TF カードを取り出します(フルバージョンでは、まずケースを分解してください);',
tf3: 'TF カードをカードリーダーに挿入してコンピュータに接続します;',
tf4: 'コンピューターから TF カードの /data ディレクトリにイメージファイルをコピーします;',
tf5: 'TF カードを NanoKVM に挿入します。'
}
},
script: {
@@ -126,7 +185,7 @@ const ja = {
},
terminal: {
title: 'ターミナル',
nanokvm: 'NanoKVMターミナル',
nanokvm: 'NanoKVM ターミナル',
serial: 'シリアルポートターミナル',
serialPort: 'シリアルポート',
serialPortPlaceholder: 'シリアルポートを入力してください',
@@ -137,115 +196,225 @@ const ja = {
title: 'Wake-on-LAN',
sending: 'コマンドを送信中...',
sent: 'コマンドを送信しました',
input: 'MACを入力してください',
input: 'MAC アドレスを入力してください',
ok: 'OK'
},
download: {
title: 'イメージダウンローダー',
input: 'イメージのダウンロードアドレスを入力してください',
ok: 'OK',
disabled: '/data パーティションは読み取り専用であり、イメージのダウンロードには使用できません'
},
power: {
title: '電源',
showConfirm: '確認メッセージ',
showConfirmTip: '電源操作の確認メッセージを表示する',
reset: 'リセット',
power: '電源',
powerShort: '電源(短いクリック)',
powerLong: '電源(長いクリック)'
powerShort: '電源(クリック)',
powerLong: '電源(長押し)',
resetConfirm: '再起動を実行しますか?',
powerConfirm: '電源操作を実行しますか?',
okBtn: 'はい',
cancelBtn: 'いいえ'
},
settings: {
title: 'Settings',
title: '設定',
about: {
title: 'NanoKVMについて',
title: 'NanoKVM について',
information: '情報',
ip: 'IP',
mdns: 'mDNS',
application: 'アプリケーションバージョン',
applicationTip: 'NanoKVM web application version',
applicationTip: 'NanoKVM ウェブアプリケーションバージョン',
image: 'イメージバージョン',
imageTip: 'NanoKVM system image version',
firmware: 'ファームウェアバージョン',
imageTip: 'NanoKVM システムイメージバージョン',
deviceKey: 'デバイスキー',
community: 'コミュニティ'
community: 'コミュニティ',
hostname: 'ホスト名',
hostnameUpdated: 'ホスト名は正常に変更され、再起動後に有効になります'
},
appearance: {
title: 'Appearance',
display: 'Display',
language: 'Language',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar'
title: '外観',
display: '表示',
language: '言語',
languageDesc: 'インターフェース言語の選択',
webTitle: 'ウェブページタイトル',
webTitleDesc: 'ウェブページタイトルのカスタマイズ',
menuBar: {
title: 'メニューバー',
mode: '表示モード',
modeDesc: 'メニューバーの画面表示方法',
modeOff: '閉じる',
modeAuto: '自動非表示',
modeAlways: '常に表示',
icons: 'メニューアイコン',
iconsDesc: 'メニューバーでのサブメニューアイコンの表示'
}
},
device: {
title: 'Device',
title: 'デバイス',
oled: {
title: 'OLED',
description: 'OLED screen automatically sleep',
0: 'Never',
15: '15 sec',
30: '30 sec',
60: '1 min',
180: '3 min',
300: '5 min',
600: '10 min',
1800: '30 min',
3600: '1 hour'
description: 'OLED 画面の自動スリープ時間',
0: '無効',
15: '15秒',
30: '30秒',
60: '1分',
180: '3分',
300: '5分',
600: '10分',
1800: '30分',
3600: '1時間'
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi',
setBtn: 'Config'
description: 'Wi-Fi 設定',
apMode: 'AP モードが有効になりました。QR コードをスキャンして Wi-Fi に接続してください。',
connect: 'Wi-Fi に接続',
connectDesc1: 'SSID とパスワードを入力してください',
connectDesc2: 'このネットワークに接続するためのパスワードを入力してください',
disconnect: 'このネットワーク接続を切断しますか?',
failed: '接続に失敗しました。もう一度お試しください。',
ssid: 'SSID',
password: 'パスワード',
joinBtn: '接続',
confirmBtn: 'OK',
cancelBtn: 'キャンセル'
},
disk: 'Virtual Disk',
diskDesc: 'Mount virtual U-disk on the remote host',
network: 'Virtual Network',
networkDesc: 'Mount virtual network card on the remote host'
ssh: {
description: 'SSH リモートアクセスを有効にする',
tip: '使用する前に必ず強力なパスワードを設定してください(アカウント - パスワードの変更)'
},
tls: {
description: 'HTTPS プロトコルを有効にする',
tip: '注意:HTTPS を使用すると、特に MJPEG ビデオモードで遅延が増加する可能性があります。'
},
advanced: '詳細設定',
swap: {
disable: '無効',
description: 'スワップファイルのサイズを設定する',
tip: 'この機能を有効にすると、SD カードの寿命が短くなる可能性があります!'
},
mouseJiggler: {
title: 'マウスジグラー',
description: 'リモートホストの休止を防ぐ',
disable: '閉じる',
absolute: '絶対モード',
relative: '相対モード'
},
mdns: {
description: 'mDNS 検出サービスを有効にする',
tip: 'この機能を使用していない場合は、オフにすることをお勧めします'
},
hdmi: {
description: 'HDMI/モニター 出力機能を有効にする'
},
autostart: {
title: '自動起動スクリプト設定',
description: 'NanoKVM の起動時に自動的に実行されるスクリプトファイルを管理します',
new: '新しいスクリプトを作成する',
deleteConfirm: 'このファイルを削除してもよろしいですか?',
yes: 'はい',
no: 'いいえ',
scriptName: '自動起動スクリプト名',
scriptContent: '自動起動スクリプト内容',
settings: '設定'
},
hidOnly: 'HID-Only モード',
hidOnlyDesc: 'このモードでは仮想デバイスはマウントされなくなり、基本的な HID 制御機能のみが保持されます。',
disk: '仮想ディスク',
diskDesc: 'リモートホストに仮想 USB ドライブをマウントする',
network: '仮想ネットワークカード',
networkDesc: 'リモートホストに仮想ネットワークカードをマウントする',
okBtn: 'はい',
cancelBtn: 'いいえ'
},
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: 'メモリ最適化',
tip: "メモリ使用量が上限を超えると、メモリ解放のためにより積極的にガベージコレクションが実行されます。Tailscale を使用する場合は 50MB に設定することをお勧めします。この設定を有効にするには Tailscale を再起動する必要があります。",
},
restart: 'Are you sure to restart Tailscale?',
stop: 'Are you sure to stop Tailscale?',
stopDesc: 'Log out Tailscale and disable its automatic startup on boot.',
swap: {
title: 'スワップメモリ',
tip: 'メモリ最適化を有効にしても問題が解決しない場合は、スワップメモリ​​を有効にしてみてください。有効にするとスワップファイルが 256MB に設定されます。このサイズは「設定 - デバイス」で変更できます。'
},
restart: 'Tailscale を再起動しますか?',
stop: 'Tailscale を停止しますか?',
stopDesc: 'Tailscale からログアウトし、起動時の自動実行を無効にします。',
loading: '読み込み中...',
notInstall: 'Tailscaleが見つかりません!インストールしてください。',
notInstall: 'Tailscale が見つかりません。インストールしてください。',
install: 'インストール',
installing: 'インストール中',
failed: 'インストールに失敗しました',
retry: 'ページをリフレッシュして再試行してください。または手動でインストールしてください',
download: 'インストールパッケージをダウンロードして',
package: '解凍してください',
upTailscale: 'tailscaleをNanoKVMのディレクトリ/usr/bin/にアップロードしてください',
upTailscaled: 'tailscaledをNanoKVMのディレクトリ/usr/sbin/にアップロードしてください',
refresh: '現在のページをリフレッシュします',
notLogin:
'デバイスはまだバインドされていません。ログインしてこのデバイスをアカウントにバインドしてください。',
urlPeriod: 'このURLは10分間有効です',
retry: 'ページを更新してもう一度お試しいただくか、手動でインストールしてください',
download: 'ダウンロードして',
package: 'インストールパッケージを',
unzip: '解凍してください',
upTailscale: 'tailscale ファイルを NanoKVM の /usr/bin ディレクトリにアップロードします',
upTailscaled: 'tailscaled ファイルを NanoKVM の /usr/sbin ディレクトリにアップロードします',
refresh: 'ページを更新します',
notRunning: 'Tailscale はまだ実行されていません。起動操作を実行してください',
run: '起動',
notLogin: 'このデバイスはまだバインドされていません。ログインしてデバイスをアカウントにバインドしてください。',
urlPeriod: 'この URL は 10 分間有効です',
login: 'ログイン',
loginSuccess: 'ログイン成功',
enable: 'Tailscaleを有効化',
enable: 'Tailscale を有効化',
deviceName: 'デバイス名',
deviceIP: 'デバイスIP',
deviceIP: 'デバイスアドレス',
account: 'アカウント',
logout: 'ログアウト',
logout2: 'ログアウトしてもよろしいですか?',
okBtn: 'Yes',
cancelBtn: 'No'
logoutDesc: 'ログアウトしてもよろしいですか?',
uninstall: 'Tailscale をアンインストール',
uninstallDesc: 'Tailscale をアンインストールしてもよろしいですか?',
reboot: '再起動',
rebootDesc: 'NanoKVM を再起動してもよろしいですか?',
okBtn: 'はい',
cancelBtn: 'いいえ'
},
update: {
title: 'アップデートの確認',
queryFailed: 'バージョンの取得に失敗しました',
updateFailed: '更新に失敗しました。再試行してください。',
isLatest: '最新のバージョンを既に持っています。',
available: 'アップデートが利用可能です。更新してもよろしいですか?',
updating: '更新を開始しました。お待ちください...',
confirm: '確認',
cancel: 'キャンセル'
title: 'アップデート',
queryFailed: 'バージョン番号の取得に失敗しました',
updateFailed: 'アップデートに失敗しました。もう一度お試しください。',
isLatest: 'すでに最新バージョンです。',
available: '新しいバージョンが利用可能です。アップデートしてもよろしいですか?',
updating: 'アップデート中、お待ちください...',
confirm: 'はい',
cancel: 'いいえ',
preview: 'プレビューアップデート',
previewDesc: '新機能や改善をいち早く体験する',
previewTip: 'プレビューアップデートには不安定な部分や不完全な機能が含まれる場合があります!',
offline: {
title: 'オフラインアップデート',
desc: 'ローカルインストールパッケージでアップデートする',
upload: 'アップロード',
invalidName: 'ファイル名の形式が正しくありません。GitHub リリースページにアクセスしてインストールパッケージをダウンロードしてください。',
updateFailed: 'アップデートに失敗しました。もう一度お試しください。'
}
},
account: {
title: 'Account',
webAccount: 'Web Account Name',
password: 'Password',
updateBtn: 'Update',
logoutBtn: 'Logout'
title: 'アカウント',
webAccount: 'ウェブアカウント名',
password: 'パスワード',
updateBtn: '変更',
logoutBtn: 'ログアウト',
logoutDesc: 'ログアウトしてもよろしいですか?',
confirm: 'はい',
cancel: 'いいえ'
}
},
error: {
title: 'エラーが発生しました',
refresh: '更新'
},
fullscreen: {
toggle: '全画面表示切り替え'
},
menu: {
collapse: 'メニューを折りたたむ',
expand: 'メニューを展開する'
}
}
};

View File

@@ -217,10 +217,19 @@ const ko = {
title: '디자인',
display: '표시',
language: '언어',
menuBar: '메뉴 바',
menuBarDesc: '메뉴 바에 아이콘을 표시',
languageDesc: '인터페이스 언어를 선택하세요',
webTitle: '웹 제목',
webTitleDesc: '웹 페이지 제목 사용자 지정'
webTitleDesc: '웹 페이지 제목 사용자 지정',
menuBar: {
title: '메뉴 바',
mode: '표시 모드',
modeDesc: '메뉴 바를 화면에 표시합니다',
modeOff: '꺼짐',
modeAuto: '자동 숨기기',
modeAlways: '항상 보이기',
icons: '하위 메뉴 아이콘',
iconsDesc: '메뉴 바에 하위 메뉴 아이콘을 표시합니다'
}
},
device: {
title: '장치',
@@ -336,7 +345,14 @@ const ko = {
cancel: '취소',
preview: '미리보기 업데이트',
previewDesc: '새로운 기능과 개선 사항에 미리 접근하세요',
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!'
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!',
offline: {
title: '오프라인 업데이트',
desc: '로컬 설치 패키지를 통한 업데이트',
upload: '업로드',
invalidName: '유효하지 않은 파일 이름 형식입니다. GitHub 릴리즈에서 다운로드하세요.',
updateFailed: '업데이트에 실패했습니다. 재시도하세요.'
}
},
account: {
title: '계정',

View File

@@ -94,7 +94,7 @@ const tr = {
resetHid: 'İnsan Arayüz Cihazı\'nı sıfırlayın',
hidOnly: {
title: 'Sadece İnsan Arayüz Cihazı modu',
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı\'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
tip1: 'Sadece İnsan Arayüz Cihazı modunu aktifleştirmek, sanal U-disk’i ve sanal ağı devre dışı bırakacaktır.',
tip2: 'Sadece İnsan Arayüz Cihazı modunda disk imajı bağlama özelliği devre dışı bırakılır',
tip3: 'NanoKVM mod değişiminden sonra kendiliğinden yeniden başlatılacaktır',

View File

@@ -16,6 +16,8 @@ const zh = {
noEmptyPassword: '密码不能为空',
noAccount: '获取用户信息失败,请刷新重试或重置密码',
invalidUser: '用户名或密码错误',
locked: '登录太频繁,请稍后再试',
globalLocked: '系统防爆破保护中,请稍后再试',
error: '未知错误',
changePassword: '修改密码',
changePasswordDesc: '为了您的设备安全,请修改密码!',
@@ -44,7 +46,14 @@ const zh = {
failed: '操作失败,请重试。',
invalidMode: '当前模式不支持配置网络。请先前往设备启用 Wi-Fi 配置模式。',
confirmBtn: '确定',
finishBtn: '完成'
finishBtn: '完成',
ap: {
authTitle: '身份验证',
authDescription: '请输入 AP 密码以继续',
authFailed: '密码错误',
passPlaceholder: 'AP 密码',
verifyBtn: '验证'
}
},
screen: {
scale: '缩放',
@@ -73,7 +82,42 @@ const zh = {
placeholder: '请输入内容',
submit: '确定',
virtual: '虚拟键盘',
ctrlaltdel: 'Ctrl+Alt+Del'
shortcut: {
title: '快捷键',
custom: '自定义',
capture: '点击此处捕获快捷键',
clear: '清空',
save: '保存',
captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。',
enterFullScreen: '切换全屏模式。'
},
leaderKey: {
title: '引导键',
desc: '绕过浏览器限制,向远程主机发送被系统拦截的快捷键。',
howToUse: '使用方法',
simultaneous: {
title: '组合按键模式',
desc1: '按住引导键不放,同时按下目标快捷键。',
desc2: '操作直观,但少数快捷键可能因系统占用而无法生效。'
},
sequential: {
title: '序列输入模式',
desc1: '点击【引导键】开始 → 依次点击【快捷键】→ 点击【引导键】结束。',
desc2: '步骤较多,但能完美避开系统键位冲突。'
},
enable: '启用引导键',
tip: '设为引导键后,该按键将仅用于触发快捷键,不再作为普通按键使用。',
placeholder: '请按下引导键',
shiftRight: '右 Shift',
ctrlRight: '右 Ctrl',
metaRight: '右 Win',
submit: '确定',
recorder: {
rec: '录制中',
activate: '激活按键',
input: '请按下快捷键...'
}
}
},
mouse: {
title: '鼠标',
@@ -204,10 +248,18 @@ const zh = {
display: '显示',
language: '语言',
languageDesc: '选择界面语言',
menuBar: '菜单栏',
menuBarDesc: '是否在菜单栏中显示图标',
webTitle: '网站标题',
webTitleDesc: '自定义网站页面标题'
webTitle: '网页标题',
webTitleDesc: '自定义网站页面标题',
menuBar: {
title: '菜单栏',
mode: '显示方式',
modeDesc: '菜单栏在屏幕上的显示方式',
modeOff: '关闭',
modeAuto: '自动隐藏',
modeAlways: '始终显示',
icons: '菜单图标',
iconsDesc: '是否在菜单栏中显示子菜单图标'
}
},
device: {
title: '设备',
@@ -362,6 +414,126 @@ const zh = {
cancel: '取消'
}
},
picoclaw: {
title: 'PicoClaw 助手',
empty: '打开面板并发送任务后,PicoClaw 会开始工作。',
inputPlaceholder: '描述你希望 PicoClaw 执行的操作',
newConversation: '新对话',
processing: '正在处理中...',
agent: {
defaultTitle: '通用助手',
defaultDescription: '适合普通问答、搜索和工作区辅助。',
kvmTitle: '远程控制',
kvmDescription: '通过 NanoKVM 操作下游远程主机。',
switched: '角色已切换',
switchFailed: '切换角色失败'
},
send: '发送',
cancel: '取消',
status: {
connecting: '正在连接 Gateway...',
connected: 'PicoClaw 会话已连接',
disconnected: 'PicoClaw 会话已关闭',
stopped: '已发送停止请求',
runtimeStarted: 'PicoClaw 运行时已启动',
runtimeStartFailed: '启动 PicoClaw 运行时失败',
runtimeStopped: 'PicoClaw 运行时已停止',
runtimeStopFailed: '停止 PicoClaw 运行时失败'
},
connection: {
runtime: {
checking: '检查中',
ready: '运行时已就绪',
stopped: '运行时未启动',
unavailable: '运行时不可用',
configError: '配置错误'
},
transport: {
connecting: '连接中',
connected: '已连接'
},
run: {
idle: '空闲',
busy: '执行中'
}
},
message: {
toolAction: '动作',
observation: '观察',
screenshot: '截图'
},
overlay: {
locked: 'PicoClaw 正在控制设备,手动输入已暂停。'
},
install: {
install: '安装 PicoClaw',
installing: '正在安装 PicoClaw',
success: 'PicoClaw 安装成功',
failed: 'PicoClaw 安装失败',
uninstalling: '正在卸载运行时...',
uninstalled: '运行时卸载成功。',
uninstallFailed: '卸载失败。',
requiredTitle: '未安装 PicoClaw',
requiredDescription: '在启动 PicoClaw 运行时之前,需要先下载安装 PicoClaw。',
progressDescription: '正在下载并安装 PicoClaw。',
stages: {
preparing: '准备中',
downloading: '下载中',
extracting: '解压中',
installing: '安装中',
installed: '已安装',
install_timeout: '已超时',
install_failed: '失败'
}
},
model: {
requiredTitle: '需要配置模型',
requiredDescription: '在使用 PicoClaw 聊天之前,请先配置 PicoClaw 模型。',
docsTitle: '配置指南',
docsDesc: '支持的模型与协议',
menuLabel: '配置模型',
modelIdentifier: '模型标识',
modelIdentifierPlaceholder: 'openai/gpt-5.4',
apiBase: 'API Base URL',
apiBasePlaceholder: 'https://api.example.com/v1',
apiKey: 'API Key',
apiKeyPlaceholder: '请输入模型 API Key',
save: '保存模型配置',
saving: '正在保存模型配置',
saved: '模型配置已保存',
saveFailed: '保存模型配置失败',
invalid: '模型标识、API Base URL 和 API Key 不能为空'
},
uninstall: {
menuLabel: '卸载',
confirmTitle: '卸载 PicoClaw',
confirmContent: '确定要卸载 PicoClaw 吗?这会删除可执行文件和所有配置文件。',
confirmOk: '卸载',
confirmCancel: '取消'
},
history: {
title: '历史会话',
loading: '正在加载历史会话...',
emptyTitle: '还没有历史会话',
emptyDescription: '之前的 PicoClaw 会话会显示在这里。',
loadFailed: '加载会话历史失败',
deleteFailed: '删除会话失败',
deleteConfirmTitle: '删除会话',
deleteConfirmContent: '确定要删除“{{title}}”吗?',
deleteConfirmOk: '删除',
deleteConfirmCancel: '取消',
messageCount_one: '{{count}} 条消息',
messageCount_other: '{{count}} 条消息'
},
config: {
startRuntime: '启动 PicoClaw',
stopRuntime: '停止 PicoClaw'
},
start: {
title: '启动 PicoClaw',
description: '启动运行时后即可开始使用 PicoClaw 助手。'
}
},
error: {
title: '我们遇到了问题',
refresh: '刷新'

View File

@@ -14,7 +14,7 @@ const zh_tw = {
placeholderPassword2: '請再次輸入密碼',
noEmptyUsername: '使用者名稱不能為空',
noEmptyPassword: '密碼不能為空',
noAccount: '取得使用者資訊失敗,請重新整理網頁或重設密碼',
noAccount: '找不到使用者,請重新整理網頁或重設密碼',
invalidUser: '使用者名稱或密碼錯誤',
error: '非預期性錯誤',
changePassword: '更改密碼',
@@ -33,26 +33,28 @@ const zh_tw = {
reset4: 'SSH 預設帳號:',
change1: '請注意,此操作將同時更新下列密碼:',
change2: '網頁登入密碼',
change3: 'root user密碼 (SSH登入密碼)',
change3: 'root user 密碼 (SSH登入密碼)',
change4: '如果您忘記密碼,需要長按 NanoKVM 上的 BOOT 按鍵來重設密碼。'
}
},
wifi: {
title: 'Wi-Fi',
description: '設定 NanoKVM WiFi',
description: '設定 NanoKVM Wi-Fi',
success: '請檢查 NanoKVM 的網路狀態,並存取新的 IP 位址。',
failed: '操作失敗,請重試。',
invalidMode: '目前模式不支援設定網路。請先前往裝置啟用 Wi-Fi 配置模式。',
confirmBtn: '確定',
finishBtn: '完成'
},
screen: {
scale: '缩放',
title: '螢幕',
video: '編碼格式',
videoDirectTips: '本模式需先啟用HTTPS,請前往「設定 -> 設備」中開啟',
videoDirectTips: '本模式需先啟用 HTTPS,請前往「設定 -> 設備」中開啟',
resolution: '解析度',
auto: '自動',
autoTips:
'在特定解析度下可能會出現畫面撕裂或滑鼠偏移的情況。考慮調整遠端主機的解析度或停用自動模式。',
'在某些特定解析度下可能會出現畫面撕裂或滑鼠偏移的情況。請調整遠端主機的解析度或停用自動模式。',
fps: '更新頻率',
customizeFps: '自定義',
quality: '品質',
@@ -61,17 +63,26 @@ const zh_tw = {
qualityMedium: '中',
qualityLow: '低',
frameDetect: '影格檢測',
frameDetectTip: '計算影格之間的差異。當遠端主機畫面未偵測到任何變更時,停止傳輸視訊串流。',
frameDetectTip: '計算影格之間的差異。當遠端主機畫面未偵測到任何變更時,停止視訊傳輸串流。',
resetHdmi: '重置 HDMI'
},
keyboard: {
title: 'Keyboard',
title: '鍵盤',
paste: '貼上',
tips: '僅支援標準鍵盤字母和符號',
tips: '僅支援標準鍵盤的字母和符號',
placeholder: '請輸入內容',
submit: '送出',
virtual: '虛擬鍵盤',
ctrlaltdel: 'Ctrl+Alt+Del'
ctrlaltdel: 'Ctrl+Alt+Del',
shortcut: {
title: '快捷鍵',
custom: '自定義',
capture: '點選此處錄製快捷鍵',
clear: '清空',
save: '儲存',
captureTips: '錄製系統級按鍵(如 Windows 鍵)需要全螢幕的權限。',
enterFullScreen: '切換全螢幕模式。'
}
},
mouse: {
title: '滑鼠',
@@ -85,53 +96,63 @@ const zh_tw = {
mode: '滑鼠模式',
absolute: '絕對模式',
relative: '相對模式',
direction: '滾輪方向',
scrollUp: '向上',
scrollDown: '向下',
speed: '滾輪速度',
fast: '快',
slow: '慢',
requestPointer: '使用相對模式。請按一下桌面以取得滑鼠游標。',
requestPointer: '正在使用滑鼠相對模式。請按一下桌面以取得滑鼠游標。',
resetHid: '重設 HID',
hidOnly: {
title: 'HID-Only 模式',
desc: "如果您的滑鼠和鍵盤停止回應,且重設 HID 無效,可能是 NanoKVM 與裝置間的相容性問題。請嘗試啟用 HID-Only 模式以獲得更好的相容性。",
tip1: '啟用 HID-Only 模式將會卸載虛擬隨身碟和虛擬網卡',
tip2: '在 HID-Only 模式下,映像掛載功能將被停用',
desc: "如果您的滑鼠和鍵盤沒有反應,且重設 HID 無效,可能是 NanoKVM 與您的裝置間有相容性問題。請嘗試啟用 HID-Only 模式以獲得更好的相容性。",
tip1: '啟用 HID-Only 模式將會停用虛擬隨身碟和虛擬網卡的功能',
tip2: '在 HID-Only 模式下,映像檔掛載功能將被停用',
tip3: 'NanoKVM 將在切換模式後自動重新啟動',
enable: '啟用 HID-Only 模式',
disable: '停用 HID-Only 模式'
}
},
image: {
title: '映像',
title: '映像檔',
loading: '載入中...',
empty: '未找到任何內容',
cdrom: '以CD-ROM模式掛載',
mountMode: '掛載模式',
mountFailed: '掛載失敗',
mountDesc: '在某些系統中,在掛載映像之前需要中斷遠端主機上的虛擬磁碟。',
refresh: 'Refresh the image list',
mountDesc: '在某些系統中,需要在遠端主機中彈出虛擬硬碟後再掛載映像檔。',
unmountFailed: '解除安裝失敗',
unmountDesc: '在某些系統中,需要在遠端主機中手動彈出後再解除安裝映像。',
refresh: '重新整理映像檔列表',
attention: '注意',
deleteConfirm: '確定要刪除該映像檔嗎?',
okBtn: '確定',
cancelBtn: '取消',
tips: {
title: '如何上傳',
usb1: '透過 USB 將 NanoKVM 連接到您的電腦。',
usb2: '確保已安裝虛擬磁碟(設定 - 虛擬磁碟)。',
usb3: '開啟電腦上的虛擬磁碟,將映像檔案複製到虛擬磁碟的根目錄下。',
usb3: '開啟電腦上的虛擬磁碟,將映象檔案複製到虛擬磁碟的根目錄下。',
scp1: '確保 NanoKVM 和您的電腦位於同一區域網路。',
scp2: '開啟電腦上的終端機,使用 SCP 指令將映像檔案上傳到 NanoKVM 的 /data 目錄下。',
scp3: '範例:scp your-image-path root@your-nanokvm-ip:/data',
tfCard: 'microSD 卡',
tf1: '此方法適用於 Linux 系統',
tf2: '從 NanoKVM 取得 microSD 卡(FULL 版本請先拆開外殼)。',
tf3: '將 microSD 卡插入讀卡機並連接至電腦。',
tf4: '將映像檔案複製到 microSD 卡的 /data 目錄下。',
tf5: '將 microSD 卡重新插入 NanoKVM。'
tf2: '從 NanoKVM 拔出 microSD 卡(FULL 版本請先拆開外殼)。',
tf3: '使用 USB 讀卡機將 microSD 卡連接至電腦。',
tf4: '將映像檔複製到 microSD 卡的 /data 目錄下。',
tf5: '將 microSD 卡重新插回 NanoKVM。'
}
},
script: {
title: '腳本',
title: '指令碼',
upload: '上傳',
run: '執行',
runBackground: '背景執行',
runFailed: '執行失敗',
attention: '注意',
delDesc: '確定刪除該檔案?',
delDesc: '確定要刪除該檔案嗎?',
confirm: '確定',
cancel: '取消',
delete: '刪除',
@@ -164,22 +185,22 @@ const zh_tw = {
ok: '確定'
},
download: {
title: '下载映像',
input: '請輸入映像檔的下載URL',
title: '下载映像檔',
input: '請輸入映像檔的下載 URL',
ok: '確定',
disabled: '/data 為唯讀目錄,無法下載映像檔'
},
power: {
title: '電源',
showConfirm: 'Confirmation',
showConfirmTip: 'Power operations require an extra confirmation',
title: '電源控制',
showConfirm: '顯示確認框',
showConfirmTip: '電源操作需要二次確認',
reset: '重新啟動',
power: '電源',
powerShort: '電源 (短按)',
powerLong: '電源 (長按)',
resetConfirm: '執行重新啟動按鍵操作?',
powerConfirm: '執行電源按鍵操作?',
okBtn: '是',
resetConfirm: '確認執行重新啟動的按鍵操作嗎?',
powerConfirm: '確認執行電源的按鍵操作嗎?',
okBtn: '確認',
cancelBtn: '取消'
},
settings: {
@@ -191,8 +212,8 @@ const zh_tw = {
mdns: 'mDNS',
application: '應用程式版本',
applicationTip: 'NanoKVM 網頁程式版本',
image: '映像版本',
imageTip: 'NanoKVM 系统镜像版本',
image: '韌體版本',
imageTip: 'NanoKVM 系统韌體版本',
deviceKey: '設備序號',
community: '社群',
hostname: '主機名稱',
@@ -207,10 +228,20 @@ const zh_tw = {
title: '外觀',
display: '顯示',
language: '語言',
menuBar: '選單列',
languageDesc: '選擇介面語言',
menuBarDesc: '是否在選單列中顯示圖案',
webTitle: '網頁標題',
webTitleDesc: '自訂網頁標題'
webTitleDesc: '自訂網頁標題',
menuBar: {
title: '選單列',
mode: '顯示方式',
modeDesc: '選單欄在螢幕上的顯示方式',
modeOff: '關閉',
modeAuto: '自動隱藏',
modeAlways: '始終顯示',
icons: '選單圖示',
iconsDesc: '是否在選單欄中顯示子選單圖示'
}
},
device: {
title: '設備',
@@ -218,57 +249,80 @@ const zh_tw = {
title: 'OLED',
description: '設定 OLED 螢幕自動睡眠時間',
0: '永不',
15: '15秒',
30: '30秒',
60: '1分鐘',
180: '3分鐘',
300: '5分鐘',
600: '10分鐘',
1800: '30分鐘',
3600: '1小時'
15: '15 秒',
30: '30 秒',
60: '1 分鐘',
180: '3 分鐘',
300: '5 分鐘',
600: '10 分鐘',
1800: '30 分鐘',
3600: '1 小時'
},
wifi: {
title: 'Wi-Fi',
description: '設定 Wi-Fi',
setBtn: '設定'
setBtn: '設定',
apMode: 'AP 模式已啟用,該模式下僅支援掃描 QRCode 配網',
connect: '連線 Wi-Fi',
connectDesc1: '請輸入網路名稱和密碼',
connectDesc2: '請輸入密碼以連線此網路',
disconnect: '是否要中斷該網路連線?',
failed: '連線失敗,請重試',
ssid: 'SSID 名稱',
password: '密碼',
joinBtn: '加入',
confirmBtn: '確定',
cancelBtn: '取消'
},
ssh: {
description: '啟用SSH',
description: '啟用 SSH 伺服器',
tip: '啟用前請務必設定強密碼(帳號 - 更改密碼)'
},
tls: {
description: '啟用 HTTPS',
tip: '啟用 HTTPS 可以提高安全性,但可能會增加延遲,特別是使用 MJPEG 編碼格式時。'
description: '啟用 HTTPS 協議',
tip: '啟用 HTTPS 可以提高安全性,但可能會增加傳輸延遲,特別是使用 MJPEG 格式傳輸時。'
},
advanced: '進階設定',
swap: {
title: 'Swap',
disable: '停用',
description: '設定Swap檔大小',
description: '設定 Swap 檔大小',
tip: "啟用此功能可能會減少SD卡的使用壽命!"
},
mouseJiggler: {
title: 'Mouse Jiggler',
title: '滑鼠抖動模式 (Mouse Jiggler)',
description: '避免遠端主機進入休眠狀態',
disable: '停用',
absolute: '絕對模式',
relative: '相對模式'
},
mdns: {
description: '啟用mDNS服務',
description: '啟用 mDNS 發現服務',
tip: "若無需求,建議關閉此功能"
},
hdmi: {
description: '啟用 HDMI/螢幕 輸出'
},
autostart: {
title: '啟動時指令碼設定',
description: '管理能夠在 NanoKVM 啟動時自動執行的相關指令碼',
new: '建立新指令碼',
deleteConfirm: '確定要刪除該檔案嗎?',
yes: '是',
no: '否',
scriptName: 'Script 名稱',
scriptContent: 'Script 內容',
settings: '設定'
},
hidOnly: 'HID-Only 模式',
hidOnlyDesc: '該模式下不再打開虛擬隨身碟,僅保留基礎的 鍵盤/滑鼠 的控制功能。',
disk: '虛擬隨身碟',
diskDesc: '在遠端主機上連接虛擬隨身碟',
network: '虛擬網卡',
networkDesc: '在遠端主機上新增虛擬網卡',
reboot: '重新啟動',
rebootDesc: '你確定要重新啟動 NanoKVM?',
okBtn: '是',
rebootDesc: '您確定要重新啟動 NanoKVM?',
okBtn: '確定',
cancelBtn: '取消'
},
tailscale: {
@@ -278,6 +332,10 @@ const zh_tw = {
tip: '當記憶體使用量超過限制時,會更積極的進行垃圾回收來嘗試釋放記憶體。若使用 Tailscale 建議設定為 50MB,於重啟 Tailscale 後生效。',
disable: '關閉'
},
swap: {
title: 'Swap',
tip: '如果啟用記憶體最佳化後依然存在問題,可以嘗試開啟 Swap。啟用後會將交換檔案設定為 256MB,可以在「設定 - 裝置」中修改該選項。'
},
restart: '確定要重啟 Tailscale 嗎?',
stop: '確定要停止 Tailscale 嗎?',
stopDesc: '此操作將會登出帳號,並停止開機自動啟動。',
@@ -290,9 +348,11 @@ const zh_tw = {
download: '下載',
package: '安裝包',
unzip: '並解壓縮它',
upTailscale: '將 Tailscale 上傳到 NanoKVM 目錄 /usr/bin/',
upTailscaled: '將 Tailscale 上傳到 NanoKVM 目錄 /usr/sbin/',
upTailscale: '將 Tailscale 上傳到 NanoKVM 的 /usr/bin/ 資料夾',
upTailscaled: '將 Tailscale 上傳到 NanoKVM 的 /usr/sbin/ 資料夾',
refresh: '重新整理頁面',
notRunning: 'Tailscale 尚未執行',
run: '啟動',
notLogin: '設備尚未綁定。請登入並將該裝置綁定到您的帳戶。',
urlPeriod: '此網址有效期限為 10 分鐘',
login: '登入',
@@ -302,24 +362,33 @@ const zh_tw = {
deviceIP: '裝置 IP',
account: '帳號',
logout: '登出',
logoutDesc: '確認登出?',
logoutDesc: '確認要登出嗎?',
uninstall: '移除 Tailscale',
uninstallDesc: '確定要解除安裝 Tailscale 嗎?',
reboot: '重啟',
rebootDesc: '您確定要重啟 NanoKVM 嗎?',
okBtn: '確認',
cancelBtn: '取消'
},
update: {
title: '檢查更新',
queryFailed: '取得版本失敗',
queryFailed: '取得版本號失敗',
updateFailed: '更新失敗。請重試。',
isLatest: '您已經擁有最新版本。',
available: '有可用更新。確定要更新嗎?',
updating: '正在更新。請稍等...',
isLatest: '您已經是最新版本。',
available: '發現有可用更新。您確定要更新嗎?',
updating: '更新中,請稍候...',
confirm: '確定',
cancel: '取消',
preview: '預覽更新',
previewDesc: '搶先體驗新功能和改進',
previewTip:
'請注意,預覽版本可能包含錯誤或功能不完整!'
previewDesc: '預覽版本,搶先體驗新功能和改進',
previewTip: '請注意,預覽版本可能包含一些不穩定因素或未完善的功能!',
offline: {
title: '離線更新',
desc: '透過本地安裝包進行更新',
upload: '上傳',
invalidName: '檔名格式錯誤,請前往 GitHub 釋出頁下載安裝包。',
updateFailed: '更新失敗,請重試'
}
},
account: {
title: '帳號',
@@ -327,9 +396,9 @@ const zh_tw = {
password: '密碼',
updateBtn: '修改',
logoutBtn: '登出',
logoutDesc: '你確定要登出嗎?',
okBtn: '是',
cancelBtn: '取消'
logoutDesc: '您確定要登出嗎?',
confirm: '確定',
cancel: '取消'
}
},
error: {

View File

@@ -5,3 +5,6 @@ export const isKeyboardEnableAtom = atom(true);
// is the virtual keyboard opened
export const isKeyboardOpenAtom = atom(false);
// leader key code for bypassing browser shortcuts (empty string means disabled)
export const leaderKeyAtom = atom('');

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