679 lines
22 KiB
Go
679 lines
22 KiB
Go
// 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
|
||
}
|