"复制即监控,粘贴即暴露。"
------ 一位不愿透露姓名的剪贴板监视器开发者
📑 目录
- [🧠 为什么要做这个?](#🧠 为什么要做这个? "#-%E4%B8%BA%E4%BB%80%E4%B9%88%E8%A6%81%E5%81%9A%E8%BF%99%E4%B8%AA")
- [🛠️ 技术原理:Windows 剪贴板监听机制](#🛠️ 技术原理:Windows 剪贴板监听机制 "#%EF%B8%8F-%E6%8A%80%E6%9C%AF%E5%8E%9F%E7%90%86windows-%E5%89%AA%E8%B4%B4%E6%9D%BF%E7%9B%91%E5%90%AC%E6%9C%BA%E5%88%B6")
- [⚠️ 注意事项](#⚠️ 注意事项 "#%EF%B8%8F-%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9")
- [🧩 核心挑战:剪贴板数据格式](#🧩 核心挑战:剪贴板数据格式 "#-%E6%A0%B8%E5%BF%83%E6%8C%91%E6%88%98%E5%89%AA%E8%B4%B4%E6%9D%BF%E6%95%B0%E6%8D%AE%E6%A0%BC%E5%BC%8F")
- [🧪 代码实现:Go + Windows API](#🧪 代码实现:Go + Windows API "#-%E4%BB%A3%E7%A0%81%E5%AE%9E%E7%8E%B0go--windows-api")
- [▶️ 如何运行?](#▶️ 如何运行? "#%EF%B8%8F-%E5%A6%82%E4%BD%95%E8%BF%90%E8%A1%8C")
- [🔒 安全提醒](#🔒 安全提醒 "#%EF%B8%8F-%E5%AE%89%E5%85%A8%E6%8F%90%E9%86%92")
- [🎉 结语](#🎉 结语 "#-%E7%BB%93%E8%AF%AD")
你有没有想过,当你按下 Ctrl+C 的那一瞬间,你的剪贴板内容其实可以被"悄悄"记录下来?别慌,这不是什么黑客教程,而是一次用 Go 语言 和 Windows API 玩转系统底层的趣味实验!
今天,我们就来手把手实现一个 剪贴板监视器 ------ 它会在后台默默监听你的复制行为,并把内容打印出来(当然,也可以做更多事情,比如自动保存、翻译、甚至发到邮箱里 😏)。
🧠 为什么要做这个?
- 学习目的:理解 Windows 消息机制、剪贴板架构、Unicode/ANSI 编码处理。
- 实用场景:自动化工具、开发调试、剪贴板历史记录等。
- 装 X 需求:在同事面前演示:"看,我一复制,程序就知道我抄了啥!"
🛠️ 技术原理:Windows 剪贴板监听机制
Windows 提供了一套经典的"剪贴板查看器链"(Clipboard Viewer Chain)机制:
- 你可以把自己的窗口注册为"剪贴板查看器"。
- 一旦剪贴板内容发生变化,系统会向链中的每个窗口发送 WM_DRAWCLIPBOARD 消息。
- 你收到消息后,就可以打开剪贴板、读取内容、然后优雅地关掉它。
听起来是不是有点像"订阅-发布"模式?没错!只不过这是 1980 年代的 Windows 版 Pub/Sub。
⚠️ 注意事项
微软官方已不推荐使用 SetClipboardViewer(推荐用 AddClipboardFormatListener),但为了兼容性和教学目的,我们仍用经典方式。
🧩 核心挑战:剪贴板数据格式
剪贴板可不是只存文本!它支持多种格式:
| 格式常量 | 含义 |
|---|---|
| CF_TEXT | ANSI 文本(通常是 GBK 编码) |
| CF_UNICODETEXT | UTF-16 文本(现代应用主流) |
| CF_HDROP | 文件拖放(比如从资源管理器复制文件) |
| 其他自定义格式 | 比如 Word 的富文本、图片等 |
所以我们的程序必须:
- 枚举所有格式
- 优先读取 CF_UNICODETEXT
- 降级处理 CF_TEXT(并转码为 UTF-8)
- 识别文件拖放
- 对未知格式友好提示
🧪 代码实现:Go + Windows API
我们使用 golang.org/x/sys/windows 调用 Windows API,并配合 unsafe 和 reflect 直接操作内存(别怕,有安全兜底)。
下面就是完整源码(已加详细注释):
go
package main
import (
"bytes"
"fmt"
"io"
"reflect"
"syscall"
"unicode/utf16"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// ===== Win32 API Constants =====
const (
WM_CREATE = 0x0001
WM_DESTROY = 0x0002
WM_DRAWCLIPBOARD = 0x0308
WM_CHANGECBCHAIN = 0x030D
CF_TEXT = 1
CF_BITMAP = 2
CF_UNICODETEXT = 13
CF_HDROP = 15
)
// ===== Win32 Structures =====
type WNDCLASSEX struct {
Size uint32
Style uint32
WndProc uintptr
ClsExtra int32
WndExtra int32
Instance syscall.Handle
Icon syscall.Handle
Cursor syscall.Handle
Background uintptr
MenuName *uint16
ClassName *uint16
IconSm syscall.Handle
}
type MSG struct {
HWnd uintptr
Message uint32
WParam uintptr
LParam uintptr
DwTime uint32
PtX int32
PtY int32
}
// ===== Win32 API Procs =====
var (
user32 = windows.NewLazySystemDLL("user32.dll")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
shell32 = windows.NewLazySystemDLL("shell32.dll")
procRegisterClassEx = user32.NewProc("RegisterClassExW")
procCreateWindowEx = user32.NewProc("CreateWindowExW")
procDefWindowProc = user32.NewProc("DefWindowProcW")
procOpenClipboard = user32.NewProc("OpenClipboard")
procCloseClipboard = user32.NewProc("CloseClipboard")
procEnumClipboardFormats = user32.NewProc("EnumClipboardFormats")
procGetClipboardData = user32.NewProc("GetClipboardData")
procSetClipboardViewer = user32.NewProc("SetClipboardViewer")
procGetMessage = user32.NewProc("GetMessageW")
procTranslateMessage = user32.NewProc("TranslateMessage")
procDispatchMessage = user32.NewProc("DispatchMessageW")
procShowWindow = user32.NewProc("ShowWindow")
procGlobalLock = kernel32.NewProc("GlobalLock")
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
procDragQueryFile = shell32.NewProc("DragQueryFileW")
)
// ===== 窗口过程函数 =====
func windowProc(hwnd syscall.Handle, msg uint32, wParam uintptr, lParam uintptr) uintptr {
switch msg {
case WM_CREATE:
_, _, _ = procSetClipboardViewer.Call(uintptr(hwnd))
case WM_DRAWCLIPBOARD:
if ret, _, _ := procOpenClipboard.Call(uintptr(hwnd)); ret == 0 {
fmt.Println("❌ 无法打开剪切板")
break
}
var format uint32 = 0
var finalText string
for {
r1, _, _ := procEnumClipboardFormats.Call(uintptr(format))
format = uint32(r1)
if format == 0 {
break
}
hMem, _, _ := procGetClipboardData.Call(uintptr(format))
if hMem == 0 {
continue
}
switch format {
case CF_UNICODETEXT:
text, err := getUnicodeText(hMem)
if err == nil {
finalText = text
goto done
}
case CF_TEXT:
text, err := getAnsiText(hMem)
if err == nil && finalText == "" {
finalText = text
}
case CF_HDROP:
finalText = ""
files, err := getHDropFiles(hMem)
if err != nil {
fmt.Println("❌ 文件列表: 读取文件失败:", err)
} else {
fmt.Printf("📎 拖放的文件 (%d):\n", len(files))
for _, f := range files {
fmt.Println(" -", f)
}
}
default:
finalText = ""
fmt.Printf("📎 %s\n", getClipboardFormatName(format))
}
}
done:
if finalText != "" {
fmt.Printf("📝 文本内容:\n%s\n", finalText)
}
procCloseClipboard.Call()
case WM_CHANGECBCHAIN:
fmt.Println("⛓️ 剪切板查看器链已更改")
default:
ret, _, _ := procDefWindowProc.Call(uintptr(hwnd), uintptr(msg), wParam, lParam)
return ret
}
return 0
}
// ===== 辅助函数 =====
func getAnsiText(hMem uintptr) (string, error) {
lpLock, _, _ := procGlobalLock.Call(hMem)
if lpLock == 0 {
return "", fmt.Errorf("无法锁定内存")
}
defer procGlobalUnlock.Call(hMem)
var size int
for {
b := *(*byte)(unsafe.Pointer(lpLock + uintptr(size)))
if b == 0 {
break
}
size++
}
data := make([]byte, size)
for i := 0; i < size; i++ {
data[i] = *(*byte)(unsafe.Pointer(lpLock + uintptr(i)))
}
decoder := simplifiedchinese.GBK.NewDecoder()
reader := transform.NewReader(bytes.NewReader(data), decoder)
utf8Bytes, err := io.ReadAll(reader)
if err != nil {
return "", err
}
return string(utf8Bytes), nil
}
func getUnicodeText(hMem uintptr) (string, error) {
lpLock, _, _ := procGlobalLock.Call(hMem)
if lpLock == 0 {
return "", fmt.Errorf("无法锁定内存")
}
defer procGlobalUnlock.Call(hMem)
var length int
for {
w := *(*uint16)(unsafe.Pointer(lpLock + uintptr(length*2)))
if w == 0 {
break
}
length++
}
sliceHeader := reflect.SliceHeader{
Data: lpLock,
Len: length,
Cap: length,
}
utf16Data := *(*[]uint16)(unsafe.Pointer(&sliceHeader))
runes := utf16.Decode(utf16Data)
return string(runes), nil
}
func getHDropFiles(hMem uintptr) ([]string, error) {
lpLock, _, _ := procGlobalLock.Call(hMem)
if lpLock == 0 {
return nil, fmt.Errorf("无法锁定HDROP内存")
}
defer procGlobalUnlock.Call(hMem)
count, _, _ := procDragQueryFile.Call(lpLock, 0xFFFFFFFF, 0, 0)
if count == 0 {
return nil, fmt.Errorf("HDROP中未找到文件")
}
files := make([]string, count)
for i := uintptr(0); i < count; i++ {
var buf [260]uint16
_, _, _ = procDragQueryFile.Call(lpLock, i, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
files[i] = syscall.UTF16ToString(buf[:])
}
return files, nil
}
func getClipboardFormatName(format uint32) string {
if format <= 0xC000 {
switch format {
case CF_TEXT:
return "CF_TEXT (ANSI文本)"
case CF_UNICODETEXT:
return "CF_UNICODETEXT"
case CF_BITMAP:
return "CF_BITMAP (位图)"
case CF_HDROP:
return "CF_HDROP (文件列表)"
default:
return "标准格式"
}
}
buf := make([]uint16, 256)
r1, _, _ := user32.NewProc("GetClipboardFormatNameW").Call(
uintptr(format),
uintptr(unsafe.Pointer(&buf[0])),
uintptr(len(buf)),
)
if r1 == 0 {
return fmt.Sprintf("未知自定义格式 (%d)", format)
}
return "自定义格式: " + syscall.UTF16ToString(buf)
}
func init() {
if err := shell32.Load(); err != nil {
panic("无法加载shell32.dll: " + err.Error())
}
}
func main() {
className := syscall.StringToUTF16Ptr("ClipboardMonitorClass")
var wc WNDCLASSEX
wc.Size = uint32(unsafe.Sizeof(wc))
wc.WndProc = syscall.NewCallback(windowProc)
wc.Instance = 0
wc.ClassName = className
wc.Style = 0x0002 // CS_HREDRAW
ret, _, err := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wc)))
if ret == 0 {
panic("注册窗口类失败: " + err.Error())
}
hwnd, _, err := procCreateWindowEx.Call(
0,
uintptr(unsafe.Pointer(className)),
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("剪切板监视器"))),
0,
0, 0, 100, 100,
0, 0, 0, 0,
)
if hwnd == 0 {
panic("创建窗口失败: " + err.Error())
}
procShowWindow.Call(hwnd, 0) // 隐藏窗口
fmt.Println("🚀 剪贴板监视器已启动。现在可以复制内容进行测试!")
var msg MSG
for {
ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0)
if ret <= 0 {
break
}
procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
procDispatchMessage.Call(uintptr(unsafe.Pointer(&msg)))
}
}
▶️ 如何运行?
安装依赖
bash
go mod init clipboard-monitor
go get golang.org/x/sys/windows
go get golang.org/x/text/encoding/simplifiedchinese
运行程序
保存为 main.go,然后:
bash
go run main.go
测试效果
复制一段文字、一张图片、或几个文件试试! 你会看到类似这样的输出:
text
🚀 剪贴板监视器已启动。现在可以复制内容进行测试!
📝 文本内容:
Hello, 剪贴板!
📎 拖放的文件 (2):
- C:\Users\Alice\Pictures\cat.jpg
- C:\Users\Alice\Documents\report.pdf
📎 CF_BITMAP (位图)
🔒 安全提醒
- 本程序仅用于学习和本地调试。
- 实际产品中应避免滥用剪贴板监控(涉及隐私!)。
- 在企业环境中,此类行为可能违反安全策略。
🎉 结语
通过几十行 Go 代码,我们撬动了 Windows 底层的消息系统,实现了对剪贴板的"温柔窥探"。这不仅是一次技术实践,更是一次穿越回 Win32 时代的浪漫冒险。
下次当你复制密码时,记得看看控制台------说不定你的程序正在偷笑 😏。
往期部分文章列表
- 一文讲透 Go 的 defer:你的"善后管家",别让他变成"背锅侠"!
- 你知道程序怎样优雅退出吗?------ Go 开发中的"体面告别"全指南
- 用golang解救PDF文件中的图片只要200行代码!
- 200KB 的烦恼,Go 语言 20 分钟搞定!------ 一个程序员的图片压缩自救指南
- 从"CPU 烧开水"到优雅暂停:Go 里 sync.Cond 的正确打开方式
- 时移世易,篡改天机:吾以 Go 语令 Windows 文件"返老还童"记
- golang圆阵列图记:天灵灵地灵灵图标排圆形
- golang解图记
- 从 4.8 秒到 0.25 秒:我是如何把 Go 正则匹配提速 19 倍的?
- 用 Go 手搓一个内网 DNS 服务器:从此告别 IP 地址,用域名畅游家庭网络!
- 我用Go写了个华容道游戏,曹操终于不用再求关羽了!
- 用 Go 接口把 Excel 变成数据库:一个疯狂但可行的想法
- 穿墙术大揭秘:用 Go 手搓一个"内网穿透"神器!
- 布隆过滤器(go):一个可能犯错但从不撒谎的内存大师
- 自由通讯的魔法:Go从零实现UDP/P2P 聊天工具
- Go语言实现的简易远程传屏工具:让你的屏幕「飞」起来
- 当你的程序学会了"诈尸":Go 实现 Windows 进程守护术
- 验证码识别API:告别收费接口,迎接免费午餐
- 用 Go 给 Windows 装个"顺风耳":两分钟写个录音小工具
- 无奈!我用go写了个MySQL服务
- 使用 Go + govcl 实现 Windows 资源管理器快捷方式管理器
- 用 Go 手搓一个 NTP 服务:从"时间混乱"到"精准同步"的奇幻之旅
- 用 Go 手搓一个 Java 构建工具:当 IDE 不在身边时的自救指南
- 深入理解 Windows 全局键盘钩子(Hook):拦截 Win 键的 Go 实现