wraith/internal/rdp/service.go
Vantz Stockwell 6c65f171f8 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>
2026-03-17 07:17:26 -04:00

173 lines
4.5 KiB
Go

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
}