init: 自 zomaintain/backend/rdplib 平移独立成库; module path 从上游 github.com/nakagami/grdp 改为 git.zeroonesoft.cn/golib/rdplib

This commit is contained in:
w11
2026-09-19 18:55:43 +08:00
commit 1096fbce9d
94 changed files with 36790 additions and 0 deletions
+678
View File
@@ -0,0 +1,678 @@
// 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
}