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>
314 lines
7.0 KiB
Go
314 lines
7.0 KiB
Go
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
|
||
}
|