diff --git a/internal/rdp/input.go b/internal/rdp/input.go new file mode 100644 index 0000000..76fb270 --- /dev/null +++ b/internal/rdp/input.go @@ -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) +} diff --git a/internal/rdp/input_test.go b/internal/rdp/input_test.go new file mode 100644 index 0000000..1340f7f --- /dev/null +++ b/internal/rdp/input_test.go @@ -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) + } +} diff --git a/internal/rdp/pixelbuffer.go b/internal/rdp/pixelbuffer.go new file mode 100644 index 0000000..f2e935d --- /dev/null +++ b/internal/rdp/pixelbuffer.go @@ -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 +} diff --git a/internal/rdp/pixelbuffer_test.go b/internal/rdp/pixelbuffer_test.go new file mode 100644 index 0000000..97dcb34 --- /dev/null +++ b/internal/rdp/pixelbuffer_test.go @@ -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 + } + } +} diff --git a/internal/rdp/types.go b/internal/rdp/types.go new file mode 100644 index 0000000..4c774a4 --- /dev/null +++ b/internal/rdp/types.go @@ -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 +}