diff --git a/internal/rdp/mock_backend.go b/internal/rdp/mock_backend.go new file mode 100644 index 0000000..ed37f1c --- /dev/null +++ b/internal/rdp/mock_backend.go @@ -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 +} diff --git a/internal/rdp/service.go b/internal/rdp/service.go new file mode 100644 index 0000000..2ab1d7b --- /dev/null +++ b/internal/rdp/service.go @@ -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 +} diff --git a/internal/rdp/service_test.go b/internal/rdp/service_test.go new file mode 100644 index 0000000..343c49e --- /dev/null +++ b/internal/rdp/service_test.go @@ -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") + } +}