feat: RDP service with mock backend for development
Add RDPService with session lifecycle management (connect, disconnect, input forwarding, frame retrieval) using an injected backend factory. Implement MockBackend that generates animated test frames with a blue-purple gradient, bouncing color-cycling rectangle, pulsing circle, and grid overlay for canvas renderer verification on macOS without FreeRDP. The real FreeRDP purego bindings are deferred to Windows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
14b780c914
commit
6c65f171f8
313
internal/rdp/mock_backend.go
Normal file
313
internal/rdp/mock_backend.go
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockBackend implements the RDPBackend interface for development and testing.
|
||||||
|
// Instead of connecting to a real RDP server, it generates animated test frames
|
||||||
|
// with a gradient pattern, moving shapes, and a timestamp overlay.
|
||||||
|
type MockBackend struct {
|
||||||
|
connected bool
|
||||||
|
config RDPConfig
|
||||||
|
buffer *PixelBuffer
|
||||||
|
mu sync.Mutex
|
||||||
|
startTime time.Time
|
||||||
|
clipboard string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockBackend creates a new MockBackend instance.
|
||||||
|
func NewMockBackend() *MockBackend {
|
||||||
|
return &MockBackend{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect initializes the mock backend with the given configuration and starts
|
||||||
|
// generating test frames.
|
||||||
|
func (m *MockBackend) Connect(config RDPConfig) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if m.connected {
|
||||||
|
return fmt.Errorf("already connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
width := config.Width
|
||||||
|
height := config.Height
|
||||||
|
if width <= 0 {
|
||||||
|
width = 1920
|
||||||
|
}
|
||||||
|
if height <= 0 {
|
||||||
|
height = 1080
|
||||||
|
}
|
||||||
|
|
||||||
|
m.config = config
|
||||||
|
m.config.Width = width
|
||||||
|
m.config.Height = height
|
||||||
|
m.buffer = NewPixelBuffer(width, height)
|
||||||
|
m.connected = true
|
||||||
|
m.startTime = time.Now()
|
||||||
|
|
||||||
|
// Generate the initial test frame
|
||||||
|
m.generateFrame()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect shuts down the mock backend.
|
||||||
|
func (m *MockBackend) Disconnect() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if !m.connected {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.connected = false
|
||||||
|
m.buffer = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMouseEvent records a mouse event (no-op for mock).
|
||||||
|
func (m *MockBackend) SendMouseEvent(x, y int, flags uint32) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if !m.connected {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// In the mock, we draw a cursor indicator at the mouse position
|
||||||
|
if m.buffer != nil && flags&MouseFlagMove != 0 {
|
||||||
|
m.drawCursor(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendKeyEvent records a key event (no-op for mock).
|
||||||
|
func (m *MockBackend) SendKeyEvent(scancode uint32, pressed bool) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if !m.connected {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendClipboard stores clipboard text (mock implementation).
|
||||||
|
func (m *MockBackend) SendClipboard(data string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if !m.connected {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.clipboard = data
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFrame returns the current test frame. Each call regenerates the frame
|
||||||
|
// with an updated animation state so the renderer can verify dynamic updates.
|
||||||
|
func (m *MockBackend) GetFrame() ([]byte, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if !m.connected {
|
||||||
|
return nil, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.generateFrame()
|
||||||
|
return m.buffer.GetFrame(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected reports whether the mock backend is connected.
|
||||||
|
func (m *MockBackend) IsConnected() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.connected
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateFrame creates a visually interesting test frame with:
|
||||||
|
// - A blue-to-purple diagonal gradient background
|
||||||
|
// - An animated bouncing rectangle
|
||||||
|
// - A grid overlay for alignment verification
|
||||||
|
// - Session info text area
|
||||||
|
func (m *MockBackend) generateFrame() {
|
||||||
|
w := m.config.Width
|
||||||
|
h := m.config.Height
|
||||||
|
elapsed := time.Since(m.startTime).Seconds()
|
||||||
|
|
||||||
|
data := make([]byte, w*h*4)
|
||||||
|
|
||||||
|
// ── Background: diagonal gradient from dark blue to dark purple ──
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
offset := (y*w + x) * 4
|
||||||
|
// Normalized coordinates
|
||||||
|
nx := float64(x) / float64(w)
|
||||||
|
ny := float64(y) / float64(h)
|
||||||
|
diag := (nx + ny) / 2.0
|
||||||
|
|
||||||
|
r := uint8(20 + diag*40) // 20–60
|
||||||
|
g := uint8(25 + (1.0-diag)*30) // 25–55
|
||||||
|
b := uint8(80 + diag*100) // 80–180
|
||||||
|
data[offset+0] = r
|
||||||
|
data[offset+1] = g
|
||||||
|
data[offset+2] = b
|
||||||
|
data[offset+3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grid overlay: subtle lines every 100 pixels ──
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
if x%100 == 0 || y%100 == 0 {
|
||||||
|
offset := (y*w + x) * 4
|
||||||
|
data[offset+0] = min8(data[offset+0]+20, 255)
|
||||||
|
data[offset+1] = min8(data[offset+1]+20, 255)
|
||||||
|
data[offset+2] = min8(data[offset+2]+20, 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Animated bouncing rectangle ──
|
||||||
|
rectW, rectH := 200, 120
|
||||||
|
// Bounce horizontally and vertically using sine/cosine
|
||||||
|
cx := int(float64(w-rectW) * (0.5 + 0.4*math.Sin(elapsed*0.7)))
|
||||||
|
cy := int(float64(h-rectH) * (0.5 + 0.4*math.Cos(elapsed*0.5)))
|
||||||
|
|
||||||
|
// Color cycles through hues
|
||||||
|
hue := math.Mod(elapsed*30, 360)
|
||||||
|
rr, gg, bb := hueToRGB(hue)
|
||||||
|
|
||||||
|
for ry := 0; ry < rectH; ry++ {
|
||||||
|
for rx := 0; rx < rectW; rx++ {
|
||||||
|
px := cx + rx
|
||||||
|
py := cy + ry
|
||||||
|
if px >= 0 && px < w && py >= 0 && py < h {
|
||||||
|
offset := (py*w + px) * 4
|
||||||
|
// Border: 3px white outline
|
||||||
|
if rx < 3 || rx >= rectW-3 || ry < 3 || ry >= rectH-3 {
|
||||||
|
data[offset+0] = 255
|
||||||
|
data[offset+1] = 255
|
||||||
|
data[offset+2] = 255
|
||||||
|
data[offset+3] = 255
|
||||||
|
} else {
|
||||||
|
data[offset+0] = rr
|
||||||
|
data[offset+1] = gg
|
||||||
|
data[offset+2] = bb
|
||||||
|
data[offset+3] = 220
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Info panel in top-left corner ──
|
||||||
|
panelW, panelH := 320, 80
|
||||||
|
for py := 10; py < 10+panelH && py < h; py++ {
|
||||||
|
for px := 10; px < 10+panelW && px < w; px++ {
|
||||||
|
offset := (py*w + px) * 4
|
||||||
|
data[offset+0] = 0
|
||||||
|
data[offset+1] = 0
|
||||||
|
data[offset+2] = 0
|
||||||
|
data[offset+3] = 180
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Secondary animated element: pulsing circle ──
|
||||||
|
circleX := w / 2
|
||||||
|
circleY := h / 2
|
||||||
|
radius := 40.0 + 20.0*math.Sin(elapsed*2.0)
|
||||||
|
|
||||||
|
for dy := -70; dy <= 70; dy++ {
|
||||||
|
for dx := -70; dx <= 70; dx++ {
|
||||||
|
dist := math.Sqrt(float64(dx*dx + dy*dy))
|
||||||
|
if dist <= radius && dist >= radius-4 {
|
||||||
|
px := circleX + dx
|
||||||
|
py := circleY + dy
|
||||||
|
if px >= 0 && px < w && py >= 0 && py < h {
|
||||||
|
offset := (py*w + px) * 4
|
||||||
|
data[offset+0] = 88
|
||||||
|
data[offset+1] = 166
|
||||||
|
data[offset+2] = 255
|
||||||
|
data[offset+3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply as a full-frame update
|
||||||
|
m.buffer.Update(0, 0, w, h, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawCursor draws a small crosshair at the given position.
|
||||||
|
func (m *MockBackend) drawCursor(cx, cy int) {
|
||||||
|
size := 10
|
||||||
|
w := m.config.Width
|
||||||
|
h := m.config.Height
|
||||||
|
|
||||||
|
cursorData := make([]byte, (size*2+1)*(size*2+1)*4)
|
||||||
|
idx := 0
|
||||||
|
for dy := -size; dy <= size; dy++ {
|
||||||
|
for dx := -size; dx <= size; dx++ {
|
||||||
|
if dx == 0 || dy == 0 {
|
||||||
|
cursorData[idx+0] = 255
|
||||||
|
cursorData[idx+1] = 255
|
||||||
|
cursorData[idx+2] = 0
|
||||||
|
cursorData[idx+3] = 200
|
||||||
|
}
|
||||||
|
idx += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startX := cx - size
|
||||||
|
startY := cy - size
|
||||||
|
if startX < 0 {
|
||||||
|
startX = 0
|
||||||
|
}
|
||||||
|
if startY < 0 {
|
||||||
|
startY = 0
|
||||||
|
}
|
||||||
|
_ = w
|
||||||
|
_ = h
|
||||||
|
|
||||||
|
m.buffer.Update(startX, startY, size*2+1, size*2+1, cursorData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hueToRGB converts a hue angle (0-360) to RGB values.
|
||||||
|
func hueToRGB(hue float64) (uint8, uint8, uint8) {
|
||||||
|
h := math.Mod(hue, 360) / 60
|
||||||
|
c := 200.0 // chroma
|
||||||
|
x := c * (1 - math.Abs(math.Mod(h, 2)-1))
|
||||||
|
|
||||||
|
var r, g, b float64
|
||||||
|
switch {
|
||||||
|
case h < 1:
|
||||||
|
r, g, b = c, x, 0
|
||||||
|
case h < 2:
|
||||||
|
r, g, b = x, c, 0
|
||||||
|
case h < 3:
|
||||||
|
r, g, b = 0, c, x
|
||||||
|
case h < 4:
|
||||||
|
r, g, b = 0, x, c
|
||||||
|
case h < 5:
|
||||||
|
r, g, b = x, 0, c
|
||||||
|
default:
|
||||||
|
r, g, b = c, 0, x
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint8(r + 55), uint8(g + 55), uint8(b + 55)
|
||||||
|
}
|
||||||
|
|
||||||
|
// min8 returns the smaller of two uint8 values.
|
||||||
|
func min8(a, b uint8) uint8 {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
172
internal/rdp/service.go
Normal file
172
internal/rdp/service.go
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RDPSession represents an active RDP connection with its associated state.
|
||||||
|
type RDPSession struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Config RDPConfig `json:"config"`
|
||||||
|
Backend RDPBackend `json:"-"`
|
||||||
|
Buffer *PixelBuffer `json:"-"`
|
||||||
|
ConnID int64 `json:"connectionId"`
|
||||||
|
Connected time.Time `json:"connected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RDPService manages multiple RDP sessions. It uses a backend factory to
|
||||||
|
// create new RDPBackend instances — during development the factory returns
|
||||||
|
// MockBackend instances; in production it will return FreeRDP-backed ones.
|
||||||
|
type RDPService struct {
|
||||||
|
sessions map[string]*RDPSession
|
||||||
|
mu sync.RWMutex
|
||||||
|
backendFactory func() RDPBackend
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRDPService creates a new service with the given backend factory.
|
||||||
|
// The factory is called once per Connect to create a fresh backend.
|
||||||
|
func NewRDPService(factory func() RDPBackend) *RDPService {
|
||||||
|
return &RDPService{
|
||||||
|
sessions: make(map[string]*RDPSession),
|
||||||
|
backendFactory: factory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect creates a new RDP session using the provided configuration.
|
||||||
|
// Returns the session ID on success.
|
||||||
|
func (s *RDPService) Connect(config RDPConfig, connectionID int64) (string, error) {
|
||||||
|
// Apply defaults
|
||||||
|
if config.Port <= 0 {
|
||||||
|
config.Port = 3389
|
||||||
|
}
|
||||||
|
if config.Width <= 0 {
|
||||||
|
config.Width = 1920
|
||||||
|
}
|
||||||
|
if config.Height <= 0 {
|
||||||
|
config.Height = 1080
|
||||||
|
}
|
||||||
|
if config.ColorDepth <= 0 {
|
||||||
|
config.ColorDepth = 32
|
||||||
|
}
|
||||||
|
if config.Security == "" {
|
||||||
|
config.Security = "nla"
|
||||||
|
}
|
||||||
|
|
||||||
|
backend := s.backendFactory()
|
||||||
|
if err := backend.Connect(config); err != nil {
|
||||||
|
return "", fmt.Errorf("rdp connect to %s:%d: %w", config.Hostname, config.Port, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := uuid.NewString()
|
||||||
|
session := &RDPSession{
|
||||||
|
ID: sessionID,
|
||||||
|
Config: config,
|
||||||
|
Backend: backend,
|
||||||
|
Buffer: NewPixelBuffer(config.Width, config.Height),
|
||||||
|
ConnID: connectionID,
|
||||||
|
Connected: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sessions[sessionID] = session
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return sessionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect tears down the RDP session and removes it from tracking.
|
||||||
|
func (s *RDPService) Disconnect(sessionID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
session, ok := s.sessions[sessionID]
|
||||||
|
if !ok {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return fmt.Errorf("session %s not found", sessionID)
|
||||||
|
}
|
||||||
|
delete(s.sessions, sessionID)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return session.Backend.Disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMouse forwards a mouse event to the session's backend.
|
||||||
|
func (s *RDPService) SendMouse(sessionID string, x, y int, flags uint32) error {
|
||||||
|
session, err := s.getSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return session.Backend.SendMouseEvent(x, y, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendKey forwards a key event to the session's backend.
|
||||||
|
func (s *RDPService) SendKey(sessionID string, scancode uint32, pressed bool) error {
|
||||||
|
session, err := s.getSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return session.Backend.SendKeyEvent(scancode, pressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendClipboard forwards clipboard text to the session's backend.
|
||||||
|
func (s *RDPService) SendClipboard(sessionID string, data string) error {
|
||||||
|
session, err := s.getSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return session.Backend.SendClipboard(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFrame returns the current RGBA pixel buffer for the given session.
|
||||||
|
func (s *RDPService) GetFrame(sessionID string) ([]byte, error) {
|
||||||
|
session, err := s.getSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return session.Backend.GetFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionInfo returns the session metadata without the backend or buffer.
|
||||||
|
// This is safe to serialize to JSON for the frontend.
|
||||||
|
func (s *RDPService) GetSessionInfo(sessionID string) (*RDPSession, error) {
|
||||||
|
session, err := s.getSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions returns all active RDP sessions.
|
||||||
|
func (s *RDPService) ListSessions() []*RDPSession {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
list := make([]*RDPSession, 0, len(s.sessions))
|
||||||
|
for _, sess := range s.sessions {
|
||||||
|
list = append(list, sess)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected returns whether a specific session is still connected.
|
||||||
|
func (s *RDPService) IsConnected(sessionID string) bool {
|
||||||
|
session, err := s.getSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return session.Backend.IsConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSession is an internal helper that retrieves a session by ID.
|
||||||
|
func (s *RDPService) getSession(sessionID string) (*RDPSession, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
session, ok := s.sessions[sessionID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("session %s not found", sessionID)
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
255
internal/rdp/service_test.go
Normal file
255
internal/rdp/service_test.go
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
package rdp
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNewRDPService(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
if svc == nil {
|
||||||
|
t.Fatal("NewRDPService returned nil")
|
||||||
|
}
|
||||||
|
if len(svc.ListSessions()) != 0 {
|
||||||
|
t.Error("new service should have no sessions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectWithMockBackend(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
|
||||||
|
config := RDPConfig{
|
||||||
|
Hostname: "test-host",
|
||||||
|
Port: 3389,
|
||||||
|
Username: "admin",
|
||||||
|
Width: 1024,
|
||||||
|
Height: 768,
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := svc.Connect(config, 42)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Connect error: %v", err)
|
||||||
|
}
|
||||||
|
if sessionID == "" {
|
||||||
|
t.Fatal("Connect returned empty session ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session is tracked
|
||||||
|
sessions := svc.ListSessions()
|
||||||
|
if len(sessions) != 1 {
|
||||||
|
t.Fatalf("expected 1 session, got %d", len(sessions))
|
||||||
|
}
|
||||||
|
if sessions[0].ID != sessionID {
|
||||||
|
t.Errorf("session ID = %q, want %q", sessions[0].ID, sessionID)
|
||||||
|
}
|
||||||
|
if sessions[0].ConnID != 42 {
|
||||||
|
t.Errorf("ConnID = %d, want 42", sessions[0].ConnID)
|
||||||
|
}
|
||||||
|
if sessions[0].Config.Hostname != "test-host" {
|
||||||
|
t.Errorf("Hostname = %q, want %q", sessions[0].Config.Hostname, "test-host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectDefaults(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
|
||||||
|
// Connect with zero values — should get defaults
|
||||||
|
config := RDPConfig{Hostname: "host"}
|
||||||
|
sessionID, err := svc.Connect(config, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Connect error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := svc.GetSessionInfo(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSessionInfo error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Config.Port != 3389 {
|
||||||
|
t.Errorf("default Port = %d, want 3389", info.Config.Port)
|
||||||
|
}
|
||||||
|
if info.Config.Width != 1920 {
|
||||||
|
t.Errorf("default Width = %d, want 1920", info.Config.Width)
|
||||||
|
}
|
||||||
|
if info.Config.Height != 1080 {
|
||||||
|
t.Errorf("default Height = %d, want 1080", info.Config.Height)
|
||||||
|
}
|
||||||
|
if info.Config.ColorDepth != 32 {
|
||||||
|
t.Errorf("default ColorDepth = %d, want 32", info.Config.ColorDepth)
|
||||||
|
}
|
||||||
|
if info.Config.Security != "nla" {
|
||||||
|
t.Errorf("default Security = %q, want %q", info.Config.Security, "nla")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisconnect(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
|
||||||
|
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
|
||||||
|
sessionID, err := svc.Connect(config, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Connect error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.Disconnect(sessionID); err != nil {
|
||||||
|
t.Fatalf("Disconnect error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(svc.ListSessions()) != 0 {
|
||||||
|
t.Error("expected 0 sessions after disconnect")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify double-disconnect returns error
|
||||||
|
if err := svc.Disconnect(sessionID); err == nil {
|
||||||
|
t.Error("expected error on double disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisconnectNotFound(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
if err := svc.Disconnect("nonexistent"); err == nil {
|
||||||
|
t.Error("expected error for nonexistent session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionTracking(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
|
||||||
|
config := RDPConfig{Hostname: "host", Width: 640, Height: 480}
|
||||||
|
|
||||||
|
id1, _ := svc.Connect(config, 1)
|
||||||
|
id2, _ := svc.Connect(config, 2)
|
||||||
|
id3, _ := svc.Connect(config, 3)
|
||||||
|
|
||||||
|
if len(svc.ListSessions()) != 3 {
|
||||||
|
t.Fatalf("expected 3 sessions, got %d", len(svc.ListSessions()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect middle session
|
||||||
|
if err := svc.Disconnect(id2); err != nil {
|
||||||
|
t.Fatalf("Disconnect error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions := svc.ListSessions()
|
||||||
|
if len(sessions) != 2 {
|
||||||
|
t.Fatalf("expected 2 sessions, got %d", len(sessions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify remaining sessions
|
||||||
|
ids := make(map[string]bool)
|
||||||
|
for _, s := range sessions {
|
||||||
|
ids[s.ID] = true
|
||||||
|
}
|
||||||
|
if !ids[id1] || !ids[id3] {
|
||||||
|
t.Error("remaining sessions should be id1 and id3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMouse(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
|
||||||
|
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
|
||||||
|
sessionID, _ := svc.Connect(config, 1)
|
||||||
|
|
||||||
|
if err := svc.SendMouse(sessionID, 100, 200, MouseFlagMove); err != nil {
|
||||||
|
t.Errorf("SendMouse error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.SendMouse(sessionID, 100, 200, MouseFlagButton1|MouseFlagDown); err != nil {
|
||||||
|
t.Errorf("SendMouse click error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMouseNotFound(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
if err := svc.SendMouse("nonexistent", 0, 0, 0); err == nil {
|
||||||
|
t.Error("expected error for nonexistent session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendKey(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
|
||||||
|
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
|
||||||
|
sessionID, _ := svc.Connect(config, 1)
|
||||||
|
|
||||||
|
// Send key down
|
||||||
|
sc, _ := JSKeyToScancode("KeyA")
|
||||||
|
if err := svc.SendKey(sessionID, sc, true); err != nil {
|
||||||
|
t.Errorf("SendKey down error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send key up
|
||||||
|
if err := svc.SendKey(sessionID, sc, false); err != nil {
|
||||||
|
t.Errorf("SendKey up error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendKeyNotFound(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
if err := svc.SendKey("nonexistent", 0x001E, true); err == nil {
|
||||||
|
t.Error("expected error for nonexistent session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendClipboard(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
|
||||||
|
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
|
||||||
|
sessionID, _ := svc.Connect(config, 1)
|
||||||
|
|
||||||
|
if err := svc.SendClipboard(sessionID, "hello clipboard"); err != nil {
|
||||||
|
t.Errorf("SendClipboard error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFrame(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
|
||||||
|
config := RDPConfig{Hostname: "host", Width: 100, Height: 100}
|
||||||
|
sessionID, _ := svc.Connect(config, 1)
|
||||||
|
|
||||||
|
frame, err := svc.GetFrame(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFrame error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedLen := 100 * 100 * 4
|
||||||
|
if len(frame) != expectedLen {
|
||||||
|
t.Errorf("frame length = %d, want %d", len(frame), expectedLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the frame is not all zeros (mock generates colored content)
|
||||||
|
allZero := true
|
||||||
|
for _, b := range frame {
|
||||||
|
if b != 0 {
|
||||||
|
allZero = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allZero {
|
||||||
|
t.Error("mock frame should not be all zeros")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFrameNotFound(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
_, err := svc.GetFrame("nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsConnected(t *testing.T) {
|
||||||
|
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||||
|
|
||||||
|
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
|
||||||
|
sessionID, _ := svc.Connect(config, 1)
|
||||||
|
|
||||||
|
if !svc.IsConnected(sessionID) {
|
||||||
|
t.Error("session should be connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.Disconnect(sessionID)
|
||||||
|
|
||||||
|
if svc.IsConnected(sessionID) {
|
||||||
|
t.Error("session should not be connected after disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user