Files
rdplib/plugin/cliprdr/handler.go
T

679 lines
22 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 cliprdr handler.go implements a cross-platform CLIPRDR
// (Clipboard Virtual Channel Extension, MS-RDPECLIP) handler for
// bidirectional text clipboard sharing between RDP client and server.
//
// Only text formats (CF_UNICODETEXT / CF_TEXT) are supported.
package cliprdr
import (
"bytes"
"encoding/binary"
"log/slog"
"strings"
"sync"
"unicode/utf16"
"git.zeroonesoft.cn/golib/rdplib/core"
)
// CliprdrHandler implements plugin.ChannelTransport for the "cliprdr"
// static virtual channel. It uses callbacks for clipboard integration
// so that any UI toolkit can wire in its own clipboard access.
type CliprdrHandler struct {
channelSender core.ChannelSender
useLongFormatNames bool
// serverCapsReceived is set when the server's CB_CLIP_CAPS PDU has been
// processed. Per MS-RDPECLIP §1.3.2.1 the server sends CB_CLIP_CAPS
// before CB_MONITOR_READY, so FORMAT_LIST should only be sent once both
// have arrived.
serverCapsReceived bool
// monitorReady is set when CB_MONITOR_READY has been received.
monitorReady bool
// onRemoteClipboardChanged is called with the text when the server's
// clipboard content arrives.
onRemoteClipboardChanged func(text string)
// getLocalClipboardText is called to retrieve the current local
// clipboard text when the server requests it.
getLocalClipboardText func() string
// onRemoteClipboardImage is called with PNG-encoded bytes when the
// server's clipboard image (PNG / CF_DIB) arrives.
onRemoteClipboardImage func(png []byte)
// getLocalClipboardImage is called to retrieve the current local
// clipboard image (PNG-encoded) when the server requests the "PNG"
// registered format. Returns nil when no image is available.
getLocalClipboardImage func() []byte
// onRemoteClipboardHTML is called with the HTML content when the server's
// clipboard "HTML Format" data arrives (fragment already extracted from
// the CF_HTML envelope).
onRemoteClipboardHTML func(html string)
// getLocalClipboardHTML is called to retrieve the current local clipboard
// HTML (raw fragment/document, without the CF_HTML envelope) when the
// server requests the "HTML Format" registered format.
// Returns "" when no HTML is available.
getLocalClipboardHTML func() string
// remoteHTMLFormatId 记录服务器 Format List 中 "HTML Format" 的格式 ID,
// 用于识别 Format Data Response(服务端注册格式的 ID 由服务端分配)。
remoteHTMLFormatId uint32
// serverDropEffectId 记录服务器 Format List 中 "Preferred DropEffect"
// 的服务器本地 ID(注册格式 ID 各端自定,按名称对齐)。
serverDropEffectId uint32
// lastRequestedFormat 记录我方最后一次 Format Data Request 的格式,
// 以正确解读 Format Data Response(PNG 原样、DIB 转码、文本按 UTF-16)。
lastRequestedFormat uint32
// suppressNextLocalChange prevents an echo loop:
// server→client clipboard update triggers a local clipboard change
// event which would otherwise be sent back to the server.
suppressNextLocalChange bool
// --- File clipboard (CF_HDROP + FileContentsRequest/Response) -----------
//
// Browser clipboards cannot hold files, so files are "staged" explicitly
// by the UI (file picker / drag-drop): staged files are advertised as
// CF_HDROP in Format List; the server fetches their bytes through
// FileContentsRequest. Files copied on the server arrive as a CF_HDROP
// path list, and the UI pulls bytes per file via RequestRemoteFile.
mu sync.Mutex
// serverFileClip is set when the server advertised CB_STREAM_FILECLIP_ENABLED.
// CF_HDROP is only advertised/requested when both sides support file streaming.
serverFileClip bool
// localFiles are files staged by the local UI, advertised as CF_HDROP.
localFiles []LocalFile
// remoteFiles are the paths from the server's last file list payload.
remoteFiles []string
// remoteFileSizes are the sizes from the server's FileGroupDescriptorW
// (-1 when unknown; only valid alongside remoteFiles from descriptors).
remoteFileSizes []int64
// streams tracks in-flight outgoing FileContentsRequest transfers
// (remote → local download) keyed by our StreamId.
streams map[uint32]*fileStream
// nextStreamID is the StreamId counter for outgoing FileContentsRequests.
nextStreamID uint32
onRemoteFiles func(names []string)
onFileData func(index int, name string, data []byte)
onFileProgress func(index int, received, total int64)
}
// NewHandler creates a CliprdrHandler.
//
// - onRemote is called when the server clipboard text is received.
// - getLocal is called to retrieve the current local clipboard text.
//
// Either callback may be nil. Image callbacks can be attached later via
// SetImageCallbacks.
func NewHandler(onRemote func(text string), getLocal func() string) *CliprdrHandler {
return &CliprdrHandler{
onRemoteClipboardChanged: onRemote,
getLocalClipboardText: getLocal,
streams: map[uint32]*fileStream{},
}
}
// SetImageCallbacks attaches image clipboard callbacks (both optional).
//
// - onRemoteImage receives PNG-encoded bytes of the server clipboard image.
// - getLocalImage returns PNG-encoded bytes of the local clipboard image
// (nil when unavailable), used to answer server requests for "PNG".
func (h *CliprdrHandler) SetImageCallbacks(onRemoteImage func(png []byte), getLocalImage func() []byte) {
h.onRemoteClipboardImage = onRemoteImage
h.getLocalClipboardImage = getLocalImage
}
// SetHTMLCallbacks attaches HTML clipboard callbacks (both optional).
//
// - onRemoteHTML receives the HTML content when the server's clipboard
// "HTML Format" data arrives (fragment extracted from the CF_HTML envelope).
// - getLocalHTML returns the local clipboard HTML (raw HTML, no CF_HTML
// envelope; empty when unavailable), used to answer server requests for
// the "HTML Format" registered format.
func (h *CliprdrHandler) SetHTMLCallbacks(onRemoteHTML func(html string), getLocalHTML func() string) {
h.onRemoteClipboardHTML = onRemoteHTML
h.getLocalClipboardHTML = getLocalHTML
}
// --- plugin.ChannelTransport interface ------------------------------------
func (h *CliprdrHandler) GetType() (string, uint32) {
return ChannelName, ChannelOption
}
func (h *CliprdrHandler) Sender(f core.ChannelSender) {
h.channelSender = f
}
// Process handles a reassembled CLIPRDR PDU from the server.
func (h *CliprdrHandler) Process(s []byte) {
if len(s) < 8 {
return
}
r := bytes.NewReader(s)
msgType, _ := core.ReadUint16LE(r)
msgFlags, _ := core.ReadUint16LE(r)
dataLen, _ := core.ReadUInt32LE(r)
body := make([]byte, dataLen)
if dataLen > 0 {
n, _ := r.Read(body)
body = body[:n]
}
slog.Debug("cliprdr recv", "msgType", msgType, "msgFlags", msgFlags, "dataLen", dataLen)
switch msgType {
case CB_CLIP_CAPS:
h.processClipCaps(body)
case CB_MONITOR_READY:
h.processMonitorReady()
case CB_FORMAT_LIST:
h.processFormatList(body, msgFlags)
case CB_FORMAT_LIST_RESPONSE:
h.processFormatListResponse(msgFlags)
case CB_FORMAT_DATA_REQUEST:
h.processFormatDataRequest(body)
case CB_FORMAT_DATA_RESPONSE:
h.processFormatDataResponse(body, msgFlags)
case CB_FILECONTENTS_REQUEST:
h.processFileContentsRequest(body)
case CB_FILECONTENTS_RESPONSE:
h.processFileContentsResponse(body, msgFlags)
case CB_LOCK_CLIPDATA, CB_UNLOCK_CLIPDATA:
// ignored
default:
slog.Debug("cliprdr: unhandled msgType", "msgType", msgType)
}
}
// --- Clipboard Capabilities (MS-RDPECLIP 2.2.2.1) -------------------------
func (h *CliprdrHandler) processClipCaps(body []byte) {
if len(body) < 4 {
return
}
cCapSets := binary.LittleEndian.Uint16(body[0:2])
// pad1 at [2:4]
offset := 4
for i := 0; i < int(cCapSets); i++ {
if offset+4 > len(body) {
break
}
capType := binary.LittleEndian.Uint16(body[offset:])
capLen := binary.LittleEndian.Uint16(body[offset+2:])
if capType == CB_CAPSTYPE_GENERAL && capLen >= 12 {
generalFlags := binary.LittleEndian.Uint32(body[offset+8:])
h.useLongFormatNames = generalFlags&CB_USE_LONG_FORMAT_NAMES != 0
h.mu.Lock()
h.serverFileClip = generalFlags&CB_STREAM_FILECLIP_ENABLED != 0
fileClip := h.serverFileClip
h.mu.Unlock()
slog.Debug("cliprdr: server caps", "generalFlags", generalFlags,
"longNames", h.useLongFormatNames, "fileClip", fileClip)
}
offset += int(capLen)
}
h.serverCapsReceived = true
// If CB_MONITOR_READY already arrived before CB_CLIP_CAPS (non-standard
// ordering), send the FORMAT_LIST now that we have correct capabilities.
if h.monitorReady {
h.sendFormatList()
}
}
func (h *CliprdrHandler) sendClipCaps() {
b := &bytes.Buffer{}
// General capability set: type(2) + length(2) + version(4) + flags(4)
binary.Write(b, binary.LittleEndian, uint16(CB_CAPSTYPE_GENERAL))
binary.Write(b, binary.LittleEndian, uint16(12))
binary.Write(b, binary.LittleEndian, uint32(CB_CAPS_VERSION_2))
// CB_STREAM_FILECLIP_ENABLED + CB_FILECLIP_NO_FILE_PATHS:声明支持
// FileGroupDescriptorW/FileContents 流式文件传输(mstsc 同款),
// 服务器未开时其会忽略这些位。
binary.Write(b, binary.LittleEndian,
uint32(CB_USE_LONG_FORMAT_NAMES|CB_STREAM_FILECLIP_ENABLED|CB_FILECLIP_NO_FILE_PATHS))
// cCapabilitySets(2) + pad1(2) + capabilitySet
body := &bytes.Buffer{}
binary.Write(body, binary.LittleEndian, uint16(1))
binary.Write(body, binary.LittleEndian, uint16(0))
body.Write(b.Bytes())
h.sendPDU(CB_CLIP_CAPS, 0, body.Bytes())
}
// --- Monitor Ready (MS-RDPECLIP 2.2.2.2) ----------------------------------
func (h *CliprdrHandler) processMonitorReady() {
slog.Debug("cliprdr: server Monitor Ready")
h.monitorReady = true
h.sendClipCaps()
// Per MS-RDPECLIP §1.3.2.1 the server sends CB_CLIP_CAPS before
// CB_MONITOR_READY. Only send FORMAT_LIST after server caps are known
// so useLongFormatNames is set correctly. If CB_CLIP_CAPS hasn't been
// received yet (non-standard ordering), defer until processClipCaps fires.
if h.serverCapsReceived {
h.sendFormatList()
}
}
// --- Format List (MS-RDPECLIP 2.2.3.1) ------------------------------------
func (h *CliprdrHandler) sendFormatList() {
h.mu.Lock()
staged := len(h.localFiles) > 0
fileClip := h.serverFileClip
h.mu.Unlock()
// 仅当双方都声明 CB_STREAM_FILECLIP_ENABLED 且本地已暂存文件时
// 才广告 CF_HDROP,避免服务器把普通粘贴当文件处理。
advertiseFiles := staged && fileClip
b := &bytes.Buffer{}
if h.useLongFormatNames {
// Long Format Name: formatId(4) + wszFormatName(null-terminated UTF-16LE)
writeFormat := func(id uint32, name string) {
binary.Write(b, binary.LittleEndian, id)
b.Write(encodeUTF16LE(name))
b.Write([]byte{0, 0}) // null terminator
}
writeFormat(CF_UNICODETEXT, "") // 空名 = 标准格式
writeFormat(CF_PNG, FormatNamePNG) // 注册格式 "PNG"
writeFormat(CF_HTML_FORMAT_ID, FormatNameHTML) // 注册格式 "HTML Format"
if advertiseFiles {
// 文件传输三件套(mstsc 同款,名称必须一致):
// 描述符列表 + 流式内容 + 复制语义
writeFormat(CF_FILE_GROUP_DESCRIPTORW, FormatNameFileGroupDescriptorW)
writeFormat(CF_FILE_CONTENTS, FormatNameFileContents)
writeFormat(CF_DROP_EFFECT, FormatNameDropEffect)
}
} else {
// Short Format Name: formatId(4) + formatName[32]
writeShort := func(id uint32, name string) {
binary.Write(b, binary.LittleEndian, id)
nameBuf := make([]byte, 32)
copy(nameBuf, name)
b.Write(nameBuf)
}
writeShort(CF_UNICODETEXT, "")
writeShort(CF_PNG, FormatNamePNG)
writeShort(CF_HTML_FORMAT_ID, FormatNameHTML)
if advertiseFiles {
writeShort(CF_FILE_GROUP_DESCRIPTORW, FormatNameFileGroupDescriptorW)
writeShort(CF_FILE_CONTENTS, FormatNameFileContents)
writeShort(CF_DROP_EFFECT, FormatNameDropEffect)
}
}
h.sendPDU(CB_FORMAT_LIST, 0, b.Bytes())
}
func (h *CliprdrHandler) processFormatList(body []byte, msgFlags uint16) {
formats := h.parseFormatList(body, msgFlags)
slog.Debug("cliprdr: server Format List", "formats", formats)
// 记录服务器侧 "Preferred DropEffect" 的本地 ID(注册格式 ID 各端自定)
for _, f := range formats {
if strings.EqualFold(f.FormatName, FormatNameDropEffect) {
h.mu.Lock()
h.serverDropEffectId = f.FormatId
h.mu.Unlock()
}
}
// Always respond OK
h.sendPDU(CB_FORMAT_LIST_RESPONSE, CB_RESPONSE_OK, nil)
// 请求优先级:文件描述符(远端复制了文件)> CF_HDROP > PNG > CF_DIB >
// HTML > 文本。资源管理器复制文件时通常还附带文件名文本,文件列表
// 必须最先识别。
for _, f := range formats {
if strings.EqualFold(f.FormatName, FormatNameFileGroupDescriptorW) {
h.mu.Lock()
fileClip := h.serverFileClip
h.mu.Unlock()
if fileClip {
slog.Debug("cliprdr: server offers files (FileGroupDescriptorW)")
h.lastRequestedFormat = CF_FILE_GROUP_DESCRIPTORW
h.sendFormatDataRequest(f.FormatId)
return
}
}
}
for _, f := range formats {
if f.FormatId == uint32(CF_HDROP) || strings.EqualFold(f.FormatName, FormatNameHDrop) {
h.mu.Lock()
fileClip := h.serverFileClip
h.mu.Unlock()
if fileClip {
slog.Debug("cliprdr: server offers files (CF_HDROP)")
h.lastRequestedFormat = CF_HDROP
h.sendFormatDataRequest(CF_HDROP)
return
}
}
}
for _, f := range formats {
if f.FormatId == CF_PNG || strings.EqualFold(f.FormatName, FormatNamePNG) ||
strings.EqualFold(f.FormatName, "image/png") {
h.lastRequestedFormat = f.FormatId
h.sendFormatDataRequest(f.FormatId)
return
}
}
for _, f := range formats {
if f.FormatId == CF_DIB {
h.lastRequestedFormat = CF_DIB
h.sendFormatDataRequest(CF_DIB)
return
}
}
// HTML Format:远端复制的富文本(浏览器/Office),保格式优于纯文本
for _, f := range formats {
if f.FormatId == CF_HTML_FORMAT_ID || strings.EqualFold(f.FormatName, FormatNameHTML) {
h.remoteHTMLFormatId = f.FormatId
h.lastRequestedFormat = f.FormatId
h.sendFormatDataRequest(f.FormatId)
return
}
}
for _, f := range formats {
if f.FormatId == CF_UNICODETEXT {
h.lastRequestedFormat = CF_UNICODETEXT
h.sendFormatDataRequest(CF_UNICODETEXT)
return
}
}
for _, f := range formats {
if f.FormatId == CF_TEXT {
h.lastRequestedFormat = CF_TEXT
h.sendFormatDataRequest(CF_TEXT)
return
}
}
}
func (h *CliprdrHandler) parseFormatList(body []byte, msgFlags uint16) []CliprdrFormat {
var formats []CliprdrFormat
if h.useLongFormatNames && (msgFlags&CB_ASCII_NAMES == 0) {
// Long Format Names (MS-RDPECLIP 2.2.3.1.1.1)
offset := 0
for offset+4 <= len(body) {
fmtId := binary.LittleEndian.Uint32(body[offset:])
offset += 4
// Read null-terminated UTF-16LE string
nameEnd := offset
for nameEnd+1 < len(body) {
if body[nameEnd] == 0 && body[nameEnd+1] == 0 {
break
}
nameEnd += 2
}
name := decodeUTF16LE(body[offset:nameEnd])
offset = nameEnd + 2
formats = append(formats, CliprdrFormat{fmtId, name})
}
} else {
// Short Format Names (MS-RDPECLIP 2.2.3.1.1.2)
offset := 0
for offset+36 <= len(body) {
fmtId := binary.LittleEndian.Uint32(body[offset:])
nameBytes := body[offset+4 : offset+36]
var name string
if msgFlags&CB_ASCII_NAMES != 0 {
name = strings.TrimRight(string(nameBytes), "\x00")
} else {
name = decodeUTF16LE(nameBytes)
name = strings.TrimRight(name, "\x00")
}
formats = append(formats, CliprdrFormat{fmtId, name})
offset += 36
}
}
return formats
}
func (h *CliprdrHandler) processFormatListResponse(msgFlags uint16) {
if msgFlags&CB_RESPONSE_OK != 0 {
slog.Debug("cliprdr: Format List Response OK")
} else {
slog.Warn("cliprdr: Format List Response FAIL")
}
}
// --- Format Data Request / Response (MS-RDPECLIP 2.2.5) --------------------
func (h *CliprdrHandler) sendFormatDataRequest(formatId uint32) {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, formatId)
h.sendPDU(CB_FORMAT_DATA_REQUEST, 0, b)
slog.Debug("cliprdr: sent Format Data Request", "formatId", formatId)
}
func (h *CliprdrHandler) processFormatDataRequest(body []byte) {
if len(body) < 4 {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
requestedFormat := binary.LittleEndian.Uint32(body[0:4])
slog.Debug("cliprdr: server requests format", "formatId", requestedFormat)
switch requestedFormat {
case CF_PNG:
var png []byte
if h.getLocalClipboardImage != nil {
png = h.getLocalClipboardImage()
}
if len(png) == 0 {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, png)
case CF_HTML_FORMAT_ID:
// 本地 HTML(无 CF_HTML 信封)→ 包装成 "HTML Format" 字节流
html := ""
if h.getLocalClipboardHTML != nil {
html = h.getLocalClipboardHTML()
}
if html == "" {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, EncodeCFHTML(html))
case CF_FILE_GROUP_DESCRIPTORW:
// 远端粘贴文件:回 FILE_GROUP_DESCRIPTORW(名称+大小+属性),
// 字节内容由服务器随后以 CB_FILECONTENTS_REQUEST 按 lIndex 拉取
h.mu.Lock()
files := h.localFiles
h.mu.Unlock()
if len(files) == 0 {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, BuildFileGroupDescriptorW(files))
case CF_FILE_CONTENTS:
// FileContents 的具体字节一律走 CB_FILECONTENTS_REQUEST(含流式
// 偏移/长度),对整格式 Format Data Request 只能回答不可流式的空体
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
case CF_DROP_EFFECT:
// 有暂存文件时回答 DROPEFFECT_COPY(否则 FAIL)。
// 该查询决定资源管理器右键菜单"粘贴"是否可用。
h.mu.Lock()
hasFiles := len(h.localFiles) > 0
h.mu.Unlock()
if !hasFiles {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, buildDropEffectReply())
case CF_UNICODETEXT:
text := ""
if h.getLocalClipboardText != nil {
text = h.getLocalClipboardText()
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, encodeUTF16LE(text+"\x00"))
case CF_TEXT:
text := ""
if h.getLocalClipboardText != nil {
text = h.getLocalClipboardText()
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, []byte(text+"\x00"))
default:
// 注册格式 ID 各端自定:服务器会用它本地的 "Preferred DropEffect"
// ID 查询(不一定先通告)。该查询决定资源管理器"粘贴"是否可用。
h.mu.Lock()
serverDropEffect := h.serverDropEffectId
hasFiles := len(h.localFiles) > 0
h.mu.Unlock()
// DropEffect 应答:精确命中服务器 ID,或未知注册格式 ID(≥0xC000)
// 且有暂存文件时按 DropEffect 猜测应答
if (serverDropEffect != 0 && requestedFormat == serverDropEffect) ||
(requestedFormat >= 0xC000 && hasFiles) {
if !hasFiles {
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
return
}
slog.Debug("cliprdr: replying DropEffect", "formatId", requestedFormat)
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, buildDropEffectReply())
return
}
h.sendPDU(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, nil)
}
}
func (h *CliprdrHandler) processFormatDataResponse(body []byte, msgFlags uint16) {
if msgFlags&CB_RESPONSE_OK == 0 {
slog.Warn("cliprdr: Format Data Response FAIL")
return
}
// 按我方请求的格式解读响应
switch {
case h.lastRequestedFormat == CF_FILE_GROUP_DESCRIPTORW:
names, sizes, err := ParseFileGroupDescriptorW(body)
if err != nil {
slog.Warn("cliprdr: FileGroupDescriptorW parse failed", "err", err, "len", len(body))
return
}
h.mu.Lock()
h.remoteFiles = names
h.remoteFileSizes = sizes
h.mu.Unlock()
if h.onRemoteFiles != nil {
h.onRemoteFiles(names)
}
case h.lastRequestedFormat == CF_HDROP:
names, err := ParseDropfiles(body)
if err != nil {
slog.Warn("cliprdr: CF_HDROP parse failed", "err", err, "len", len(body))
return
}
h.mu.Lock()
h.remoteFiles = names
h.mu.Unlock()
if h.onRemoteFiles != nil {
h.onRemoteFiles(names)
}
case h.lastRequestedFormat != 0 && h.lastRequestedFormat == h.remoteHTMLFormatId:
// "HTML Format"(服务端注册格式的 ID 与请求时记录的一致)
html := DecodeCFHTML(body)
if html == "" {
return
}
if h.onRemoteClipboardHTML != nil {
h.suppressNextLocalChange = true
h.onRemoteClipboardHTML(html)
}
case h.lastRequestedFormat == CF_PNG:
if len(body) == 0 {
return
}
if h.onRemoteClipboardImage != nil {
h.suppressNextLocalChange = true
png := make([]byte, len(body))
copy(png, body)
h.onRemoteClipboardImage(png)
}
case h.lastRequestedFormat == CF_DIB || h.lastRequestedFormat == CF_DIBV5:
png, err := DIBToPNG(body)
if err != nil {
slog.Warn("cliprdr: DIB→PNG conversion failed", "err", err)
return
}
if h.onRemoteClipboardImage != nil {
h.suppressNextLocalChange = true
h.onRemoteClipboardImage(png)
}
default:
// CF_UNICODETEXT / CF_TEXT → UTF-16LE 文本
text := decodeUTF16LE(body)
text = strings.TrimRight(text, "\x00")
if text != "" && h.onRemoteClipboardChanged != nil {
slog.Debug("cliprdr: received text", "len", len(text))
h.suppressNextLocalChange = true
h.onRemoteClipboardChanged(text)
}
}
}
// --- Public API for local clipboard changes --------------------------------
// OnLocalClipboardChanged notifies the server that the local clipboard
// content has changed. Call this from the UI when the system clipboard
// changes (e.g. via polling or a platform clipboard-change signal).
func (h *CliprdrHandler) OnLocalClipboardChanged() {
if h.suppressNextLocalChange {
h.suppressNextLocalChange = false
return
}
if h.channelSender != nil {
h.sendFormatList()
slog.Debug("cliprdr: local clipboard changed, sent Format List")
}
}
// --- Send helpers ----------------------------------------------------------
func (h *CliprdrHandler) sendPDU(msgType, msgFlags uint16, body []byte) {
if h.channelSender == nil {
return
}
sendClipPDU(h.channelSender, msgType, msgFlags, body)
}
// --- UTF-16LE helpers ------------------------------------------------------
func decodeUTF16LE(b []byte) string {
if len(b) < 2 {
return ""
}
// Trim to even length
if len(b)%2 != 0 {
b = b[:len(b)-1]
}
u16 := make([]uint16, len(b)/2)
for i := range u16 {
u16[i] = binary.LittleEndian.Uint16(b[i*2:])
}
return string(utf16.Decode(u16))
}
func encodeUTF16LE(s string) []byte {
runes := []rune(s)
u16 := utf16.Encode(runes)
b := make([]byte, len(u16)*2)
for i, v := range u16 {
binary.LittleEndian.PutUint16(b[i*2:], v)
}
return b
}