feat: RDP types, pixel buffer, and scancode mapping
Define the RDPBackend interface, RDPConfig, and FrameUpdate types that abstract FreeRDP behind a pluggable backend. Add PixelBuffer for shared RGBA frame management with partial-update support and dirty tracking. Implement full 104-key US keyboard scancode map (JS KeyboardEvent.code to RDP hardware scancodes) with extended-key detection helpers and mouse event flag constants matching MS-RDPBCGR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ae50bef795
commit
14b780c914
189
internal/rdp/input.go
Normal file
189
internal/rdp/input.go
Normal file
@ -0,0 +1,189 @@
|
||||
package rdp
|
||||
|
||||
// RDP mouse event flags — these match the MS-RDPBCGR specification.
|
||||
const (
|
||||
MouseFlagMove uint32 = 0x0800 // Mouse moved (no button change)
|
||||
MouseFlagButton1 uint32 = 0x1000 // Left button
|
||||
MouseFlagButton2 uint32 = 0x2000 // Right button
|
||||
MouseFlagButton3 uint32 = 0x4000 // Middle button
|
||||
MouseFlagDown uint32 = 0x8000 // Button pressed (absence = released)
|
||||
|
||||
// Extended mouse flags for wheel events
|
||||
MouseFlagWheel uint32 = 0x0200 // Vertical wheel rotation
|
||||
MouseFlagWheelNeg uint32 = 0x0100 // Negative wheel direction (scroll down)
|
||||
MouseFlagHWheel uint32 = 0x0400 // Horizontal wheel rotation
|
||||
)
|
||||
|
||||
// ScancodeMap maps JavaScript KeyboardEvent.code strings to RDP hardware
|
||||
// scancodes (Set 1 / XT scan codes). This covers the standard US 104-key
|
||||
// layout. Extended keys (those with a 0xE0 prefix on the wire) have the
|
||||
// high byte set to 0xE0.
|
||||
//
|
||||
// Reference: USB HID to PS/2 scancode mapping + MS-RDPBCGR 2.2.8.1.1.3.1.1.1
|
||||
var ScancodeMap = map[string]uint32{
|
||||
// ── Row 0: Escape + Function keys ──────────────────────────────
|
||||
"Escape": 0x0001,
|
||||
"F1": 0x003B,
|
||||
"F2": 0x003C,
|
||||
"F3": 0x003D,
|
||||
"F4": 0x003E,
|
||||
"F5": 0x003F,
|
||||
"F6": 0x0040,
|
||||
"F7": 0x0041,
|
||||
"F8": 0x0042,
|
||||
"F9": 0x0043,
|
||||
"F10": 0x0044,
|
||||
"F11": 0x0057,
|
||||
"F12": 0x0058,
|
||||
|
||||
// ── Row 1: Number row ──────────────────────────────────────────
|
||||
"Backquote": 0x0029, // ` ~
|
||||
"Digit1": 0x0002,
|
||||
"Digit2": 0x0003,
|
||||
"Digit3": 0x0004,
|
||||
"Digit4": 0x0005,
|
||||
"Digit5": 0x0006,
|
||||
"Digit6": 0x0007,
|
||||
"Digit7": 0x0008,
|
||||
"Digit8": 0x0009,
|
||||
"Digit9": 0x000A,
|
||||
"Digit0": 0x000B,
|
||||
"Minus": 0x000C, // - _
|
||||
"Equal": 0x000D, // = +
|
||||
"Backspace": 0x000E,
|
||||
|
||||
// ── Row 2: QWERTY row ─────────────────────────────────────────
|
||||
"Tab": 0x000F,
|
||||
"KeyQ": 0x0010,
|
||||
"KeyW": 0x0011,
|
||||
"KeyE": 0x0012,
|
||||
"KeyR": 0x0013,
|
||||
"KeyT": 0x0014,
|
||||
"KeyY": 0x0015,
|
||||
"KeyU": 0x0016,
|
||||
"KeyI": 0x0017,
|
||||
"KeyO": 0x0018,
|
||||
"KeyP": 0x0019,
|
||||
"BracketLeft": 0x001A, // [ {
|
||||
"BracketRight":0x001B, // ] }
|
||||
"Backslash": 0x002B, // \ |
|
||||
|
||||
// ── Row 3: Home row ───────────────────────────────────────────
|
||||
"CapsLock": 0x003A,
|
||||
"KeyA": 0x001E,
|
||||
"KeyS": 0x001F,
|
||||
"KeyD": 0x0020,
|
||||
"KeyF": 0x0021,
|
||||
"KeyG": 0x0022,
|
||||
"KeyH": 0x0023,
|
||||
"KeyJ": 0x0024,
|
||||
"KeyK": 0x0025,
|
||||
"KeyL": 0x0026,
|
||||
"Semicolon": 0x0027, // ; :
|
||||
"Quote": 0x0028, // ' "
|
||||
"Enter": 0x001C,
|
||||
|
||||
// ── Row 4: Bottom row ─────────────────────────────────────────
|
||||
"ShiftLeft": 0x002A,
|
||||
"KeyZ": 0x002C,
|
||||
"KeyX": 0x002D,
|
||||
"KeyC": 0x002E,
|
||||
"KeyV": 0x002F,
|
||||
"KeyB": 0x0030,
|
||||
"KeyN": 0x0031,
|
||||
"KeyM": 0x0032,
|
||||
"Comma": 0x0033, // , <
|
||||
"Period": 0x0034, // . >
|
||||
"Slash": 0x0035, // / ?
|
||||
"ShiftRight": 0x0036,
|
||||
|
||||
// ── Row 5: Bottom modifiers + space ───────────────────────────
|
||||
"ControlLeft": 0x001D,
|
||||
"MetaLeft": 0xE05B, // Left Windows / Super
|
||||
"AltLeft": 0x0038,
|
||||
"Space": 0x0039,
|
||||
"AltRight": 0xE038, // Right Alt (extended)
|
||||
"MetaRight": 0xE05C, // Right Windows / Super
|
||||
"ContextMenu": 0xE05D, // Application / Menu key
|
||||
"ControlRight": 0xE01D, // Right Ctrl (extended)
|
||||
|
||||
// ── Navigation cluster ────────────────────────────────────────
|
||||
"PrintScreen": 0xE037,
|
||||
"ScrollLock": 0x0046,
|
||||
"Pause": 0x0045, // Note: Pause has special handling on wire
|
||||
"Insert": 0xE052,
|
||||
"Home": 0xE047,
|
||||
"PageUp": 0xE049,
|
||||
"Delete": 0xE053,
|
||||
"End": 0xE04F,
|
||||
"PageDown": 0xE051,
|
||||
|
||||
// ── Arrow keys ────────────────────────────────────────────────
|
||||
"ArrowUp": 0xE048,
|
||||
"ArrowLeft": 0xE04B,
|
||||
"ArrowDown": 0xE050,
|
||||
"ArrowRight": 0xE04D,
|
||||
|
||||
// ── Numpad ────────────────────────────────────────────────────
|
||||
"NumLock": 0x0045,
|
||||
"NumpadDivide": 0xE035,
|
||||
"NumpadMultiply":0x0037,
|
||||
"NumpadSubtract":0x004A,
|
||||
"Numpad7": 0x0047,
|
||||
"Numpad8": 0x0048,
|
||||
"Numpad9": 0x0049,
|
||||
"NumpadAdd": 0x004E,
|
||||
"Numpad4": 0x004B,
|
||||
"Numpad5": 0x004C,
|
||||
"Numpad6": 0x004D,
|
||||
"Numpad1": 0x004F,
|
||||
"Numpad2": 0x0050,
|
||||
"Numpad3": 0x0051,
|
||||
"NumpadEnter": 0xE01C,
|
||||
"Numpad0": 0x0052,
|
||||
"NumpadDecimal": 0x0053,
|
||||
|
||||
// ── Multimedia / browser keys (common on 104+ key layouts) ───
|
||||
"BrowserBack": 0xE06A,
|
||||
"BrowserForward": 0xE069,
|
||||
"BrowserRefresh": 0xE067,
|
||||
"BrowserStop": 0xE068,
|
||||
"BrowserSearch": 0xE065,
|
||||
"BrowserFavorites":0xE066,
|
||||
"BrowserHome": 0xE032,
|
||||
"VolumeMute": 0xE020,
|
||||
"VolumeDown": 0xE02E,
|
||||
"VolumeUp": 0xE030,
|
||||
"MediaTrackNext": 0xE019,
|
||||
"MediaTrackPrevious":0xE010,
|
||||
"MediaStop": 0xE024,
|
||||
"MediaPlayPause": 0xE022,
|
||||
"LaunchMail": 0xE06C,
|
||||
"LaunchApp1": 0xE06B,
|
||||
"LaunchApp2": 0xE021,
|
||||
|
||||
// ── International keys ────────────────────────────────────────
|
||||
"IntlBackslash": 0x0056, // key between left Shift and Z on ISO keyboards
|
||||
"IntlYen": 0x007D, // Yen key on Japanese keyboards
|
||||
"IntlRo": 0x0073, // Ro key on Japanese keyboards
|
||||
}
|
||||
|
||||
// JSKeyToScancode translates a JavaScript KeyboardEvent.code string to an
|
||||
// RDP hardware scancode. Returns the scancode and true if a mapping exists,
|
||||
// or 0 and false for unmapped keys.
|
||||
func JSKeyToScancode(jsCode string) (uint32, bool) {
|
||||
sc, ok := ScancodeMap[jsCode]
|
||||
return sc, ok
|
||||
}
|
||||
|
||||
// IsExtendedKey returns true if the scancode has the 0xE0 extended prefix.
|
||||
// Extended keys require a two-byte sequence on the RDP wire.
|
||||
func IsExtendedKey(scancode uint32) bool {
|
||||
return (scancode & 0xFF00) == 0xE000
|
||||
}
|
||||
|
||||
// ScancodeValue returns the low byte of the scancode (the actual scan code
|
||||
// value without the extended prefix).
|
||||
func ScancodeValue(scancode uint32) uint8 {
|
||||
return uint8(scancode & 0xFF)
|
||||
}
|
||||
227
internal/rdp/input_test.go
Normal file
227
internal/rdp/input_test.go
Normal file
@ -0,0 +1,227 @@
|
||||
package rdp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScancodeMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
jsCode string
|
||||
expected uint32
|
||||
}{
|
||||
{"Escape", 0x0001},
|
||||
{"Digit1", 0x0002},
|
||||
{"Digit0", 0x000B},
|
||||
{"KeyA", 0x001E},
|
||||
{"KeyZ", 0x002C},
|
||||
{"Enter", 0x001C},
|
||||
{"Space", 0x0039},
|
||||
{"Tab", 0x000F},
|
||||
{"Backspace", 0x000E},
|
||||
{"ShiftLeft", 0x002A},
|
||||
{"ShiftRight", 0x0036},
|
||||
{"ControlLeft", 0x001D},
|
||||
{"ControlRight", 0xE01D},
|
||||
{"AltLeft", 0x0038},
|
||||
{"AltRight", 0xE038},
|
||||
{"CapsLock", 0x003A},
|
||||
{"F1", 0x003B},
|
||||
{"F12", 0x0058},
|
||||
{"ArrowUp", 0xE048},
|
||||
{"ArrowDown", 0xE050},
|
||||
{"ArrowLeft", 0xE04B},
|
||||
{"ArrowRight", 0xE04D},
|
||||
{"Insert", 0xE052},
|
||||
{"Delete", 0xE053},
|
||||
{"Home", 0xE047},
|
||||
{"End", 0xE04F},
|
||||
{"PageUp", 0xE049},
|
||||
{"PageDown", 0xE051},
|
||||
{"NumLock", 0x0045},
|
||||
{"Numpad0", 0x0052},
|
||||
{"Numpad9", 0x0049},
|
||||
{"NumpadEnter", 0xE01C},
|
||||
{"NumpadAdd", 0x004E},
|
||||
{"NumpadSubtract", 0x004A},
|
||||
{"NumpadMultiply", 0x0037},
|
||||
{"NumpadDivide", 0xE035},
|
||||
{"NumpadDecimal", 0x0053},
|
||||
{"MetaLeft", 0xE05B},
|
||||
{"MetaRight", 0xE05C},
|
||||
{"ContextMenu", 0xE05D},
|
||||
{"PrintScreen", 0xE037},
|
||||
{"ScrollLock", 0x0046},
|
||||
{"Backquote", 0x0029},
|
||||
{"Minus", 0x000C},
|
||||
{"Equal", 0x000D},
|
||||
{"BracketLeft", 0x001A},
|
||||
{"BracketRight", 0x001B},
|
||||
{"Backslash", 0x002B},
|
||||
{"Semicolon", 0x0027},
|
||||
{"Quote", 0x0028},
|
||||
{"Comma", 0x0033},
|
||||
{"Period", 0x0034},
|
||||
{"Slash", 0x0035},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.jsCode, func(t *testing.T) {
|
||||
sc, ok := JSKeyToScancode(tt.jsCode)
|
||||
if !ok {
|
||||
t.Fatalf("JSKeyToScancode(%q) returned false", tt.jsCode)
|
||||
}
|
||||
if sc != tt.expected {
|
||||
t.Errorf("JSKeyToScancode(%q) = 0x%04X, want 0x%04X", tt.jsCode, sc, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllLetterKeys(t *testing.T) {
|
||||
// Verify all 26 letter keys are mapped
|
||||
letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
for _, ch := range letters {
|
||||
code := "Key" + string(ch)
|
||||
_, ok := JSKeyToScancode(code)
|
||||
if !ok {
|
||||
t.Errorf("missing mapping for %s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllDigitKeys(t *testing.T) {
|
||||
for i := 0; i <= 9; i++ {
|
||||
code := "Digit" + string(rune('0'+i))
|
||||
_, ok := JSKeyToScancode(code)
|
||||
if !ok {
|
||||
t.Errorf("missing mapping for %s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllFunctionKeys(t *testing.T) {
|
||||
fKeys := []string{"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"}
|
||||
for _, key := range fKeys {
|
||||
_, ok := JSKeyToScancode(key)
|
||||
if !ok {
|
||||
t.Errorf("missing mapping for %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllNumpadKeys(t *testing.T) {
|
||||
numpadKeys := []string{
|
||||
"Numpad0", "Numpad1", "Numpad2", "Numpad3", "Numpad4",
|
||||
"Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9",
|
||||
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide",
|
||||
"NumpadDecimal", "NumpadEnter", "NumLock",
|
||||
}
|
||||
for _, key := range numpadKeys {
|
||||
_, ok := JSKeyToScancode(key)
|
||||
if !ok {
|
||||
t.Errorf("missing mapping for %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownKey(t *testing.T) {
|
||||
sc, ok := JSKeyToScancode("FakeKey123")
|
||||
if ok {
|
||||
t.Errorf("unknown key returned ok=true with scancode 0x%04X", sc)
|
||||
}
|
||||
if sc != 0 {
|
||||
t.Errorf("unknown key scancode = 0x%04X, want 0", sc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExtendedKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scancode uint32
|
||||
extended bool
|
||||
}{
|
||||
{"Escape (not extended)", 0x0001, false},
|
||||
{"Enter (not extended)", 0x001C, false},
|
||||
{"Right Ctrl (extended)", 0xE01D, true},
|
||||
{"Right Alt (extended)", 0xE038, true},
|
||||
{"ArrowUp (extended)", 0xE048, true},
|
||||
{"Insert (extended)", 0xE052, true},
|
||||
{"NumpadEnter (extended)", 0xE01C, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsExtendedKey(tt.scancode)
|
||||
if got != tt.extended {
|
||||
t.Errorf("IsExtendedKey(0x%04X) = %v, want %v", tt.scancode, got, tt.extended)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScancodeValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scancode uint32
|
||||
value uint8
|
||||
}{
|
||||
{"Escape", 0x0001, 0x01},
|
||||
{"Right Ctrl", 0xE01D, 0x1D},
|
||||
{"ArrowUp", 0xE048, 0x48},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScancodeValue(tt.scancode)
|
||||
if got != tt.value {
|
||||
t.Errorf("ScancodeValue(0x%04X) = 0x%02X, want 0x%02X", tt.scancode, got, tt.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMouseFlags(t *testing.T) {
|
||||
// Verify the flag constants have the right values
|
||||
if MouseFlagMove != 0x0800 {
|
||||
t.Errorf("MouseFlagMove = 0x%04X, want 0x0800", MouseFlagMove)
|
||||
}
|
||||
if MouseFlagButton1 != 0x1000 {
|
||||
t.Errorf("MouseFlagButton1 = 0x%04X, want 0x1000", MouseFlagButton1)
|
||||
}
|
||||
if MouseFlagButton2 != 0x2000 {
|
||||
t.Errorf("MouseFlagButton2 = 0x%04X, want 0x2000", MouseFlagButton2)
|
||||
}
|
||||
if MouseFlagButton3 != 0x4000 {
|
||||
t.Errorf("MouseFlagButton3 = 0x%04X, want 0x4000", MouseFlagButton3)
|
||||
}
|
||||
if MouseFlagDown != 0x8000 {
|
||||
t.Errorf("MouseFlagDown = 0x%04X, want 0x8000", MouseFlagDown)
|
||||
}
|
||||
|
||||
// Test combining flags — left click down
|
||||
flags := MouseFlagButton1 | MouseFlagDown
|
||||
if flags != 0x9000 {
|
||||
t.Errorf("left click down = 0x%04X, want 0x9000", flags)
|
||||
}
|
||||
|
||||
// Test combining flags — right click up (no Down flag)
|
||||
flags = MouseFlagButton2
|
||||
if flags != 0x2000 {
|
||||
t.Errorf("right click up = 0x%04X, want 0x2000", flags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScancodeMapCompleteness(t *testing.T) {
|
||||
// Minimum expected mappings for a standard 104-key US keyboard:
|
||||
// 26 letters + 10 digits + 12 F-keys + escape + tab + caps + 2 shifts +
|
||||
// 2 ctrls + 2 alts + 2 metas + space + enter + backspace +
|
||||
// numrow punctuation (backquote, minus, equal) +
|
||||
// bracket pair + backslash + semicolon + quote + comma + period + slash +
|
||||
// 4 arrows + insert + delete + home + end + pageup + pagedown +
|
||||
// printscreen + scrolllock + pause +
|
||||
// numlock + numpad 0-9 + numpad operators (5) + numpad enter + numpad decimal +
|
||||
// context menu
|
||||
// = 26+10+12+1+1+1+2+2+2+2+1+1+1+3+2+1+1+1+1+1+1+4+6+3+1+10+5+1+1+1 = ~104
|
||||
minExpected := 90 // conservative lower bound for core keys
|
||||
if len(ScancodeMap) < minExpected {
|
||||
t.Errorf("ScancodeMap has %d entries, expected at least %d", len(ScancodeMap), minExpected)
|
||||
}
|
||||
}
|
||||
103
internal/rdp/pixelbuffer.go
Normal file
103
internal/rdp/pixelbuffer.go
Normal file
@ -0,0 +1,103 @@
|
||||
package rdp
|
||||
|
||||
import "sync"
|
||||
|
||||
// PixelBuffer holds a shared RGBA pixel buffer that is updated by the RDP
|
||||
// backend (via partial region updates) and read by the frame-serving path.
|
||||
type PixelBuffer struct {
|
||||
Width int
|
||||
Height int
|
||||
Data []byte // RGBA pixel data, len = Width * Height * 4
|
||||
mu sync.RWMutex
|
||||
dirty bool
|
||||
}
|
||||
|
||||
// NewPixelBuffer allocates a buffer for the given resolution.
|
||||
// The buffer is initialized to all zeros (transparent black).
|
||||
func NewPixelBuffer(width, height int) *PixelBuffer {
|
||||
return &PixelBuffer{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Data: make([]byte, width*height*4),
|
||||
}
|
||||
}
|
||||
|
||||
// Update applies a partial region update to the pixel buffer.
|
||||
// The data slice must contain w*h*4 bytes of RGBA pixel data.
|
||||
// Pixels outside the buffer bounds are silently clipped.
|
||||
func (p *PixelBuffer) Update(x, y, w, h int, data []byte) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
for row := 0; row < h; row++ {
|
||||
destY := y + row
|
||||
if destY < 0 || destY >= p.Height {
|
||||
continue
|
||||
}
|
||||
|
||||
srcOffset := row * w * 4
|
||||
destOffset := (destY*p.Width + x) * 4
|
||||
|
||||
// Calculate how many pixels we can copy on this row
|
||||
copyWidth := w
|
||||
if x < 0 {
|
||||
srcOffset += (-x) * 4
|
||||
copyWidth += x // reduce by the clipped amount
|
||||
destOffset = destY * p.Width * 4
|
||||
}
|
||||
if x+copyWidth > p.Width {
|
||||
copyWidth = p.Width - x
|
||||
if x < 0 {
|
||||
copyWidth = p.Width
|
||||
}
|
||||
}
|
||||
if copyWidth <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
srcEnd := srcOffset + copyWidth*4
|
||||
if srcEnd > len(data) {
|
||||
srcEnd = len(data)
|
||||
}
|
||||
if srcOffset >= len(data) {
|
||||
continue
|
||||
}
|
||||
|
||||
destEnd := destOffset + copyWidth*4
|
||||
if destEnd > len(p.Data) {
|
||||
destEnd = len(p.Data)
|
||||
}
|
||||
|
||||
copy(p.Data[destOffset:destEnd], data[srcOffset:srcEnd])
|
||||
}
|
||||
|
||||
p.dirty = true
|
||||
}
|
||||
|
||||
// GetFrame returns a copy of the full pixel buffer.
|
||||
// The caller receives an independent copy that will not be affected
|
||||
// by subsequent updates.
|
||||
func (p *PixelBuffer) GetFrame() []byte {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
frame := make([]byte, len(p.Data))
|
||||
copy(frame, p.Data)
|
||||
return frame
|
||||
}
|
||||
|
||||
// IsDirty reports whether the buffer has been updated since the last
|
||||
// ClearDirty call.
|
||||
func (p *PixelBuffer) IsDirty() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.dirty
|
||||
}
|
||||
|
||||
// ClearDirty resets the dirty flag. Typically called after a frame has
|
||||
// been sent to the frontend.
|
||||
func (p *PixelBuffer) ClearDirty() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.dirty = false
|
||||
}
|
||||
172
internal/rdp/pixelbuffer_test.go
Normal file
172
internal/rdp/pixelbuffer_test.go
Normal file
@ -0,0 +1,172 @@
|
||||
package rdp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewPixelBuffer(t *testing.T) {
|
||||
pb := NewPixelBuffer(100, 50)
|
||||
|
||||
if pb.Width != 100 {
|
||||
t.Errorf("Width = %d, want 100", pb.Width)
|
||||
}
|
||||
if pb.Height != 50 {
|
||||
t.Errorf("Height = %d, want 50", pb.Height)
|
||||
}
|
||||
|
||||
expectedLen := 100 * 50 * 4
|
||||
if len(pb.Data) != expectedLen {
|
||||
t.Errorf("Data length = %d, want %d", len(pb.Data), expectedLen)
|
||||
}
|
||||
|
||||
// All pixels should be initialized to zero
|
||||
for i, b := range pb.Data {
|
||||
if b != 0 {
|
||||
t.Errorf("Data[%d] = %d, want 0", i, b)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPixelBufferSmall(t *testing.T) {
|
||||
pb := NewPixelBuffer(1, 1)
|
||||
if len(pb.Data) != 4 {
|
||||
t.Errorf("1x1 buffer Data length = %d, want 4", len(pb.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRegion(t *testing.T) {
|
||||
pb := NewPixelBuffer(10, 10) // 10x10 pixels
|
||||
|
||||
// Create a 3x2 red patch
|
||||
patch := make([]byte, 3*2*4) // 3 wide, 2 tall
|
||||
for i := 0; i < 3*2; i++ {
|
||||
patch[i*4+0] = 255 // R
|
||||
patch[i*4+1] = 0 // G
|
||||
patch[i*4+2] = 0 // B
|
||||
patch[i*4+3] = 255 // A
|
||||
}
|
||||
|
||||
// Apply at position (2, 3)
|
||||
pb.Update(2, 3, 3, 2, patch)
|
||||
|
||||
// Check that pixel (2,3) is red
|
||||
offset := (3*10 + 2) * 4
|
||||
if pb.Data[offset+0] != 255 || pb.Data[offset+1] != 0 || pb.Data[offset+2] != 0 || pb.Data[offset+3] != 255 {
|
||||
t.Errorf("pixel (2,3) = [%d,%d,%d,%d], want [255,0,0,255]",
|
||||
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
|
||||
}
|
||||
|
||||
// Check that pixel (4,4) is red (last pixel of patch)
|
||||
offset = (4*10 + 4) * 4
|
||||
if pb.Data[offset+0] != 255 || pb.Data[offset+1] != 0 || pb.Data[offset+2] != 0 || pb.Data[offset+3] != 255 {
|
||||
t.Errorf("pixel (4,4) = [%d,%d,%d,%d], want [255,0,0,255]",
|
||||
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
|
||||
}
|
||||
|
||||
// Check that pixel (1,3) is still black (just outside patch)
|
||||
offset = (3*10 + 1) * 4
|
||||
if pb.Data[offset+0] != 0 || pb.Data[offset+3] != 0 {
|
||||
t.Errorf("pixel (1,3) should be untouched, got [%d,%d,%d,%d]",
|
||||
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
|
||||
}
|
||||
|
||||
// Check that pixel (5,3) is still black (just outside patch)
|
||||
offset = (3*10 + 5) * 4
|
||||
if pb.Data[offset+0] != 0 || pb.Data[offset+3] != 0 {
|
||||
t.Errorf("pixel (5,3) should be untouched, got [%d,%d,%d,%d]",
|
||||
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRegionClipping(t *testing.T) {
|
||||
pb := NewPixelBuffer(10, 10)
|
||||
|
||||
// Create a 5x5 green patch and place it at (8, 8), so it overflows
|
||||
patch := make([]byte, 5*5*4)
|
||||
for i := 0; i < 5*5; i++ {
|
||||
patch[i*4+0] = 0
|
||||
patch[i*4+1] = 255
|
||||
patch[i*4+2] = 0
|
||||
patch[i*4+3] = 255
|
||||
}
|
||||
|
||||
// Should not panic — overflowing regions are clipped
|
||||
pb.Update(8, 8, 5, 5, patch)
|
||||
|
||||
// Pixel (8,8) should be green
|
||||
offset := (8*10 + 8) * 4
|
||||
if pb.Data[offset+1] != 255 {
|
||||
t.Errorf("pixel (8,8) G = %d, want 255", pb.Data[offset+1])
|
||||
}
|
||||
|
||||
// Pixel (9,9) should also be green (last valid pixel)
|
||||
offset = (9*10 + 9) * 4
|
||||
if pb.Data[offset+1] != 255 {
|
||||
t.Errorf("pixel (9,9) G = %d, want 255", pb.Data[offset+1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirtyFlag(t *testing.T) {
|
||||
pb := NewPixelBuffer(10, 10)
|
||||
|
||||
if pb.IsDirty() {
|
||||
t.Error("new buffer should not be dirty")
|
||||
}
|
||||
|
||||
// Update a region
|
||||
patch := make([]byte, 4) // 1x1 pixel
|
||||
patch[0] = 255
|
||||
patch[3] = 255
|
||||
pb.Update(0, 0, 1, 1, patch)
|
||||
|
||||
if !pb.IsDirty() {
|
||||
t.Error("buffer should be dirty after update")
|
||||
}
|
||||
|
||||
pb.ClearDirty()
|
||||
|
||||
if pb.IsDirty() {
|
||||
t.Error("buffer should not be dirty after ClearDirty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFrameReturnsCopy(t *testing.T) {
|
||||
pb := NewPixelBuffer(2, 2)
|
||||
|
||||
// Set first pixel to white
|
||||
patch := []byte{255, 255, 255, 255}
|
||||
pb.Update(0, 0, 1, 1, patch)
|
||||
|
||||
frame1 := pb.GetFrame()
|
||||
|
||||
// Modify the returned frame
|
||||
frame1[0] = 0
|
||||
|
||||
// Get another frame — it should still have the original value
|
||||
frame2 := pb.GetFrame()
|
||||
if frame2[0] != 255 {
|
||||
t.Errorf("GetFrame did not return an independent copy: got %d, want 255", frame2[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullFrameUpdate(t *testing.T) {
|
||||
pb := NewPixelBuffer(4, 4)
|
||||
|
||||
// Create a full-frame update with all blue pixels
|
||||
fullFrame := make([]byte, 4*4*4)
|
||||
for i := 0; i < 4*4; i++ {
|
||||
fullFrame[i*4+0] = 0
|
||||
fullFrame[i*4+1] = 0
|
||||
fullFrame[i*4+2] = 255
|
||||
fullFrame[i*4+3] = 255
|
||||
}
|
||||
|
||||
pb.Update(0, 0, 4, 4, fullFrame)
|
||||
|
||||
frame := pb.GetFrame()
|
||||
for i := 0; i < 4*4; i++ {
|
||||
if frame[i*4+2] != 255 {
|
||||
t.Errorf("pixel %d blue channel = %d, want 255", i, frame[i*4+2])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
52
internal/rdp/types.go
Normal file
52
internal/rdp/types.go
Normal file
@ -0,0 +1,52 @@
|
||||
package rdp
|
||||
|
||||
// RDPConfig holds the parameters needed to establish an RDP connection.
|
||||
type RDPConfig struct {
|
||||
Hostname string // Remote host address
|
||||
Port int // RDP port (default 3389)
|
||||
Username string
|
||||
Password string
|
||||
Domain string // Windows domain (optional)
|
||||
Width int // Desktop width in pixels
|
||||
Height int // Desktop height in pixels
|
||||
ColorDepth int // 16, 24, or 32
|
||||
Security string // "nla", "tls", or "rdp"
|
||||
}
|
||||
|
||||
// FrameUpdate represents a partial screen update from the RDP server.
|
||||
// The data is in RGBA format (4 bytes per pixel).
|
||||
type FrameUpdate struct {
|
||||
X int // top-left X of the updated region
|
||||
Y int // top-left Y of the updated region
|
||||
Width int // width of the updated region in pixels
|
||||
Height int // height of the updated region in pixels
|
||||
Data []byte // RGBA pixel data, len = Width * Height * 4
|
||||
}
|
||||
|
||||
// RDPBackend abstracts the FreeRDP implementation so that a mock can be
|
||||
// used during development on platforms where FreeRDP is not available.
|
||||
type RDPBackend interface {
|
||||
// Connect establishes an RDP session with the given configuration.
|
||||
Connect(config RDPConfig) error
|
||||
|
||||
// Disconnect tears down the RDP session.
|
||||
Disconnect() error
|
||||
|
||||
// SendMouseEvent sends a mouse event with the given position and flags.
|
||||
// Flags use the RDP mouse event flag constants (MouseFlag*).
|
||||
SendMouseEvent(x, y int, flags uint32) error
|
||||
|
||||
// SendKeyEvent sends a keyboard event for the given RDP scancode.
|
||||
// pressed=true for key down, false for key up.
|
||||
SendKeyEvent(scancode uint32, pressed bool) error
|
||||
|
||||
// SendClipboard sends clipboard text to the remote session.
|
||||
SendClipboard(data string) error
|
||||
|
||||
// GetFrame returns the current full-frame RGBA pixel buffer.
|
||||
// The returned slice length is Width * Height * 4.
|
||||
GetFrame() ([]byte, error)
|
||||
|
||||
// IsConnected reports whether the backend has an active connection.
|
||||
IsConnected() bool
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user