Files
rdplib/grdp.go
T

2070 lines
68 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package grdp
import (
"errors"
"fmt"
"image"
"log/slog"
"net"
"os"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
"git.zeroonesoft.cn/golib/rdplib/plugin"
"git.zeroonesoft.cn/golib/rdplib/plugin/cliprdr"
"git.zeroonesoft.cn/golib/rdplib/plugin/rdpdr"
"git.zeroonesoft.cn/golib/rdplib/plugin/drdynvc"
"git.zeroonesoft.cn/golib/rdplib/plugin/rdpedisp"
"git.zeroonesoft.cn/golib/rdplib/plugin/rdpgfx"
"git.zeroonesoft.cn/golib/rdplib/plugin/rdpsnd"
"git.zeroonesoft.cn/golib/rdplib/core"
"git.zeroonesoft.cn/golib/rdplib/protocol/nla"
"git.zeroonesoft.cn/golib/rdplib/protocol/pdu"
"git.zeroonesoft.cn/golib/rdplib/protocol/sec"
"git.zeroonesoft.cn/golib/rdplib/protocol/t125"
"git.zeroonesoft.cn/golib/rdplib/protocol/t125/gcc"
"git.zeroonesoft.cn/golib/rdplib/protocol/tpkt"
"git.zeroonesoft.cn/golib/rdplib/protocol/x224"
)
// mouseCoalescer holds all state for mouse-move coalescing.
// High-frequency move events are collapsed into at most one network PDU per
// mouseCoalesceInterval, with the latest position always winning.
type mouseCoalescer struct {
mu sync.Mutex
pending bool
x, y int
timer *time.Timer
lastTx time.Time
pdu pdu.PointerEvent
pduBuf [1]pdu.InputEventsInterface
}
// wheelCoalescer holds all state for wheel-scroll coalescing (vertical and
// horizontal). Rapid scroll events are accumulated over mouseCoalesceInterval
// and sent as a single PDU whose rotation value is the sum of all deltas in
// that window. accum/haccum are stored in RDP WHEEL_DELTA units (120 per
// physical notch); haccum is the horizontal axis (positive = scroll right).
type wheelCoalescer struct {
mu sync.Mutex
accum float64
haccum float64
timer *time.Timer
lastTx time.Time
pdu pdu.PointerEvent
pduBuf [1]pdu.InputEventsInterface
}
// stubChannel is a no-op virtual channel handler for channels the server
// expects to be present (e.g. rdpdr, cliprdr) but that we don't process.
type stubChannel struct {
name string
option uint32
sender core.ChannelSender
}
func (s *stubChannel) GetType() (string, uint32) { return s.name, s.option }
func (s *stubChannel) Sender(f core.ChannelSender) { s.sender = f }
func (s *stubChannel) Process(data []byte) {}
type RdpClient struct {
hostPort string // ip:port
width int
height int
kbdLayout uint32
keyboardType uint32
keyboardSubType uint32
timezoneName string
timezoneBias int
tpkt *tpkt.TPKT
x224 *x224.X224
mcs *t125.MCSClient
sec *sec.Client
pdu *pdu.Client
channels *plugin.Channels
eventReady atomic.Bool
decompressPool sync.Pool // pools []uint8 buffers for bitmap decompression
flipLinePool sync.Pool // pools line-sized []uint8 buffers for bitmap vertical flip
bmpEmitLogged int // 临时诊断:前 10 次位图事件计数
closed atomic.Bool
// credentials stored for reconnection
domain string
user string
password string
// stored callbacks for re-registration on reconnect
onErrorFn func(e error)
onCloseFn func()
onSuccessFn func()
onReadyFn func()
onBitmapPaintFn func([]Bitmap)
onPointerHideFn func()
onPointerCachedFn func(uint16)
onPointerDefaultFn func()
onPointerUpdateFn func(uint16, uint16, uint16, uint16, uint16, uint16, []byte, []byte)
onAudioFn func(rdpsnd.AudioFormat, []byte)
onAudioResetFn func()
onH264RawFn func(destX, destY, w, h int, isKey bool, data []byte, regions []int32)
onH264I420Fn func(destX, destY, w, h int, y []byte, yStride int, u []byte, uStride int, v []byte, vStride int)
onH264NV12Fn func(destX, destY, w, h int, y []byte, yStride int, uv []byte, uvStride int)
onDecoderBrokenFn func()
// clipboard callbacks and handler
onClipboardFn func(text string) // remote → local
getClipboardFn func() string // local → remote
onClipboardImageFn func(png []byte) // remote → local(PNG)
onClipboardHTMLFn func(string) // remote → local(HTML Format)
getClipboardHTMLFn func() string // local → remote(HTML Format,空串=无)
getClipboardImgFn func() []byte // local → remote(PNG,无图返回 nil)
// 文件剪贴板(CF_HDROP + FileContentsRequest/Response,MS-RDPECLIP §3.1.5.4)
onClipboardFilesFn func(names []string) // remote → local:远端剪贴板含文件
onClipboardFileDataFn func(index int, name string, data []byte) // 单文件下载完成/失败
onFileProgressFn func(index int, received, total int64) // 下载进度(每段一次)
// certVerifierFn:TLS 服务器证书 SHA-256 指纹校验(TOFU),nil = 不校验
certVerifierFn func(sha256Fp []byte) error
// 位图缓存(stage6 6.4b M1 会话内缓存):CacheBitmapV2 存入,
// MemBlt 按键引用回贴。键 = cacheId<<16 | cacheIndex。
bitmapCache map[uint32]*Bitmap
bitmapCacheFIFO []uint32
bmpCacheStores atomic.Uint64
bmpCacheHits atomic.Uint64
// gfxCacheStore:GFX/bitmap 两条管线共用的持久缓存桥(6.4/6.4b)。
gfxCacheStore rdpgfx.GfxCacheStore
// 连接参数(mstsc 基本选项):
// audioMode: "local"(默认,本机播放)| "none"(不播放)| "remote"(远端播放)
audioMode string
// colorDepth: 0/32 = 默认(WANT_32BPP_SESSION),16/24 生效于传统位图管线
colorDepth int
// perfFlags/perfFlagsSet: Client Info performanceFlags;未设置保持库默认
perfFlags uint32
perfFlagsSet bool
// rdpsndHandler 在 doLogin 内创建(audioMode != "remote" 时),
// 供同连接周期的 AUDIO_PLAYBACK DVC 适配器复用。
rdpsndHandler *rdpsnd.Handler
cliprdrHandler *cliprdr.CliprdrHandler
// 驱动器重定向(MS-RDPEFS):SetDriveRedirect(true) 后 doLogin 注册
// 完整 rdpdr 处理器(替换 stub),设备内容由异步 Filesystem 桥提供。
driveRedirect bool
driveLabel string
driveFS rdpdr.Filesystem // Login 前暂存,处理器创建时补挂
rdpdrHandler *rdpdr.Handler
// reconnectMu serialises concurrent Reconnect() calls.
// reconnecting is also set during async server redirects to suppress
// user-facing callbacks while the transport is being re-established.
reconnectMu sync.Mutex
reconnecting atomic.Bool
// mouse and wheel hold all coalescing state for pointer input.
mouse mouseCoalescer
wheel wheelCoalescer
// gfxHandler is the active RDPGFX handler; nil when not connected.
// Stored here so closeTransport() can stop its goroutines.
gfxHandler *rdpgfx.GfxHandler
// avc444Disabled, when true, limits CAPS_ADVERTISE to v8.1 so the server
// uses AVC420 only. Set via DisableAVC444() before Connect(); preserved
// across reconnects.
avc444Disabled bool
// gfxNoAVC, when true, advertises v10.x caps with AVC_DISABLED so the
// server keeps using the RDPGFX channel with ClearCodec/RFX Progressive
// instead of H.264. Set via NoAVC() before Connect().
gfxNoAVC bool
// pduRecorderFn backs SetPduRecorder: the wasm layer may arm recording
// before the client (and its GfxHandler) exists; the callback is attached
// at GfxHandler creation.
pduRecorderFn func(kind byte, codecId uint16, sw, sh, x, y, w, h uint32, payload []byte)
// rejectGFX, when true, rejects the RDPGFX dynvc channel entirely so the
// server falls back to legacy bitmap updates (compat mode).
rejectGFX bool
// dispHandler is the active MS-RDPEDISP handler; nil when not connected.
// Used by SetResolution to send MONITOR_LAYOUT PDUs.
dispHandler *rdpedisp.Handler
dialer func(hostPort string) (net.Conn, error)
}
const mouseCoalesceInterval = 16 * time.Millisecond
// Bitmap is a single rendered region delivered to the OnBitmap callback.
//
// Lifecycle: Bitmap.Data is borrowed from an internal buffer pool and is
// only valid for the duration of the synchronous OnBitmap callback. After
// the callback returns the slice may be returned to the pool and overwritten
// by subsequent updates. Callers that need to retain the pixels (e.g. to
// hand them to an asynchronous paint goroutine) MUST copy the bytes before
// the callback returns.
type Bitmap struct {
DestLeft int
DestTop int
DestRight int
DestBottom int
Width int
Height int
BitsPerPixel int
Data []byte
}
// FillRGBA converts the bitmap's pixel data to RGBA format, writing into dst.
// If dst is nil or has the wrong dimensions a new *image.RGBA is allocated.
// Callers that process tiles of stable dimensions can reuse the same *image.RGBA
// across frames to avoid repeated heap allocations:
//
// var tile *image.RGBA
// tile = bm.FillRGBA(tile)
func (bm *Bitmap) FillRGBA(dst *image.RGBA) *image.RGBA {
if dst == nil || dst.Bounds().Dx() != bm.Width || dst.Bounds().Dy() != bm.Height {
dst = image.NewRGBA(image.Rect(0, 0, bm.Width, bm.Height))
}
pix := dst.Pix
data := bm.Data
// Per-format specialised loops avoid a per-pixel switch and let the
// compiler hoist bounds checks and emit tight, branch-free inner code.
switch bm.BitsPerPixel {
case 1:
// 16-bit RGB555 stored big-endian in two bytes.
n := len(pix) >> 2
if len(data) < n*2 {
n = len(data) / 2
}
rgb555BatchToRGBA(pix, data, n)
case 2:
// 16-bit RGB565 stored big-endian in two bytes.
n := len(pix) >> 2
if len(data) < n*2 {
n = len(data) / 2
}
rgb565BatchToRGBA(pix, data, n)
default:
// 24/32-bit BGR(A) → RGBA with stride = bm.BitsPerPixel.
stride := bm.BitsPerPixel
n := len(pix) >> 2
if len(data) < n*stride {
n = len(data) / stride
}
if stride == 4 {
// BGRA32 is the common case; use the SIMD-accelerated path.
bgr32BatchToRGBA(pix, data, n)
} else {
// BGR24 (stride==3) and any other depth: scalar fallback.
// Write each pixel as a single 32-bit store to let the compiler
// vectorise the loop (avoids 4 separate byte stores per pixel).
for i := range n {
s := i * stride
*(*uint32)(unsafe.Pointer(&pix[i*4])) =
uint32(data[s+2]) | uint32(data[s+1])<<8 | uint32(data[s])<<16 | 0xFF000000
}
}
}
return dst
}
// RGBA converts the bitmap pixel data to an *image.RGBA.
// A new *image.RGBA is allocated on each call. If the caller processes tiles
// of the same dimensions across frames, prefer FillRGBA to avoid allocations.
func (bm *Bitmap) RGBA() *image.RGBA {
return bm.FillRGBA(nil)
}
// SwapRB swaps the red and blue byte of every 32-bit pixel in p in place and
// forces the alpha byte to 0xFF, converting between RGBA and BGRA byte order
// (the operation is its own inverse). It reuses the SIMD-accelerated batch
// converter (SSE2 on amd64, NEON on arm64), so callers that need BGRA pixels
// for an SDL_PIXELFORMAT_BGRA32 texture can convert an *image.RGBA buffer with
// a single vectorised pass instead of a scalar per-byte swap loop. len(p) must
// be a multiple of 4; any trailing bytes are ignored.
func SwapRB(p []byte) {
bgr32BatchToRGBA(p, p, len(p)/4)
}
// FillBGRA converts the bitmap pixel data into packed BGRA32 (4 bytes/pixel,
// B at offset 0) and writes it into dst, growing dst if necessary. The caller
// may pass a previously returned slice to reuse the allocation across calls
// (common for tiled rendering where many same-sized bitmaps are converted in a
// loop). The returned slice has length == bm.Width * bm.Height * 4.
//
// Unlike RGBA() + SwapRB, FillBGRA avoids an intermediate *image.RGBA
// allocation and the second-pass R/B swap for BGR24/BGRA32 inputs. For
// BGR24 the output is a single-pass direct pack; for BGRA32 it is a memcopy.
func (bm *Bitmap) FillBGRA(dst []byte) []byte {
n := bm.Width * bm.Height
need := n * 4
if cap(dst) < need {
dst = make([]byte, need)
}
dst = dst[:need]
data := bm.Data
switch bm.BitsPerPixel {
case 2:
// 16-bit RGB565: convert to RGBA then swap R↔B in-place → BGRA.
if len(data) < n*2 {
n = len(data) / 2
}
rgb565BatchToRGBA(dst, data, n)
bgr32BatchToRGBA(dst, dst, n)
default:
stride := bm.BitsPerPixel
if stride < 3 {
stride = 4 // treat unknown as BGRA32
}
if stride == 4 {
// BGRA32 source — already in the target format; bulk copy.
if len(data) < n*4 {
n = len(data) / 4
}
copy(dst[:n*4], data[:n*4])
} else {
// BGR24 source — pack B,G,R directly with A=0xFF; no R/B swap.
if len(data) < n*3 {
n = len(data) / 3
}
for i := range n {
s := i * 3
*(*uint32)(unsafe.Pointer(&dst[i*4])) =
uint32(data[s]) | uint32(data[s+1])<<8 | uint32(data[s+2])<<16 | 0xFF000000
}
}
}
return dst
}
func NewRdpClient(host string, width, height int, dialer func(string) (net.Conn, error)) *RdpClient {
g := &RdpClient{
hostPort: host,
width: width,
height: height,
kbdLayout: uint32(gcc.US),
keyboardType: uint32(gcc.KT_IBM_101_102_KEYS),
keyboardSubType: 0,
timezoneName: "UTC",
dialer: dialer,
decompressPool: sync.Pool{
New: func() any { return []uint8(nil) },
},
flipLinePool: sync.Pool{
New: func() any { return []uint8(nil) },
},
}
// Point the cached single-element slices at the cached PDU fields so
// sendMouseMoveLocked / sendWheelLocked need no per-call allocations.
g.mouse.pduBuf[0] = &g.mouse.pdu
g.wheel.pduBuf[0] = &g.wheel.pdu
return g
}
var keyboardLayoutMap = map[string]uint32{
"ARABIC": uint32(gcc.ARABIC),
"BULGARIAN": uint32(gcc.BULGARIAN),
"CHINESE_US_KEYBOARD": uint32(gcc.CHINESE_US_KEYBOARD),
"CZECH": uint32(gcc.CZECH),
"DANISH": uint32(gcc.DANISH),
"GERMAN": uint32(gcc.GERMAN),
"GREEK": uint32(gcc.GREEK),
"US": uint32(gcc.US),
"SPANISH": uint32(gcc.SPANISH),
"FINNISH": uint32(gcc.FINNISH),
"FRENCH": uint32(gcc.FRENCH),
"HEBREW": uint32(gcc.HEBREW),
"HUNGARIAN": uint32(gcc.HUNGARIAN),
"ICELANDIC": uint32(gcc.ICELANDIC),
"ITALIAN": uint32(gcc.ITALIAN),
"JAPANESE": uint32(gcc.JAPANESE),
"KOREAN": uint32(gcc.KOREAN),
"DUTCH": uint32(gcc.DUTCH),
"NORWEGIAN": uint32(gcc.NORWEGIAN),
}
var keyboardTypeMap = map[string]uint32{
"IBM_PC_XT_83_KEY": uint32(gcc.KT_IBM_PC_XT_83_KEY),
"OLIVETTI": uint32(gcc.KT_OLIVETTI),
"IBM_PC_AT_84_KEY": uint32(gcc.KT_IBM_PC_AT_84_KEY),
"IBM_101_102_KEYS": uint32(gcc.KT_IBM_101_102_KEYS),
"NOKIA_1050": uint32(gcc.KT_NOKIA_1050),
"NOKIA_9140": uint32(gcc.KT_NOKIA_9140),
"JAPANESE": uint32(gcc.KT_JAPANESE),
}
// SetKeyboardLayout sets the keyboard layout by name (e.g. "US", "FRENCH").
// Must be called before Login.
func (g *RdpClient) SetKeyboardLayout(layout string) {
if v, ok := keyboardLayoutMap[strings.ToUpper(layout)]; ok {
g.kbdLayout = v
} else {
slog.Warn("Unknown keyboard layout, falling back to US", "layout", layout)
g.kbdLayout = uint32(gcc.US)
}
}
// SetKeyboardType sets the keyboard type by name (e.g. "IBM_101_102_KEYS").
// Must be called before Login.
func (g *RdpClient) SetKeyboardType(keyboardType string) {
if v, ok := keyboardTypeMap[strings.ToUpper(keyboardType)]; ok {
g.keyboardType = v
} else {
slog.Warn("Unknown keyboard type, falling back to IBM_101_102_KEYS", "keyboardType", keyboardType)
g.keyboardType = uint32(gcc.KT_IBM_101_102_KEYS)
}
}
// SetTimezone sets the client timezone reported in the Client Info PDU.
// name is a Windows timezone registry key name (e.g. "UTC", "China Standard
// Time"); biasMinutes = UTC minus local time in minutes (e.g. -480 for UTC+8).
// Must be called before Login.
func (g *RdpClient) SetTimezone(name string, biasMinutes int) {
g.timezoneName = name
g.timezoneBias = biasMinutes
}
// DisableAVC444 prevents the client from advertising AVC444/AVC444v2 support.
// When called before Login, the RDPGFX CAPS_ADVERTISE is limited to v8.1
// (AVC420 only), so the server will never send LC=2 chroma-upgrade frames.
// This avoids the colour distortion seen with VirtualBox VRDE, which sends
// LC=2 data but does not include stream2 in LC=0 IDR packets.
// The setting is preserved across automatic reconnects.
func (g *RdpClient) DisableAVC444() *RdpClient {
g.avc444Disabled = true
return g
}
// RejectGFXChannel rejects the RDPGFX dynamic channel entirely, forcing the
// server to fall back to legacy bitmap updates. Useful against servers whose
// graphics pipeline misbehaves. Must be called before Login.
func (g *RdpClient) RejectGFXChannel() {
g.rejectGFX = true
}
// NoAVC keeps the RDPGFX channel alive while forbidding H.264: CAPS_ADVERTISE
// includes v10.x sets with RDPGFX_CAPS_FLAG_AVC_DISABLED, so the server encodes
// with ClearCodec + RFX Progressive ("RemoteFX mode"). Must be called before
// Login; preserved across automatic reconnects.
func (g *RdpClient) NoAVC() *RdpClient {
g.gfxNoAVC = true
return g
}
// SetGfxCacheStore installs the persistent bitmap cache store (MS-RDPEGFX
// CacheImport): SurfaceToCache entries go to the store, and each connection
// offers persisted entries to the server after the caps exchange. Must be
// called after client creation, before the GFX channel finishes negotiation.
func (g *RdpClient) SetGfxCacheStore(s rdpgfx.GfxCacheStore) {
g.gfxCacheStore = s
if h := g.gfxHandler; h != nil {
h.SetPersistentCacheStore(s)
}
}
// SetPduRecorder installs a callback receiving every wire-to-surface bitmap
// payload for offline replay analysis (garbled-screen debugging harness).
func (g *RdpClient) SetPduRecorder(fn func(kind byte, codecId uint16, surfW, surfH, x, y, w, h uint32, payload []byte)) {
g.pduRecorderFn = fn
if h := g.gfxHandler; h != nil {
h.SetPduRecorder(fn)
}
}
func bpp(BitsPerPixel uint16) int {
switch BitsPerPixel {
case 15, 16:
return 2
case 24:
return 3
case 32:
return 4
default:
slog.Error("invalid bitmap data format", "BitsPerPixel", BitsPerPixel)
return 0
}
}
// mouseButtonFlag returns the PTRFLAGS constant for button index 0/1/2.
func mouseButtonFlag(button int) uint16 {
switch button {
case 0:
return pdu.PTRFLAGS_BUTTON1
case 2:
return pdu.PTRFLAGS_BUTTON2
case 1:
return pdu.PTRFLAGS_BUTTON3
default:
return pdu.PTRFLAGS_MOVE
}
}
func (g *RdpClient) Login(domain string, user string, password string) error {
slog.Debug("Login", "Host", g.hostPort, "domain", domain, "user", user)
g.domain = domain
g.user = user
g.password = password
return g.doLogin(nil)
}
// doLogin establishes an RDP connection.
// When routingToken is non-nil it replaces the username cookie in the
// x224 Connection Request (required for Server Redirection).
func (g *RdpClient) doLogin(routingToken []byte) error {
conn, err := g.dialer(g.hostPort)
if err != nil {
return fmt.Errorf("[dial err] %v", err)
}
host, _, _ := net.SplitHostPort(g.hostPort)
socketLayer := core.NewSocketLayer(conn, host)
socketLayer.SetCertVerifier(g.certVerifierFn)
g.tpkt = tpkt.New(socketLayer, nla.NewNTLMv2(g.domain, g.user, g.password))
g.x224 = x224.New(g.tpkt)
g.mcs = t125.NewMCSClient(g.x224, g.kbdLayout, g.keyboardType, g.keyboardSubType)
g.sec = sec.NewClient(g.mcs)
if g.perfFlagsSet {
g.sec.SetPerformanceFlags(g.perfFlags)
}
// 声音模式的协议层声明(与通道注册行为互补,对齐 mstsc)
switch g.audioMode {
case "none":
g.sec.SetNoAudioPlayback()
case "remote":
g.sec.SetRemoteConsoleAudio()
}
g.pdu = pdu.NewClient(g.sec)
g.channels = plugin.NewChannels(g.sec)
// Wire user-registered callbacks now that g.pdu is initialised.
// This allows callers to invoke On* methods before Login.
g.reregisterCallbacks()
// Wire RemoteFX surface decoder so the pdu layer can decode
// codecID=3 in surface bitmap commands without importing rdpgfx.
pdu.DecodeRemoteFX = rdpgfx.DecodeSurfaceRFX
g.mcs.SetClientDesktop(uint16(g.width), uint16(g.height))
// 客户端名随机化(RDPDR-2 假设实验):M1 验收成功时 ClientName 为 wasm
// os.Hostname 的固定值 "js";引入随机名后所有会话的 \\tsclient\<共享名>
// 打开均报"试图访问无效的地址"且 rdpdr 零 IRP。ClientName 是成功/失败
// 之间唯一的客户端侧系统差异——临时禁用随机化以隔离变量。
if os.Getenv("WEBRDP_RANDOM_CLIENT_NAME") == "1" {
cn := fmt.Sprintf("web%06x", uint64(time.Now().UnixNano())&0xffffff)
g.mcs.SetClientName(cn)
slog.Info("client name", "name", cn)
}
if g.colorDepth != 0 {
g.mcs.SetSessionColorDepth(g.colorDepth)
}
// Register channels in order: rdpdr, rdpsnd, cliprdr, drdynvc
// (matching the channel order that Windows servers expect)
// rdpdr (Device Redirection) — stub, required for server to enable audio。
// 启用驱动器重定向时注册完整处理器(宣告文件系统设备并处理 IO),
// 否则保持 stub。
if g.driveRedirect {
label := g.driveLabel
if label == "" {
label = "local"
}
// DosName 与 DeviceData 须同名(服务端建 \\tsclient\<共享名> UNC
// 映射的键,FreeRDP 同款);label 同时作卷标。
// (2026-09-13 凌晨实验结论:DosName 固定 "C" 与 label 同名行为
// 一致——rdpdr 通道同样在验证序列后被服务端关闭,DosName 已彻底
// 排除,见 doc/RDPDR-2.md 与 doc/history/stage6-plan.md。)
g.rdpdrHandler = rdpdr.NewHandler(label, label)
if g.driveFS != nil {
g.rdpdrHandler.SetFilesystem(g.driveFS)
}
g.channels.Register(g.rdpdrHandler)
} else {
g.channels.Register(&stubChannel{name: "rdpdr",
option: plugin.CHANNEL_OPTION_INITIALIZED | plugin.CHANNEL_OPTION_ENCRYPT_RDP | plugin.CHANNEL_OPTION_COMPRESS_RDP})
}
g.mcs.SetClientDeviceRedirection()
// RDPSND (Audio Output) handler — static virtual channel + DVC paths
// 声音重定向三模式(对应 mstsc 远程音频播放):
// remote(远端播放):不注册 rdpsnd / AUDIO_PLAYBACK 通道,
// 服务器音频走本机扬声器;
// none(不播放):照常注册协商,但 wave 数据丢弃——服务器认为
// 音频已被重定向而保持静音;
// local(本机播放,默认):注册并播放。
switch g.audioMode {
case "remote":
// 不注册任何音频通道
default:
rdpsndHandler := rdpsnd.NewHandler(func(format rdpsnd.AudioFormat, data []byte) {
if g.onAudioFn != nil {
g.onAudioFn(format, data)
}
})
if g.audioMode == "none" {
rdpsndHandler.SetMuted(true)
}
rdpsndHandler.SetAudioResetCallback(func() {
if g.onAudioResetFn != nil {
g.onAudioResetFn()
}
})
g.channels.Register(rdpsndHandler)
g.mcs.SetClientSoundProtocol()
// 音频 DVC 适配器在 doLogin 后半段注册(需要 rdpsndHandler 存活),
// 用局部暂存传递;remote 模式下两通道同样不注册。
g.rdpsndHandler = rdpsndHandler
}
// cliprdr (Clipboard) — cross-platform text clipboard handler
cliprdrHandler := cliprdr.NewHandler(
func(text string) {
if g.onClipboardFn != nil {
g.onClipboardFn(text)
}
},
func() string {
if g.getClipboardFn != nil {
return g.getClipboardFn()
}
return ""
},
)
cliprdrHandler.SetImageCallbacks(
func(png []byte) {
if g.onClipboardImageFn != nil {
g.onClipboardImageFn(png)
}
},
func() []byte {
if g.getClipboardImgFn != nil {
return g.getClipboardImgFn()
}
return nil
},
)
cliprdrHandler.SetHTMLCallbacks(
func(html string) {
if g.onClipboardHTMLFn != nil {
g.onClipboardHTMLFn(html)
}
},
func() string {
if g.getClipboardHTMLFn != nil {
return g.getClipboardHTMLFn()
}
return ""
},
)
cliprdrHandler.SetFileCallbacks(
func(names []string) {
if g.onClipboardFilesFn != nil {
g.onClipboardFilesFn(names)
}
},
func(index int, name string, data []byte) {
if g.onClipboardFileDataFn != nil {
g.onClipboardFileDataFn(index, name, data)
}
},
func(index int, received, total int64) {
if g.onFileProgressFn != nil {
g.onFileProgressFn(index, received, total)
}
},
)
g.cliprdrHandler = cliprdrHandler
g.channels.Register(cliprdrHandler)
g.mcs.SetClientClipboard()
// drdynvc (Dynamic Virtual Channels)
dvcClient := drdynvc.NewDvcClient()
g.channels.Register(dvcClient)
g.mcs.SetClientDynvcProtocol()
// RDPGFX (Graphics Pipeline) handler
gfxHandler := rdpgfx.NewGfxHandler(func(updates []rdpgfx.BitmapUpdate) {
if g.onBitmapPaintFn == nil {
return
}
bs := make([]Bitmap, len(updates))
for i, u := range updates {
bs[i] = Bitmap{
DestLeft: u.DestLeft,
DestTop: u.DestTop,
DestRight: u.DestRight,
DestBottom: u.DestBottom,
Width: u.Width,
Height: u.Height,
BitsPerPixel: u.Bpp,
Data: u.Data,
}
}
g.onBitmapPaintFn(bs)
})
gfxHandler.SetDecoderBrokenCallback(func() {
slog.Debug("H.264 decoder broken")
if g.onDecoderBrokenFn != nil {
g.onDecoderBrokenFn()
}
})
gfxHandler.SetKeyframeRequestFunc(func() {
slog.Debug("H.264: requesting keyframe via force refresh")
if g.pdu != nil {
// SendRefreshRect is silently ignored by Windows servers while
// an H.264 video stream is active. Use the suppress→allow
// toggle (SendForceRefresh) which mstsc/FreeRDP rely on to
// reliably trigger a fresh IDR. See protocol/pdu/pdu.go.
g.pdu.SendForceRefresh(uint16(g.width), uint16(g.height))
}
})
if g.onH264RawFn != nil {
gfxHandler.SetH264RawCallback(g.onH264RawFn)
}
if g.onH264I420Fn != nil {
gfxHandler.SetI420Callback(g.onH264I420Fn)
}
if g.onH264NV12Fn != nil {
gfxHandler.SetNV12Callback(g.onH264NV12Fn)
}
if g.avc444Disabled {
gfxHandler.SetAVC444Disabled(true)
}
if g.gfxNoAVC {
gfxHandler.SetAVCDisabled(true)
}
if g.pduRecorderFn != nil {
gfxHandler.SetPduRecorder(g.pduRecorderFn)
}
g.gfxHandler = gfxHandler
// 持久缓存桥:SetGfxCacheStore 在 handler 创建前调用,必须在这里补挂
// (此前 store 在这里丢失,GFX 持久缓存实际从未生效——服务端 0 条
// SurfaceToCache 掩盖了这一点)。
if g.gfxCacheStore != nil && !g.rejectGFX {
gfxHandler.SetPersistentCacheStore(g.gfxCacheStore)
}
// bitmap 管线持久缓存(6.4b M2):把跨会话持有的键注册给 finalize,
// 服务器广告 HOST SUPPORT 时经 PERSISTENT_KEY_LIST 上报。
if g.rejectGFX && g.gfxCacheStore != nil {
g.pdu.SetPersistentKeyList(g.gfxCacheStore.Keys())
}
gfxHandler.SetSessionSize(uint16(g.width), uint16(g.height))
if g.rejectGFX {
dvcClient.RegisterRejectedChannel(rdpgfx.ChannelName)
} else {
dvcClient.RegisterHandler(rdpgfx.ChannelName, gfxHandler)
}
// RDPEDISP (Display Update Virtual Channel) handler — allows requesting
// a resolution change while connected (MS-RDPEDISP). Pass 0,0 so no
// initial MONITOR_LAYOUT PDU is sent: some servers' graphics pipeline
// fails (ERRINFO_GRAPHICS_SUBSYSTEM_FAILED 0x112F) when the desktop is
// resized during GFX surface setup. Resolution changes go through
// SetResolution() instead.
dispHandler := rdpedisp.NewHandler(0, 0)
g.dispHandler = dispHandler
dvcClient.RegisterHandler(rdpedisp.ChannelName, dispHandler)
// Reject Video Optimized Remoting (VOR) channels so the server keeps
// sending video through the RDPGFX pipeline which we do handle.
// Without this, the server detects video playback (e.g. YouTube) and
// switches to VOR channels that we don't implement, causing the video
// to freeze while audio continues.
dvcClient.RegisterRejectedChannel("Microsoft::Windows::RDS::Video::Control::v08.01")
dvcClient.RegisterRejectedChannel("Microsoft::Windows::RDS::Video::Data::v08.01")
dvcClient.RegisterRejectedChannel("Microsoft::Windows::RDS::Geometry::v08.01")
// Register DVC audio handlers for both the lossless and lossy variants.
// gnome-remote-desktop requests AUDIO_PLAYBACK_LOSSY_DVC first; if it is
// rejected, gnome-remote-desktop triggers its SVC fallback path which also
// sets prevent_dvc_initialization=true, silently blocking AUDIO_PLAYBACK_DVC
// as well — leaving the client with no audio at all.
// By accepting both channels with the same rdpsnd handler, format negotiation
// (which only advertises PCM) ensures PCM is used regardless of which channel
// gnome-remote-desktop chooses.
// remote(远端播放)模式不注册,让服务器走本机音频。
if g.rdpsndHandler != nil {
dvcClient.RegisterHandler("AUDIO_PLAYBACK_DVC", rdpsnd.NewDvcAdapter(g.rdpsndHandler))
dvcClient.RegisterHandler("AUDIO_PLAYBACK_LOSSY_DVC", rdpsnd.NewDvcAdapter(g.rdpsndHandler))
}
g.sec.SetUser(g.user)
g.sec.SetPwd(g.password)
g.sec.SetDomain(g.domain)
// 时区:dynamic DST 键名为空会导致服务器 0x112F 断连;默认发 UTC
g.sec.SetClientTimezone(g.timezoneName, g.timezoneBias)
g.tpkt.SetFastPathListener(g.sec)
g.sec.SetFastPathListener(g.pdu)
g.sec.SetChannelSender(g.mcs)
g.channels.SetChannelSender(g.sec)
// Wire fast-path output: pdu → sec → tpkt. This enables the much
// shorter Fast-Path Client Input PDU framing for mouse/keyboard events
// (MS-RDPBCGR §2.2.8.1.2). Use is gated at runtime both by capability
// negotiation in the PDU layer and by sec.SendFastPath itself, which
// refuses when legacy RDP encryption is in effect.
g.sec.SetFastPathSender(g.tpkt)
g.pdu.SetFastPathSender(g.sec)
g.x224.SetRequestedProtocol(x224.PROTOCOL_SSL | x224.PROTOCOL_HYBRID)
if routingToken != nil {
g.x224.SetRoutingToken(routingToken)
} else {
g.x224.SetUsername(g.user)
}
err = g.x224.Connect()
if err != nil {
return fmt.Errorf("[x224 connect err] %v", err)
}
// Wait for the RDP handshake to complete or fail.
// Events arrive asynchronously from the TPKT read goroutine.
type connResult struct {
err error
redirect *pdu.ServerRedirectionPDU
}
ch := make(chan connResult, 4)
send := func(r connResult) {
select {
case ch <- r:
default:
}
}
// readyFired is set by the "ready" callback. All emitter callbacks
// run synchronously on the TPKT read goroutine, so no mutex needed.
readyFired := false
g.pdu.On("ready", func() {
g.eventReady.Store(true)
readyFired = true
send(connResult{})
})
g.pdu.On("error", func(err error) {
if !readyFired {
send(connResult{err: err})
} else {
// Mid-session error: stop accepting input so we don't
// try to write to the now-dead transport.
g.eventReady.Store(false)
}
})
// Redirect may arrive before or after "ready".
// Before ready: send to channel for synchronous handling.
// After ready: launch async goroutine (GNOME Remote Desktop
// sends redirect ~5s after the GFX retry's "ready").
g.pdu.Once("redirect", func(redir *pdu.ServerRedirectionPDU) {
if !readyFired {
send(connResult{redirect: redir})
} else {
go g.handleRedirect(redir)
}
})
// DeactivateAllPDU during an active session means the server is
// reactivating (e.g. desktop resize). Pause input until "ready"
// fires again after the reactivation handshake completes.
g.pdu.On("deactivateAll", func() {
g.eventReady.Store(false)
})
select {
case r := <-ch:
if r.err != nil {
g.tpkt.Close()
return fmt.Errorf("[connection err] %v", r.err)
}
if r.redirect != nil {
slog.Debug("Server redirect", "loadBalanceInfo", string(r.redirect.LoadBalanceInfo))
g.tpkt.Close()
g.eventReady.Store(false)
return g.doLogin(r.redirect.LoadBalanceInfo)
}
// "ready" received — session established.
return nil
case <-time.After(30 * time.Second):
g.tpkt.Close()
return fmt.Errorf("[connection timeout]")
}
}
// handleRedirect handles a Server Redirection PDU that arrives after
// "ready" (e.g. GNOME Remote Desktop). Runs asynchronously.
func (g *RdpClient) handleRedirect(redir *pdu.ServerRedirectionPDU) {
slog.Debug("Async server redirect", "loadBalanceInfo", string(redir.LoadBalanceInfo))
g.reconnecting.Store(true)
g.tpkt.Close()
g.eventReady.Store(false)
err := g.doLogin(redir.LoadBalanceInfo)
g.reconnecting.Store(false)
if err != nil {
slog.Error("handleRedirect: login failed", "err", err)
if g.onErrorFn != nil {
g.onErrorFn(err)
}
return
}
g.reregisterCallbacks()
}
func (g *RdpClient) Width() int {
return g.width
}
func (g *RdpClient) Height() int {
return g.height
}
func (g *RdpClient) OnError(f func(e error)) *RdpClient {
g.onErrorFn = f
if g.pdu != nil {
g.pdu.On("error", func(e error) {
if !g.reconnecting.Load() {
f(e)
}
})
}
return g
}
func (g *RdpClient) OnClose(f func()) *RdpClient {
g.onCloseFn = f
if g.pdu != nil {
g.pdu.On("close", func() {
if !g.reconnecting.Load() {
f()
}
})
}
return g
}
func (g *RdpClient) OnSuccess(f func()) *RdpClient {
g.onSuccessFn = f
if g.sec != nil {
g.sec.On("success", f)
}
return g
}
func (g *RdpClient) OnReady(f func()) *RdpClient {
g.onReadyFn = f
if g.pdu != nil {
g.pdu.On("ready", f)
}
return g
}
// OnBitmap registers a callback for bitmap update events.
// For compressed bitmaps, Bitmap.Data is borrowed from an internal pool and
// is valid only for the duration of the paint call. If you need to retain
// the raw pixel data beyond paint, copy it or call bm.RGBA() inside paint.
func (g *RdpClient) OnBitmap(paint func([]Bitmap)) *RdpClient {
g.onBitmapPaintFn = paint
if g.pdu == nil {
return g
}
g.pdu.On("bitmap", func(rectangles []pdu.BitmapData) {
// 16/24bpp 位图路径曾发生 panic 直接杀死整个 wasm 程序;
// recover 保证连接存活并留下完整堆栈用于定位。
defer func() {
if r := recover(); r != nil {
slog.Error("bitmap update panic", "err", r, "stack", string(debug.Stack()))
}
}()
bs := make([]Bitmap, 0, len(rectangles))
var pooled [][]uint8 // track buffers borrowed from pool
for idx, v := range rectangles {
data := v.BitmapDataStream
wireBpp := v.BitsPerPixel
if wireBpp == 0 {
// Win10 在低色深会话(经 postBeta2ColorDepth 协商)里把
// bitsPerPixel 置 0:按会话色深处理
wireBpp = uint16(g.colorDepth)
if wireBpp == 0 {
wireBpp = 32
}
}
Bpp := bpp(wireBpp)
if Bpp == 0 {
slog.Error("bitmap rect with invalid bpp",
"idx", idx, "count", len(rectangles),
"rect", fmt.Sprintf("%+v", v),
"allRects", fmt.Sprintf("%+v", rectangles))
continue
}
if v.Flags&pdu.BITMAP_NO_PROCESSING != 0 {
// Surface command: data is already decoded top-down BGRA
} else if v.IsCompress() {
buf := g.decompressPool.Get().([]uint8)
var ok bool
buf, ok = core.DecompressInto(v.BitmapDataStream, buf, int(v.Width), int(v.Height), Bpp)
if !ok {
// 解码失败的矩形绝不能上屏:缓冲里是半解码+池化残留,
// 画出来就是噪块。跳过并等服务器后续更新修复该区域。
g.decompressPool.Put(buf)
slog.Warn("skip undecodable bitmap rect",
"dx", v.DestLeft, "dy", v.DestTop,
"dr", v.DestRight, "db", v.DestBottom,
"w", v.Width, "h", v.Height, "bpp", Bpp,
"flags", fmt.Sprintf("0x%04X", v.Flags),
"len", len(v.BitmapDataStream))
continue
}
data = buf
pooled = append(pooled, buf)
} else {
// Uncompressed bitmaps are bottom-up; flip to top-down.
stride := int(v.Width) * Bpp
h := int(v.Height)
tmp := g.flipLinePool.Get().([]byte)
if cap(tmp) < stride {
tmp = make([]byte, stride)
} else {
tmp = tmp[:stride]
}
for y := 0; y < h/2; y++ {
top := y * stride
bot := (h - 1 - y) * stride
copy(tmp, data[top:top+stride])
copy(data[top:top+stride], data[bot:bot+stride])
copy(data[bot:bot+stride], tmp)
}
g.flipLinePool.Put(tmp[:cap(tmp)])
}
b := Bitmap{int(v.DestLeft), int(v.DestTop), int(v.DestRight), int(v.DestBottom),
int(v.Width), int(v.Height), Bpp, data}
bs = append(bs, b)
}
if g.bmpEmitLogged < 10 {
g.bmpEmitLogged++
slog.Warn("BITMAP_EMIT", "rects", len(bs))
}
paint(bs)
for _, buf := range pooled {
g.decompressPool.Put(buf[:cap(buf)])
}
})
// 位图缓存(stage6 6.4b M1):CacheBitmapV2 存入,MemBlt 引用回贴。
// 订单与位图更新同为 fast-path PDU,按到达顺序同步处理,服务器保证
// MemBlt 先于对应 CacheBitmapV2 的乱序不存在。
g.bitmapCache = make(map[uint32]*Bitmap)
ordersSeen := 0
g.pdu.On("orders", func(orderPdus []pdu.OrderPdu) {
ordersSeen++
if ordersSeen <= 5 {
kinds := make(map[string]int)
for i := range orderPdus {
o := &orderPdus[i]
switch {
case o.CacheBitmapV2 != nil:
kinds["CacheBitmapV2"]++
case o.Primary != nil && o.Primary.Data != nil:
kinds[fmt.Sprintf("%T", o.Primary.Data)]++
default:
kinds["empty"]++
}
}
slog.Warn("ORDERS event", "n", ordersSeen, "pdus", len(orderPdus), "kinds", fmt.Sprintf("%v", kinds))
}
defer func() {
if r := recover(); r != nil {
slog.Error("orders update panic", "err", r, "stack", string(debug.Stack()))
}
}()
for i := range orderPdus {
o := &orderPdus[i]
if cb := o.CacheBitmapV2; cb != nil {
g.storeBitmapCacheV2(cb)
continue
}
if o.Primary != nil {
if mb, ok := o.Primary.Data.(*pdu.Memblt); ok {
g.drawMemblt(mb)
}
}
}
})
return g
}
func (g *RdpClient) OnPointerHide(f func()) *RdpClient {
g.onPointerHideFn = f
if g.pdu != nil {
g.pdu.On("pointer_hide", f)
}
return g
}
func (g *RdpClient) OnPointerCached(f func(uint16)) *RdpClient {
g.onPointerCachedFn = f
if g.pdu != nil {
g.pdu.On("pointer_cached", f)
}
return g
}
func (g *RdpClient) OnPointerDefault(f func()) *RdpClient {
g.onPointerDefaultFn = f
if g.pdu != nil {
g.pdu.On("pointer_default", f)
}
return g
}
// bitmapCacheMaxEntries 单元总量上限(超出按 FIFO 逐出,防御服务器
// 引用已逐出条目时缓存无限增长)。
const bitmapCacheMaxEntries = 4096
// storeBitmapCacheV2 解压 CacheBitmapV2 次级订单的位图并按
// cacheId<<16|cacheIndex 存入会话内缓存(DO_NOT_CACHE 条目不存)。
func (g *RdpClient) storeBitmapCacheV2(cb *pdu.CacheBitmapV2Order) {
if cb.CacheIndex == 0x7FFF || cb.BitmapWidth == 0 || cb.BitmapHeight == 0 {
return
}
// 持久键(6.4b M2):服务器在 CacheBitmapV2 上携带跨会话键。
// 带键且零数据 = 服务器指示客户端从持久库回填该单元。
persistentKey := uint64(0)
if cb.Flags&pdu.CBR2_PERSISTENT_KEY_PRESENT != 0 {
persistentKey = uint64(cb.Key2)<<32 | uint64(cb.Key1)
}
Bpp := bpp(uint16(cb.BitmapBpp))
if Bpp == 0 {
return
}
w, h := int(cb.BitmapWidth), int(cb.BitmapHeight)
if cb.BitmapLength == 0 && persistentKey != 0 {
if g.gfxCacheStore == nil {
return
}
e, ok := g.gfxCacheStore.Get(persistentKey)
if !ok || e.Width != w || e.Height != h || e.Bpp != uint16(Bpp) ||
len(e.Data) != w*h*int(Bpp) {
slog.Debug("bmpcache: persistent backfill miss", "key", persistentKey,
"cacheId", cb.CacheId, "idx", cb.CacheIndex, "w", w, "h", h)
return
}
entry := &Bitmap{Width: w, Height: h, BitsPerPixel: Bpp, Data: e.Data}
g.bitmapCachePut(uint32(cb.CacheId)<<16|uint32(cb.CacheIndex), entry)
return
}
stride := w * Bpp
var data []byte
var ok bool
if cb.Compressed {
buf := g.decompressPool.Get().([]byte)
var out []byte
out, ok = core.DecompressInto(cb.BitmapDataStream, buf, w, h, Bpp)
if !ok {
g.decompressPool.Put(buf)
slog.Debug("bmpcache: skip undecodable cache bitmap",
"cacheId", cb.CacheId, "idx", cb.CacheIndex,
"w", w, "h", h, "bpp", Bpp, "len", len(cb.BitmapDataStream))
return
}
data = out
} else {
data = append([]byte(nil), cb.BitmapDataStream...)
}
// RLE 位图自底向上,翻转为自顶向下(与 bitmap 更新路径一致)
mirrorRows(data, stride, h)
entry := &Bitmap{Width: w, Height: h, BitsPerPixel: Bpp, Data: data}
g.bitmapCachePut(uint32(cb.CacheId)<<16|uint32(cb.CacheIndex), entry)
// 带持久键的条目跨会话持久化(像素副本,键 = key2<<32|key1)
if persistentKey != 0 && g.gfxCacheStore != nil {
g.gfxCacheStore.Persist(persistentKey, w, h, uint16(Bpp), data)
}
stores := g.bmpCacheStores.Add(1)
if stores == 1 || stores%500 == 0 {
slog.Info("bmpcache: stored", "stores", stores, "total", len(g.bitmapCache),
"cacheId", cb.CacheId, "idx", cb.CacheIndex, "w", w, "h", h, "bpp", Bpp)
}
}
// bitmapCachePut 写入会话内缓存单元(cacheId<<16|cacheIndex → 位图),
// 超过 bitmapCacheMaxEntries 按插入序 FIFO 逐出。
func (g *RdpClient) bitmapCachePut(key uint32, entry *Bitmap) {
if _, exists := g.bitmapCache[key]; !exists {
g.bitmapCacheFIFO = append(g.bitmapCacheFIFO, key)
if len(g.bitmapCacheFIFO) > bitmapCacheMaxEntries {
evict := g.bitmapCacheFIFO[0]
g.bitmapCacheFIFO = g.bitmapCacheFIFO[1:]
delete(g.bitmapCache, evict)
}
}
g.bitmapCache[key] = entry
}
// drawMemblt 处理 MemBlt 主订单:从缓存取位图按目标坐标回贴。
// 缓存缺失时跳过(保持既有画面,等服务器后续更新修复该区域)。
func (g *RdpClient) drawMemblt(mb *pdu.Memblt) {
if g.onBitmapPaintFn == nil {
return
}
key := uint32(mb.CacheId)<<16 | uint32(mb.CacheIdx)
b, ok := g.bitmapCache[key]
if !ok || b == nil {
slog.Debug("bmpcache: memblt cache miss", "cacheId", mb.CacheId, "idx", mb.CacheIdx)
return
}
out := *b
out.DestLeft, out.DestTop = int(mb.X), int(mb.Y)
out.DestRight, out.DestBottom = int(mb.X)+int(mb.Cx), int(mb.Y)+int(mb.Cy)
hits := g.bmpCacheHits.Add(1)
if hits == 1 || hits%500 == 0 {
slog.Info("bmpcache: hit", "hits", hits, "cacheId", mb.CacheId,
"idx", mb.CacheIdx, "x", mb.X, "y", mb.Y, "w", mb.Cx, "h", mb.Cy)
}
g.onBitmapPaintFn([]Bitmap{out})
}
// mirrorRows 将 stride 对齐的行序图像原地垂直镜像(bottom-up ↔ top-down)。
func mirrorRows(buf []byte, stride, height int) {
tmp := make([]byte, stride)
for y := 0; y < height/2; y++ {
top := y * stride
bot := (height - 1 - y) * stride
if top+stride <= len(buf) && bot+stride <= len(buf) {
copy(tmp, buf[top:top+stride])
copy(buf[top:top+stride], buf[bot:bot+stride])
copy(buf[bot:bot+stride], tmp)
}
}
}
func (g *RdpClient) OnPointerUpdate(f func(uint16, uint16, uint16, uint16, uint16, uint16, []byte, []byte)) *RdpClient {
g.onPointerUpdateFn = f
if g.pdu != nil {
g.pdu.On("pointer_update", func(p *pdu.FastPathUpdatePointerPDU) {
w := int(p.Width)
h := int(p.Height)
// xorBpp 由线路直接携带(TS_POINTER_NEW 首字段,FreeRDP
// update_read_pointer_new 校验 1≤xorBpp≤32)。
xorBpp := int(p.XorBpp)
if xorBpp == 0 {
xorBpp = 1
}
slog.Debug("OnPointerUpdate", "cacheIdx", p.CacheIdx, "xorBpp", p.XorBpp,
"hotX", p.HotX, "hotY", p.HotY, "w", p.Width, "h", p.Height,
"andLen", p.MaskLen, "xorLen", p.XorLen)
// 掩码行序:按 MS-RDPBCGR/FreeRDP(vFlip = xorBpp != 1),彩色
// 指针(24/32bpp)自底向上存储,翻转为自顶向下;1bpp 单色指针
// 本身自顶向下。stride 均按 2 字节对齐。
flip := xorBpp != 1
xorStride := ((w*xorBpp + 15) / 16) * 2
andStride := ((w + 15) / 16) * 2
var xorData []byte
if len(p.Data) > 0 && h > 0 && w > 0 {
xorData = make([]byte, len(p.Data))
copy(xorData, p.Data)
if flip {
mirrorRows(xorData, xorStride, h)
}
} else {
xorData = p.Data
}
var andMask []byte
if len(p.Mask) > 0 && h > 0 && w > 0 {
andMask = make([]byte, len(p.Mask))
copy(andMask, p.Mask)
if flip {
mirrorRows(andMask, andStride, h)
}
} else {
andMask = p.Mask
}
f(p.CacheIdx, uint16(xorBpp), p.HotX, p.HotY, p.Width, p.Height, andMask, xorData)
})
}
return g
}
// OnAudio registers a callback for server audio data.
// The callback receives the AudioFormat describing the PCM data and the raw audio bytes.
// Must be called before Login.
func (g *RdpClient) OnAudio(f func(rdpsnd.AudioFormat, []byte)) *RdpClient {
g.onAudioFn = f
return g
}
// OnAudioReset registers a callback that is called when the server closes the
// audio channel (e.g. media seek or stream restart). The application should
// flush its audio playback buffer so that stale audio does not keep playing.
// Must be called before Login.
func (g *RdpClient) OnAudioReset(f func()) *RdpClient {
g.onAudioResetFn = f
return g
}
// OnH264Raw registers a callback that receives raw H.264 NAL unit data when
// the built-in decoder is unavailable (e.g. WASM builds without CGo).
// destX, destY are the top-left canvas coordinates; isKey flags an IDR frame.
// regions 为扁平 [l,t,r,b,...](帧内坐标,右下开区间)的脏矩形:服务器只
// 保证区域内像素有效,绘制端必须只上屏区域;空切片表示整帧有效。
// The caller owns data and may retain it beyond the callback.
func (g *RdpClient) OnH264Raw(fn func(destX, destY, w, h int, isKey bool, data []byte, regions []int32)) *RdpClient {
g.onH264RawFn = fn
return g
}
// OnH264I420 registers a callback that receives decoded H.264 frames in I420
// planar format (Y, U, V planes with associated strides). When set, the
// decoded frame is NOT delivered via OnBitmap; the caller is responsible for
// rendering it directly (e.g. via an SDL2 IYUV texture for GPU-accelerated
// YUV→RGB conversion). When I420 extraction is unavailable for a frame
// (e.g. non-YUV420P/NV12 formats), grdp falls back to OnBitmap delivery.
// destX, destY are top-left canvas coordinates; w, h are frame dimensions.
// The plane slices are only valid for the duration of the callback; copy them
// if they need to be retained beyond the callback's return.
func (g *RdpClient) OnH264I420(fn func(destX, destY, w, h int, y []byte, yStride int, u []byte, uStride int, v []byte, vStride int)) *RdpClient {
g.onH264I420Fn = fn
if g.gfxHandler != nil {
g.gfxHandler.SetI420Callback(fn)
}
return g
}
// OnH264NV12 registers a callback that receives decoded H.264 frames in NV12
// format (Y plane plus interleaved UV plane). This is the fastest SDL2 path
// on platforms whose hardware decoder already outputs NV12 (notably macOS
// VideoToolbox), because callers can upload the planes directly with an NV12
// texture and avoid NV12->I420 deinterleaving in grdp. When NV12 extraction
// is unavailable for a frame, grdp falls back to OnBitmap delivery.
// destX, destY are top-left canvas coordinates; w, h are frame dimensions.
// The plane slices are only valid for the duration of the callback; copy them
// if they need to be retained beyond the callback's return.
func (g *RdpClient) OnH264NV12(fn func(destX, destY, w, h int, y []byte, yStride int, uv []byte, uvStride int)) *RdpClient {
g.onH264NV12Fn = fn
if g.gfxHandler != nil {
g.gfxHandler.SetNV12Callback(fn)
}
return g
}
// OnDecoderBroken registers a callback that is invoked when the H.264 decoder
// enters an unrecoverable state (all hard-reset attempts exhausted). When
// this callback is set, grdp does NOT automatically call Reconnect; the
// application is responsible for deciding when to reconnect (e.g. via its
// own stall watchdog). If no callback is registered, grdp falls back to
// the previous behaviour of reconnecting immediately.
func (g *RdpClient) OnDecoderBroken(f func()) *RdpClient {
g.onDecoderBrokenFn = f
return g
}
// OnClipboard registers callbacks for bidirectional clipboard sharing.
//
// - onRemote is called with the text when the RDP server's clipboard
// content is received (server → client).
// - getLocal is called to retrieve the current local clipboard text
// when the server requests it (client → server).
//
// Must be called before Login.
func (g *RdpClient) OnClipboard(onRemote func(text string), getLocal func() string) *RdpClient {
g.onClipboardFn = onRemote
g.getClipboardFn = getLocal
return g
}
// OnClipboardImage registers the callback invoked with PNG-encoded bytes when
// the RDP server's clipboard image is received (server → client). Must be
// called before Login.
func (g *RdpClient) OnClipboardImage(onRemoteImage func(png []byte)) *RdpClient {
g.onClipboardImageFn = onRemoteImage
return g
}
// SetClipboardImageProvider registers the provider used to answer server
// requests for the local clipboard image (client → server). The provider
// returns PNG-encoded bytes, or nil when no image is on the local clipboard.
// Must be called before Login.
func (g *RdpClient) SetClipboardImageProvider(getImage func() []byte) *RdpClient {
g.getClipboardImgFn = getImage
return g
}
// OnClipboardHTML registers the callback invoked when the remote clipboard
// HTML content (HTML Format, fragment already extracted) arrives.
func (g *RdpClient) OnClipboardHTML(onRemoteHTML func(html string)) *RdpClient {
g.onClipboardHTMLFn = onRemoteHTML
return g
}
// SetClipboardHTMLProvider registers the provider used to answer server
// requests for the local clipboard HTML (HTML Format). The provider returns
// raw HTML (no CF_HTML envelope), or "" when no HTML is on the local
// clipboard. Must be called before Login.
func (g *RdpClient) SetClipboardHTMLProvider(getHTML func() string) *RdpClient {
g.getClipboardHTMLFn = getHTML
return g
}
// OnClipboardFiles registers the callback invoked when the remote clipboard
// holds files (CF_HDROP path list received, server → client). Files are NOT
// fetched automatically; call RequestRemoteFile for each file to download.
func (g *RdpClient) OnClipboardFiles(onRemoteFiles func(names []string)) *RdpClient {
g.onClipboardFilesFn = onRemoteFiles
return g
}
// OnClipboardFileData registers the callback invoked when a file requested
// via RequestRemoteFile has been fully received. data is nil on failure.
func (g *RdpClient) OnClipboardFileData(fn func(index int, name string, data []byte)) *RdpClient {
g.onClipboardFileDataFn = fn
return g
}
// OnFileTransferProgress registers the callback invoked after every received
// chunk of an in-flight RequestRemoteFile transfer.
func (g *RdpClient) OnFileTransferProgress(fn func(index int, received, total int64)) *RdpClient {
g.onFileProgressFn = fn
return g
}
// SetLocalFiles stages files as the local clipboard file content (client →
// server). The server will see a CF_HDROP format and fetch bytes on paste.
func (g *RdpClient) SetLocalFiles(files []cliprdr.LocalFile) {
if g.cliprdrHandler != nil {
g.cliprdrHandler.SetLocalFiles(files)
}
}
// ClearLocalFiles removes locally staged clipboard files.
func (g *RdpClient) ClearLocalFiles() {
if g.cliprdrHandler != nil {
g.cliprdrHandler.ClearLocalFiles()
}
}
// RequestRemoteFile starts downloading file `index` from the remote clipboard
// file list; progress and completion arrive via the registered callbacks.
func (g *RdpClient) RequestRemoteFile(index int) error {
if g.cliprdrHandler == nil {
return errors.New("client not connected")
}
return g.cliprdrHandler.RequestRemoteFile(index)
}
// SetCertVerifier registers a TOFU (trust-on-first-use) verifier for the
// server's TLS certificate. The callback receives the SHA-256 of the leaf
// certificate DER; return an error to abort the connection. Must be called
// before Login.
func (g *RdpClient) SetCertVerifier(fn func(sha256Fp []byte) error) *RdpClient {
g.certVerifierFn = fn
return g
}
// NotifyClipboardChanged tells the server that the local clipboard has
// changed. The UI should call this when it detects a system clipboard
// change (e.g. via polling or a platform clipboard-change signal).
func (g *RdpClient) NotifyClipboardChanged() {
if g.cliprdrHandler != nil {
g.cliprdrHandler.OnLocalClipboardChanged()
}
}
func (g *RdpClient) notifyGfxLocalInput() {
if gfx := g.gfxHandler; gfx != nil {
gfx.NotifyLocalInput()
}
}
// newScancodeEvent builds a TS_SCANCODE_EVENT from the 0xE0xx convention
// used throughout this codebase: the E0 prefix must travel as
// KBDFLAGS_EXTENDED with the 8-bit make code in KeyCode — the slow-path
// serializer sends KeyCode verbatim, and a raw 0xE0xx value there is an
// invalid scancode the server silently drops (observed: Delete did
// nothing on the remote).
func newScancodeEvent(sc int, release bool) *pdu.ScancodeKeyEvent {
p := &pdu.ScancodeKeyEvent{}
if sc&0xFF00 == 0xE000 {
p.KeyboardFlags |= pdu.KBDFLAGS_EXTENDED
sc &= 0xFF
}
p.KeyCode = uint16(sc)
if release {
p.KeyboardFlags |= pdu.KBDFLAGS_RELEASE
}
return p
}
func (g *RdpClient) KeyUp(sc int) {
if !g.eventReady.Load() {
return
}
slog.Debug("KeyUp", "sc", sc)
g.flushMouseMove()
g.flushWheel()
p := newScancodeEvent(sc, true)
g.pdu.SendInputEvents(pdu.INPUT_EVENT_SCANCODE, []pdu.InputEventsInterface{p})
g.notifyGfxLocalInput()
}
// SendUnicodeText sends text as RDP Unicode input events (MS-RDPBCGR
// RDP_INPUT_UNICODE, TS_UNICODE_EVENT). Used for IME-committed text and
// clipboard paste, which have no meaningful scancode representation.
// Each character is sent as a press+release pair; characters outside the
// BMP (no UTF-16 code unit mapping) are skipped.
func (g *RdpClient) SendUnicodeText(text string) {
if !g.eventReady.Load() {
return
}
g.flushMouseMove()
g.flushWheel()
const maxEventsPerPDU = 14 // fast-path input PDU hard limit is 15 events
events := make([]pdu.InputEventsInterface, 0, maxEventsPerPDU)
flush := func() {
if len(events) == 0 {
return
}
g.pdu.SendInputEvents(pdu.INPUT_EVENT_UNICODE, events)
events = events[:0]
}
for _, r := range text {
if r == '\r' || r == '\n' {
// CR/LF 走 Unicode 事件会被服务端当不可打印字符丢弃(实测 cmd
// 收不到回车,粘贴多行文本/IME 提交带回车的文本无法执行),转成
// Enter 扫描码按下+释放。扫描码必须走 INPUT_EVENT_SCANCODE PDU:
// 与 Unicode 事件混在同一 PDU 里服务端会按 TS_UNICODE_EVENT
// 解析出控制字符并丢弃(实测 Enter 静默丢失)。
flush()
g.pdu.SendInputEvents(pdu.INPUT_EVENT_SCANCODE, []pdu.InputEventsInterface{
newScancodeEvent(0x001C, false),
newScancodeEvent(0x001C, true),
})
continue
}
if r > 0xFFFF || (r >= 0xD800 && r <= 0xDFFF) {
continue
}
u := uint16(r)
events = append(events,
&pdu.UnicodeKeyEvent{Unicode: u},
&pdu.UnicodeKeyEvent{Unicode: u, KeyboardFlags: pdu.KBDFLAGS_RELEASE},
)
if len(events) >= maxEventsPerPDU {
flush()
}
}
flush()
g.notifyGfxLocalInput()
}
func (g *RdpClient) KeyDown(sc int) {
if !g.eventReady.Load() {
return
}
slog.Debug("KeyDown", "sc", sc)
g.flushMouseMove()
g.flushWheel()
p := newScancodeEvent(sc, false)
g.pdu.SendInputEvents(pdu.INPUT_EVENT_SCANCODE, []pdu.InputEventsInterface{p})
g.notifyGfxLocalInput()
}
// MouseMove queues a mouse-move event. Successive moves within
// mouseCoalesceInterval are collapsed: only the latest (x,y) is sent. The
// first move in a burst is sent immediately so the server sees no extra
// latency for a single isolated motion.
func (g *RdpClient) MouseMove(x, y int) {
if !g.eventReady.Load() {
return
}
g.mouse.mu.Lock()
g.mouse.x = x
g.mouse.y = y
g.mouse.pending = true
now := time.Now()
since := now.Sub(g.mouse.lastTx)
if since >= mouseCoalesceInterval {
// Throttle window has elapsed — send right away.
g.sendMouseMoveLocked(now)
g.mouse.mu.Unlock()
return
}
// Within throttle window: schedule a flush for the remainder of it
// (unless one is already scheduled).
if g.mouse.timer == nil {
delay := mouseCoalesceInterval - since
g.mouse.timer = time.AfterFunc(delay, g.flushMouseMoveTimer)
}
g.mouse.mu.Unlock()
}
// flushMouseMove sends any pending mouse-move event synchronously. Called
// before any non-move input event to preserve server-side ordering.
func (g *RdpClient) flushMouseMove() {
g.mouse.mu.Lock()
if g.mouse.timer != nil {
g.mouse.timer.Stop()
g.mouse.timer = nil
}
if g.mouse.pending {
g.sendMouseMoveLocked(time.Now())
}
g.mouse.mu.Unlock()
}
// flushMouseMoveTimer is the time.AfterFunc callback. Acquires the lock
// itself and sends whatever's pending.
func (g *RdpClient) flushMouseMoveTimer() {
g.mouse.mu.Lock()
g.mouse.timer = nil
if g.mouse.pending && g.eventReady.Load() {
g.sendMouseMoveLocked(time.Now())
}
g.mouse.mu.Unlock()
}
// sendMouseMoveLocked must be called with mouse.mu held.
func (g *RdpClient) sendMouseMoveLocked(now time.Time) {
g.mouse.pdu.PointerFlags = pdu.PTRFLAGS_MOVE
g.mouse.pdu.XPos = uint16(g.mouse.x)
g.mouse.pdu.YPos = uint16(g.mouse.y)
g.mouse.pending = false
g.mouse.lastTx = now
g.pdu.SendInputEvents(pdu.INPUT_EVENT_MOUSE, g.mouse.pduBuf[:])
}
// MouseWheel sends a vertical scroll event to the remote desktop.
// delta is the rotation amount in physical notches (1.0 = one click of a
// scroll wheel = Windows WHEEL_DELTA). Fractional values are accepted for
// smooth / high-resolution input devices such as trackpads.
// Positive values scroll up (away from the user); negative values scroll down.
func (g *RdpClient) MouseWheel(delta float64) {
if !g.eventReady.Load() {
return
}
slog.Debug("MouseWheel", "delta", delta)
g.flushMouseMove()
// Convert notch count to RDP WHEEL_DELTA units (120 per notch).
const wheelDelta = 120
g.wheel.mu.Lock()
g.wheel.accum += delta * wheelDelta
if g.wheel.accum == 0 {
// Opposite deltas cancelled out; nothing to send.
g.wheel.mu.Unlock()
return
}
now := time.Now()
since := now.Sub(g.wheel.lastTx)
if since >= mouseCoalesceInterval {
g.sendWheelLocked(now)
g.wheel.mu.Unlock()
return
}
if g.wheel.timer == nil {
delay := mouseCoalesceInterval - since
g.wheel.timer = time.AfterFunc(delay, g.flushWheelTimer)
}
g.wheel.mu.Unlock()
}
// MouseHWheel sends a horizontal scroll event (trackpad two-finger horizontal
// pan or tilt-wheel). delta is the rotation amount in physical notches;
// positive values scroll right, negative scroll left. Shares the vertical
// axis's coalescing window and timer.
func (g *RdpClient) MouseHWheel(delta float64) {
if !g.eventReady.Load() {
return
}
g.flushMouseMove()
const wheelDelta = 120
g.wheel.mu.Lock()
g.wheel.haccum += delta * wheelDelta
if g.wheel.haccum == 0 {
g.wheel.mu.Unlock()
return
}
now := time.Now()
since := now.Sub(g.wheel.lastTx)
if since >= mouseCoalesceInterval {
g.sendWheelLocked(now)
g.wheel.mu.Unlock()
return
}
if g.wheel.timer == nil {
delay := mouseCoalesceInterval - since
g.wheel.timer = time.AfterFunc(delay, g.flushWheelTimer)
}
g.wheel.mu.Unlock()
}
// flushWheel sends any pending wheel event synchronously. Called before any
// non-wheel input event to preserve server-side ordering.
func (g *RdpClient) flushWheel() {
g.wheel.mu.Lock()
if g.wheel.timer != nil {
g.wheel.timer.Stop()
g.wheel.timer = nil
}
if g.wheel.accum != 0 {
g.sendWheelLocked(time.Now())
}
g.wheel.mu.Unlock()
}
// flushWheelTimer is the time.AfterFunc callback for wheel coalescing.
func (g *RdpClient) flushWheelTimer() {
g.wheel.mu.Lock()
g.wheel.timer = nil
if g.wheel.accum != 0 && g.eventReady.Load() {
g.sendWheelLocked(time.Now())
}
g.wheel.mu.Unlock()
}
// sendWheelLocked must be called with wheel.mu held.
// Modelled on FreeRDP's send_mouse_wheel in client/SDL/SDL2/sdl_touch.cpp.
func (g *RdpClient) sendWheelLocked(now time.Time) {
// Truncate the accumulated float to a whole WHEEL_DELTA integer; keep the
// fractional remainder so sub-notch trackpad movements aren't discarded.
iaccum := int(g.wheel.accum)
g.wheel.accum -= float64(iaccum)
haccum := int(g.wheel.haccum)
g.wheel.haccum -= float64(haccum)
g.wheel.lastTx = now
// sendAxis emits 0xFF-capped wheel events for one axis. The WheelRotation
// field is 9 bits. Bits 0–7 hold the unsigned magnitude (max 0xFF per
// event); bit 8 is the sign (PTRFLAGS_WHEEL_NEGATIVE). For negative values
// the receiver computes -(0x100 - bits[0:7]), so we must store the 9-bit
// two's-complement form, not the raw magnitude (same loop as FreeRDP).
sendAxis := func(baseFlags uint16, v int) {
negative := v < 0
if negative {
v = -v
}
if negative {
baseFlags |= uint16(pdu.PTRFLAGS_WHEEL_NEGATIVE)
}
for v > 0 {
cval := min(v, 0xFF)
v -= cval
if negative {
g.wheel.pdu.PointerFlags = (baseFlags & 0xFF00) | uint16(0x100-cval)
} else {
g.wheel.pdu.PointerFlags = baseFlags | uint16(cval)
}
g.pdu.SendInputEvents(pdu.INPUT_EVENT_MOUSE, g.wheel.pduBuf[:])
}
}
if iaccum != 0 {
sendAxis(uint16(pdu.PTRFLAGS_WHEEL), iaccum)
}
if haccum != 0 {
// 水平轴:正值 = 向右滚动(不设 NEGATIVE 位 = 远端向右)。
sendAxis(uint16(pdu.PTRFLAGS_HWHEEL), haccum)
}
if iaccum != 0 || haccum != 0 {
g.notifyGfxLocalInput()
}
}
func (g *RdpClient) MouseUp(button int, x, y int) {
if !g.eventReady.Load() {
return
}
slog.Debug("MouseUp", "x", x, "y", y, "button", button)
g.flushMouseMove()
g.flushWheel()
p := &pdu.PointerEvent{}
p.PointerFlags = mouseButtonFlag(button)
p.XPos = uint16(x)
p.YPos = uint16(y)
g.pdu.SendInputEvents(pdu.INPUT_EVENT_MOUSE, []pdu.InputEventsInterface{p})
g.notifyGfxLocalInput()
}
func (g *RdpClient) MouseDown(button int, x, y int) {
if !g.eventReady.Load() {
return
}
slog.Debug("MouseDown", "x", x, "y", y, "button", button)
g.flushMouseMove()
g.flushWheel()
p := &pdu.PointerEvent{}
p.PointerFlags = pdu.PTRFLAGS_DOWN | mouseButtonFlag(button)
p.XPos = uint16(x)
p.YPos = uint16(y)
g.pdu.SendInputEvents(pdu.INPUT_EVENT_MOUSE, []pdu.InputEventsInterface{p})
g.notifyGfxLocalInput()
}
// SetResolution requests a desktop resolution change via the MS-RDPEDISP
// Display Update Virtual Channel. The server will reshape the desktop to the
// given dimensions and send a fresh RDPGFX ResetGraphics command.
//
// width must be even and both width and height must be >= 200.
// This method is a no-op when the RDPEDISP channel has not been established
// (e.g. when the server does not support it).
func (g *RdpClient) SetResolution(width, height int) {
if g.dispHandler == nil {
slog.Warn("SetResolution: RDPEDISP channel not available")
return
}
w := uint32(width)
if w%2 != 0 {
w++
}
w = max(w, 200)
h := uint32(height)
h = max(h, 200)
g.dispHandler.SendMonitorLayout([]rdpedisp.Monitor{
{
Flags: rdpedisp.MonitorFlagPrimary,
Left: 0,
Top: 0,
Width: w,
Height: h,
PhysicalWidth: 0,
PhysicalHeight: 0,
Orientation: 0,
DesktopScaleFactor: 100,
DeviceScaleFactor: 100,
},
})
slog.Debug("SetResolution", "width", w, "height", h)
}
// SetQueueDepthHint controls the frame-rate and encoding quality reported to
// the server via the RDPGFX FRAME_ACKNOWLEDGE queueDepth field
// (MS-RDPEGFX 2.2.2.8).
//
// A higher value signals a larger client decode backlog, causing the server to
// slow down or reduce H.264/RFX encoding quality. 0 (default) means "report
// the real decode-queue length" — no artificial throttling.
// DebugSurfacePixel 诊断:读第一个 mapped surface 上 (x,y) 的 BGRA
func (g *RdpClient) DebugSurfacePixel(x, y int) (uint8, uint8, uint8, uint8, bool) {
return g.gfxHandler.DebugSurfacePixel(x, y)
}
// GfxDiagStats 返回图形管线实时诊断指标:累计解码帧数、解码队列深度、
// 帧间隔(毫秒)与单消息解码耗时 EMA(微秒)。见 GfxHandler.DiagStats。
func (g *RdpClient) GfxDiagStats() (frames, qdepth, fintvMs, decUs int64) {
return g.gfxHandler.DiagStats()
}
// CodecStats exposes cumulative surface-bitmap bytes per codec id for
// bandwidth diagnostics. Returns nil before a graphics session exists.
func (g *RdpClient) CodecStats() map[uint16]int64 {
if g.gfxHandler != nil {
return g.gfxHandler.CodecStats()
}
return nil
}
// Typical values: 0 = off, 10–50 = moderate throttle, 100+ = heavy throttle.
// Use 0xFFFFFFFF to pause new frames entirely (the stream resumes when hint is
// reduced or cleared).
func (g *RdpClient) SetQueueDepthHint(depth uint32) {
if g.gfxHandler != nil {
g.gfxHandler.SetQueueDepthHint(depth)
}
}
// SetAudioMode 设置声音重定向模式(mstsc 远程音频播放):
// "local"(本机播放,默认)| "none"(不播放:协商后丢弃,服务器静音)|
// "remote"(远端播放:不注册音频通道,服务器本机扬声器出声)。
// 必须在 Login 前调用。
func (g *RdpClient) SetAudioMode(mode string) {
g.audioMode = mode
}
// SetDriveRedirect 启用驱动器重定向(MS-RDPEFS):远端将看到一个只读的
// 重定向设备,内容由 SetFilesystem 桥接的异步文件系统提供。必须在 Login
// 前调用。
func (g *RdpClient) SetDriveRedirect(enabled bool) {
g.driveRedirect = enabled
}
// SetDriveLabel 设置重定向卷的卷标(远端 Explorer 显示名)。必须在 Login
// 前调用;空值取 "local"。
func (g *RdpClient) SetDriveLabel(label string) {
g.driveLabel = label
}
// SetDriveFilesystem 挂接异步文件系统桥。处理器在 Login(doLogin)内才
// 创建——这里先暂存字段,创建时补挂(同 SetGfxCacheStore 的时序教训)。
func (g *RdpClient) SetDriveFilesystem(fs rdpdr.Filesystem) {
g.driveFS = fs
}
// Rdpdr 返回驱动器重定向处理器(未启用时为 nil)。桥接层用它接收完成回调。
func (g *RdpClient) Rdpdr() *rdpdr.Handler { return g.rdpdrHandler }
// SetSessionColorDepth 请求会话颜色位数(16/24/32,其它值按 32 处理)。
// 仅影响传统位图管线;RDPGFX 会话表面恒为 32bpp。必须在 Login 前调用。
func (g *RdpClient) SetSessionColorDepth(bpp int) {
g.colorDepth = bpp
}
// SetPerformanceFlags 覆盖 Client Info PDU 的 performanceFlags(体验选项)。
// 禁用类位置位 = 关闭(PERF_DISABLE_WALLPAPER 等),0 = 视觉全开默认。
// 必须在 Login 前调用。
func (g *RdpClient) SetPerformanceFlags(flags uint32) {
g.perfFlags = flags
g.perfFlagsSet = true
}
// RequestKeyframe asks the server to send a fresh full-screen IDR keyframe via
// the SuppressOutput off→on toggle (SendForceRefresh). It is the same request
// the RDPGFX decoder issues internally when it stalls, exposed publicly so the
// frontend's black-screen watchdog can recover an initial all-black session —
// where the server sent its first IDR before the desktop finished painting
// (decoded as a black warm-up frame and dropped) and then went idle — without
// the cost of a full reconnect. Safe to call from the render loop goroutine.
func (g *RdpClient) RequestKeyframe() {
if g.closed.Load() {
return
}
if g.pdu != nil {
g.pdu.SendForceRefresh(uint16(g.width), uint16(g.height))
}
}
func (g *RdpClient) Reconnect(width, height int) error {
if g.closed.Load() {
return fmt.Errorf("client is closed")
}
g.reconnectMu.Lock()
defer g.reconnectMu.Unlock()
g.reconnecting.Store(true)
defer func() { g.reconnecting.Store(false) }()
slog.Debug("Reconnect", "width", width, "height", height)
g.closeTransport()
g.width = width
g.height = height
g.eventReady.Store(false)
const maxRetries = 3
for attempt := 1; attempt <= maxRetries; attempt++ {
// No delay on the first attempt: the transport was already closed above
// so the server has already started session teardown. Use exponential
// backoff (1s, 2s) only for retries after a failed login.
delay := time.Duration(0)
if attempt > 1 {
delay = time.Duration(1<<uint(attempt-2)) * time.Second
}
slog.Debug("Reconnect: waiting before attempt", "attempt", attempt, "delay", delay)
time.Sleep(delay)
err := g.Login(g.domain, g.user, g.password)
if err != nil {
slog.Warn("Reconnect: login failed", "attempt", attempt, "err", err)
if attempt < maxRetries {
g.closeTransport()
continue
}
return fmt.Errorf("[reconnect err] %v", err)
}
slog.Debug("Reconnect: succeeded", "attempt", attempt)
return nil
}
return fmt.Errorf("[reconnect failed after %d attempts]", maxRetries)
}
func (g *RdpClient) reregisterCallbacks() {
if g.onErrorFn != nil {
g.OnError(g.onErrorFn)
}
if g.onCloseFn != nil {
g.OnClose(g.onCloseFn)
}
if g.onSuccessFn != nil {
g.OnSuccess(g.onSuccessFn)
}
if g.onReadyFn != nil {
g.OnReady(g.onReadyFn)
}
if g.onBitmapPaintFn != nil {
g.OnBitmap(g.onBitmapPaintFn)
}
if g.onPointerHideFn != nil {
g.OnPointerHide(g.onPointerHideFn)
}
if g.onPointerCachedFn != nil {
g.OnPointerCached(g.onPointerCachedFn)
}
if g.onPointerDefaultFn != nil {
g.OnPointerDefault(g.onPointerDefaultFn)
}
if g.onPointerUpdateFn != nil {
g.OnPointerUpdate(g.onPointerUpdateFn)
}
if g.onAudioResetFn != nil {
g.OnAudioReset(g.onAudioResetFn)
}
if g.onDecoderBrokenFn != nil {
g.OnDecoderBroken(g.onDecoderBrokenFn)
}
}
// closeTransport closes the underlying transport and stops any active GFX handler.
func (g *RdpClient) closeTransport() {
if g.gfxHandler != nil {
g.gfxHandler.Close()
g.gfxHandler = nil
}
if g.tpkt != nil {
g.tpkt.Close()
}
}
func (g *RdpClient) Close() {
slog.Debug("Close()")
g.closed.Store(true)
g.closeTransport()
}