mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
Add an MCP Streamable HTTP server with token authentication, screenshot capture, keyboard, and mouse tools. Introduce shared control-mode and input-control coordination so MCP, PicoClaw, and local HID paths serialize ownership safely. Integrate PicoClaw gateway/runtime control handoff with PID-managed startup and focused unit coverage.
246 lines
5.7 KiB
Go
246 lines
5.7 KiB
Go
package hid
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestMouseModeSwitchReleasesPreviousDevice(t *testing.T) {
|
|
relativeReader, relativeWriter, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer relativeReader.Close()
|
|
absoluteReader, absoluteWriter, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer absoluteReader.Close()
|
|
|
|
h := &Hid{g1: relativeWriter, g2: absoluteWriter}
|
|
queue := make(chan QueuedReport, 2)
|
|
completed := make(chan bool, 2)
|
|
absoluteReset := make(chan struct{}, 1)
|
|
absoluteDown := []byte{1, 0x34, 0x12, 0x78, 0x56, 0}
|
|
queue <- QueuedReport{
|
|
Data: absoluteDown,
|
|
Complete: func(success bool) { completed <- success },
|
|
ResetAbsoluteMouse: func() {
|
|
absoluteReset <- struct{}{}
|
|
},
|
|
}
|
|
queue <- QueuedReport{
|
|
Data: relativeMouseReleaseReport(),
|
|
Complete: func(success bool) { completed <- success },
|
|
ResetAbsoluteMouse: func() { absoluteReset <- struct{}{} },
|
|
}
|
|
close(queue)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
h.mouseReports(queue, "unused-relative", "unused-absolute")
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("mouse worker did not stop")
|
|
}
|
|
|
|
for range 2 {
|
|
select {
|
|
case success := <-completed:
|
|
if !success {
|
|
t.Fatal("mouse report failed")
|
|
}
|
|
default:
|
|
t.Fatal("missing mouse completion")
|
|
}
|
|
}
|
|
select {
|
|
case <-absoluteReset:
|
|
default:
|
|
t.Fatal("absolute state was not reset during mode switch")
|
|
}
|
|
|
|
absoluteData := make([]byte, 12)
|
|
if _, err := io.ReadFull(absoluteReader, absoluteData); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(absoluteData[:6], absoluteDown) {
|
|
t.Fatalf("absolute down report = %v", absoluteData[:6])
|
|
}
|
|
wantRelease := absoluteMouseReleaseReport(absoluteDown)
|
|
if !bytes.Equal(absoluteData[6:], wantRelease) {
|
|
t.Fatalf("absolute release report = %v, want %v", absoluteData[6:], wantRelease)
|
|
}
|
|
relativeData := make([]byte, 4)
|
|
if _, err := io.ReadFull(relativeReader, relativeData); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(relativeData, relativeMouseReleaseReport()) {
|
|
t.Fatalf("relative report = %v", relativeData)
|
|
}
|
|
h.Close()
|
|
}
|
|
|
|
func TestKeyboardFailureCompletesAfterCleanup(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "hidg0")
|
|
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := closedFile.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
h := &Hid{g0: closedFile}
|
|
queue := make(chan QueuedReport, 1)
|
|
cleanupStarted := make(chan struct{})
|
|
allowCleanup := make(chan struct{})
|
|
cleanupFinished := make(chan struct{})
|
|
completed := make(chan bool, 1)
|
|
executions := 0
|
|
queue <- QueuedReport{
|
|
Data: []byte{0, 0, 4, 0, 0, 0, 0, 0},
|
|
Execute: func(write func() error) error {
|
|
executions++
|
|
if executions == 2 {
|
|
close(cleanupStarted)
|
|
<-allowCleanup
|
|
}
|
|
err := write()
|
|
if executions == 2 {
|
|
close(cleanupFinished)
|
|
}
|
|
return err
|
|
},
|
|
Complete: func(success bool) { completed <- success },
|
|
}
|
|
close(queue)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
h.keyboardReports(queue, path)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-cleanupStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("cleanup did not start")
|
|
}
|
|
select {
|
|
case success := <-completed:
|
|
t.Fatalf("reservation completed before cleanup: success=%v", success)
|
|
default:
|
|
}
|
|
|
|
close(allowCleanup)
|
|
select {
|
|
case <-cleanupFinished:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("cleanup did not finish")
|
|
}
|
|
select {
|
|
case success := <-completed:
|
|
if success {
|
|
t.Fatal("failed write completed successfully")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("reservation was not completed")
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("keyboard worker did not stop")
|
|
}
|
|
h.Close()
|
|
}
|
|
|
|
func TestKeyboardFailureCompletesFalseWhenCleanupFails(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "hidg0")
|
|
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := closedFile.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
h := &Hid{g0: closedFile}
|
|
queue := make(chan QueuedReport, 1)
|
|
completed := make(chan bool, 1)
|
|
queue <- QueuedReport{
|
|
Data: []byte{0, 0, 4, 0, 0, 0, 0, 0},
|
|
Complete: func(success bool) { completed <- success },
|
|
}
|
|
close(queue)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
h.keyboardReports(queue, filepath.Join(t.TempDir(), "missing", "hidg0"))
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case success := <-completed:
|
|
if success {
|
|
t.Fatal("failed keyboard write completed successfully after cleanup failed")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("reservation was not completed")
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("keyboard worker did not stop")
|
|
}
|
|
h.Close()
|
|
}
|
|
|
|
func TestMouseFailureCompletesFalseWhenCleanupFails(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "hidg1")
|
|
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := closedFile.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
h := &Hid{g1: closedFile}
|
|
queue := make(chan QueuedReport, 1)
|
|
completed := make(chan bool, 1)
|
|
queue <- QueuedReport{
|
|
Data: []byte{1, 0, 0, 0},
|
|
Complete: func(success bool) { completed <- success },
|
|
}
|
|
close(queue)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
h.mouseReports(queue, filepath.Join(t.TempDir(), "missing", "hidg1"), filepath.Join(t.TempDir(), "hidg2"))
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case success := <-completed:
|
|
if success {
|
|
t.Fatal("failed mouse write completed successfully after cleanup failed")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("reservation was not completed")
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("mouse worker did not stop")
|
|
}
|
|
h.Close()
|
|
}
|