1. 从一次“鼠标自己动了”的调试说起:SetCursorPos 与 mouse_event 远程控制到底能做什么
先说结论:Windows 下做鼠标远程控制,核心就三件事——坐标换算、事件注入、网络传输。SetCursorPos负责把光标挪到屏幕某个绝对位置,mouse_event(或更新的SendInput)负责模拟按下、抬起、滚轮这些动作,ClientToScreen负责把窗口内的逻辑坐标翻译成屏幕坐标。三者串起来,再套一层 TCP 或 WebSocket,就能在本地双机之间跑通一次点击与拖拽的远程回放。
这套东西适合谁?适合想理解远程桌面底层原理的 Windows C++ 开发者、做自动化测试工具的同学、以及想给自己写个“局域网内控制另一台机器鼠标”的小工具的人。它不是什么高深技术,但坑不少:坐标对不上、DPI 缩放导致偏移、mouse_event在部分场景被 UIPI 拦截、结构体跨机传输字节序和内存对齐问题。我试过在 125% 缩放的屏幕上直接发逻辑坐标,接收端光标永远偏一截,后来才明白必须统一到屏幕物理坐标。
本文不会只给你一段“能跑就行”的代码。我会把链路拆成三段讲清楚,给出可复制的config.toml和settings.json配置骨架,演示用 TaoToken 统一 Key 打通 API 通道的验证动作,最后把常见报错(401、local proxy failed、reading choices、OAuth 相关)逐个对照排查。目标很明确:你在本地两台机器上,能跟着把一次点击和一次拖拽完整回放出来。
先明确一个边界:本文讲的是本地局域网内的技术验证,不涉及任何绕过网络管理的手段。所有连接都发生在你自己的设备之间,IP 用192.168.x.x这类内网地址。TaoToken 在这里的角色是统一 API Key 管理,帮你把模型调用通道收敛到一个入口,方便后续接入更复杂的控制逻辑(比如用自然语言描述动作,让模型生成坐标序列)。它不替代你的编辑器,也不碰生产数据库,就是一个 Key 和通道的聚合层。
坐标换算这块,最容易混的是三套坐标系:屏幕坐标(物理像素,原点在主显示器左上角)、窗口客户区坐标(原点在窗口客户区左上角)、逻辑坐标(受 DPI 缩放影响)。发送端如果直接抓OnMouseMove的point,那是客户区逻辑坐标;接收端如果直接SetCursorPos(point.x, point.y),在缩放不是 100% 的机器上必然偏。正确做法是发送端用ClientToScreen转成屏幕坐标再发,或者接收端收到后按自己的窗口做一次转换。excerpt 里那段代码在接收端调了ClientToScreen,但发送端发的是客户区坐标,两边窗口位置不一致时就会错位——这是第一个要修的坑。
事件注入方面,mouse_event是老 API,参数是dwFlags(如MOUSEEVENTF_MOVE、MOUSEEVENTF_LEFTDOWN、MOUSEEVENTF_LEFTUP)、dx、dy、dwData、dwExtraInfo。移动用SetCursorPos更直接,点击和拖拽用mouse_event补动作。注意MOUSEEVENTF_MOVE配合SetCursorPos时,dx/dy一般传 0,因为位置已经由SetCursorPos定了。拖拽就是LEFTDOWN→ 若干次移动 →LEFTUP,中间每次移动后最好Sleep(10~20),否则目标程序可能来不及响应。
网络传输这块,结构体直接send有个隐患:不同编译器、不同架构下struct的内存对齐可能不一样,CPoint里是两个LONG,通常没问题,但加上int event后整体大小可能是 12 或 16 字节。稳妥做法是手动序列化成固定布局的字节流,或者用#pragma pack(1)。另外 TCP 是字节流,recv不保证一次收满一个结构体,必须循环收够sizeof再解析。excerpt 里直接recv(s_d, (char*)&pos, sizeof(point_pos), 0)在局域网小包下大概率没事,但严格来说要处理粘包和半包。
下面进入实操。我会先给配置骨架,再给发送端和接收端的关键代码,然后演示用 TaoToken 验证 API 通道,最后排错。你不需要一次全懂,跟着敲一遍,跑通一次点击,再跑一次拖拽,链路就清楚了。
2. TaoToken 前置:统一 Key 与 config.toml / settings.json 配置骨架
在写鼠标控制代码之前,先把 API 通道理顺。为什么这一步放在前面?因为后面你想扩展成“模型生成动作序列”时,会需要一个稳定的模型调用入口。TaoToken 提供统一 Key,把不同模型的接入收敛成一套 Base URL + Key + Model ID 的组合,省得你在多个平台之间来回切。
先拿 Key。打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后在控制台创建 API Key。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。API 基础地址统一用 https://taotoken.net/api ,注意这个地址不加 UTM 参数,直接写进配置。
拿到 Key 后,先建一个项目目录,比如D:\mouse-remote,在里面放两个配置文件。第一个是config.toml,用于 C++ 侧读取网络和模型参数:
# D:\mouse-remote\config.toml [network] listen_port = 8000 target_host = "192.168.0.2" target_port = 8000 recv_timeout_ms = 3000 [coordinate] # 发送端是否把客户区坐标转屏幕坐标后再发 convert_to_screen = true # 接收端是否按自身窗口再做一次转换 reconvert_on_receiver = false [taotoken] base_url = "https://taotoken.net/api" api_key = "sk-你的Key" model_id = "claude-sonnet-4-20250514" timeout_ms = 30000第二个是settings.json,用于 VS Code 或 Cline 这类工具读取,方便你在编辑器里直接调模型辅助生成坐标逻辑:
{ "taotoken.baseUrl": "https://taotoken.net/api", "taotoken.apiKey": "sk-你的Key", "taotoken.modelId": "claude-sonnet-4-20250514", "mouseRemote.listenPort": 8000, "mouseRemote.targetHost": "192.168.0.2", "mouseRemote.targetPort": 8000, "mouseRemote.convertToScreen": true }如果你用 Claude Code 做辅助开发,配置走 Anthropic 兼容通道,Base URL 同样填https://taotoken.net/api,Key 用上面那个,Model ID 按你选的填。Claude Code 的接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有完整的 settings 片段。Coding Plan 适合长期做这类工具开发,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
这里强调三件套:Base URL、Key、Model ID,缺一不可。很多人只填了 Key 忘了 Base URL,结果请求打到默认端点,报 401 或 404。TaoToken 的 Base URL 固定是https://taotoken.net/api,不要自己拼/v1之类的后缀,具体路径由 SDK 或 curl 决定。
配置写好后,先别急着写鼠标代码,用一条 curl 验证通道是否通。打开 PowerShell:
curl.exe https://taotoken.net/api/v1/messages ` -H "Content-Type: application/json" ` -H "x-api-key: sk-你的Key" ` -H "anthropic-version: 2023-06-01" ` -d "{\"model\":\"claude-sonnet-4-20250514\",\"max_tokens\":64,\"messages\":[{\"role\":\"user\",\"content\":\"回复 OK 两个字母\"}]}"如果返回里有content字段且文本是OK,说明 Key 和通道都正常。这一步很重要,因为后面鼠标控制代码里如果嵌了模型调用,通道不通会浪费你大量时间在排查业务逻辑上。
关于 Key 的安全:不要把 Key 硬编码进提交到 Git 的源码里。config.toml和settings.json加进.gitignore,或者用环境变量TAOTOKEN_API_KEY读取。C++ 里可以用getenv,Node 里用process.env。这是基本习惯,别省。
另外,TaoToken 的模型对话入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite ,你可以在网页上先试几条 prompt,确认模型能理解“把坐标 (100,200) 转成屏幕坐标”这类指令,再写进代码。这样调试时你心里有底,知道是代码问题还是模型问题。
配置骨架到这里就齐了。下一节进入可复制的 C++ 配置和代码,把发送端、接收端、坐标换算、事件注入串起来。
3. 可复制配置:SetCursorPos / mouse_event / ClientToScreen 完整代码骨架
这一节给可直接编译的代码。我用 Win32 + Winsock,VS2019 及以上都能编。先建一个共享头文件mouse_packet.h,定义数据包结构,用#pragma pack(1)避免对齐差异:
// mouse_packet.h #pragma once #include <cstdint> #pragma pack(push, 1) struct MousePacket { uint8_t event; // 1=move, 2=leftdown, 3=leftup, 4=rightdown, 5=rightup, 6=wheel int32_t x; // 屏幕坐标 x int32_t y; // 屏幕坐标 y int32_t data; // 滚轮增量等附加数据 }; #pragma pack(pop) constexpr uint8_t EV_MOVE = 1; constexpr uint8_t EV_LEFTDOWN = 2; constexpr uint8_t EV_LEFTUP = 3; constexpr uint8_t EV_RIGHTDOWN = 4; constexpr uint8_t EV_RIGHTUP = 5; constexpr uint8_t EV_WHEEL = 6;发送端核心逻辑:抓鼠标移动,把客户区坐标用ClientToScreen转屏幕坐标,打包发送。注意ClientToScreen需要窗口句柄,在 MFC 的OnMouseMove里可以直接用this的m_hWnd。
// sender.cpp 关键片段 #include "mouse_packet.h" #include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib") SOCKET g_sock = INVALID_SOCKET; bool ConnectToReceiver(const char* host, int port) { WSADATA wsa; if (WSAStartup(MAKEWORD(2,2), &wsa) != 0) return false; g_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (g_sock == INVALID_SOCKET) return false; sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(static_cast<u_short>(port)); inet_pton(AF_INET, host, &addr.sin_addr); if (connect(g_sock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { closesocket(g_sock); g_sock = INVALID_SOCKET; return false; } return true; } void SendMouseEvent(uint8_t ev, int x, int y, int data = 0) { if (g_sock == INVALID_SOCKET) return; MousePacket pkt{}; pkt.event = ev; pkt.x = x; pkt.y = y; pkt.data = data; send(g_sock, (const char*)&pkt, sizeof(pkt), 0); } // 在窗口的 OnMouseMove 里调用 void OnMouseMove(UINT nFlags, CPoint point) { CPoint screenPt = point; ClientToScreen(&screenPt); // 客户区 -> 屏幕坐标 SendMouseEvent(EV_MOVE, screenPt.x, screenPt.y); } // 左键按下 void OnLButtonDown(UINT nFlags, CPoint point) { CPoint screenPt = point; ClientToScreen(&screenPt); SendMouseEvent(EV_LEFTDOWN, screenPt.x, screenPt.y); } // 左键抬起 void OnLButtonUp(UINT nFlags, CPoint point) { CPoint screenPt = point; ClientToScreen(&screenPt); SendMouseEvent(EV_LEFTUP, screenPt.x, screenPt.y); }接收端核心逻辑:循环收满一个MousePacket,按event分派,移动用SetCursorPos,点击用mouse_event。注意mouse_event的dx/dy在配合SetCursorPos时传 0。
// receiver.cpp 关键片段 #include "mouse_packet.h" #include <winsock2.h> #include <windows.h> #pragma comment(lib, "ws2_32.lib") bool RecvAll(SOCKET s, char* buf, int len) { int total = 0; while (total < len) { int n = recv(s, buf + total, len - total, 0); if (n <= 0) return false; total += n; } return true; } void HandlePacket(const MousePacket& pkt) { switch (pkt.event) { case EV_MOVE: SetCursorPos(pkt.x, pkt.y); break; case EV_LEFTDOWN: SetCursorPos(pkt.x, pkt.y); mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0); break; case EV_LEFTUP: SetCursorPos(pkt.x, pkt.y); mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0); break; case EV_RIGHTDOWN: SetCursorPos(pkt.x, pkt.y); mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, 0); break; case EV_RIGHTUP: SetCursorPos(pkt.x, pkt.y); mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0); break; case EV_WHEEL: mouse_event(MOUSEEVENTF_WHEEL, 0, 0, (DWORD)pkt.data, 0); break; default: break; } } void ReceiverLoop(SOCKET clientSock) { MousePacket pkt{}; while (true) { if (!RecvAll(clientSock, (char*)&pkt, sizeof(pkt))) break; HandlePacket(pkt); } }拖拽怎么实现?发送端在OnLButtonDown后进入拖拽状态,OnMouseMove时如果处于拖拽,就连续发EV_MOVE;OnLButtonUp发EV_LEFTUP。接收端收到LEFTDOWN后光标已经按下,后续MOVE会带着按下状态移动,最后LEFTUP释放。中间每次MOVE之间在发送端加Sleep(15),避免刷太快。
如果你用 Cline 或 CC Switch 做辅助开发,配置里记得写全三件套。Cline 的 MCP 配置片段:
{ "mcpServers": { "taotoken": { "command": "npx", "args": ["-y", "@taotoken/mcp-server"], "env": { "TAOTOKEN_BASE_URL": "https://taotoken.net/api", "TAOTOKEN_API_KEY": "sk-你的Key", "TAOTOKEN_MODEL_ID": "claude-sonnet-4-20250514" } } } }Codex 的auth.json片段:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model": "claude-sonnet-4-20250514" }这些配置的作用是让你在写鼠标控制代码时,能随时让模型帮你生成坐标换算逻辑或排查 Winsock 错误码。通道统一了,切换模型只改model_id一行。
编译时注意链接ws2_32.lib,MFC 项目在stdafx.h里加#include <winsock2.h>要放在windows.h之前,否则会有一堆重定义错误。这是经典坑,我踩过不止一次。
4. 验证请求与成功结果:跑通一次点击和一次拖拽的远程回放
代码写完了,怎么验证?分三步:先验证网络连通,再验证单次点击,最后验证拖拽。
第一步,网络连通。接收端先跑起来,监听 8000 端口。发送端用ConnectToReceiver("192.168.0.2", 8000),返回 true 说明 TCP 握手成功。如果返回 false,先用ping 192.168.0.2确认两台机器互通,再检查接收端防火墙是否放行 8000 端口。Windows 防火墙默认会拦入站,加一条规则:
New-NetFirewallRule -DisplayName "MouseRemote8000" -Direction Inbound -Protocol TCP -LocalPort 8000 -Action Allow第二步,单次点击。发送端在窗口里点一下左键,接收端应该看到光标先跳到对应屏幕位置,然后触发一次左键点击。怎么确认点击生效?在接收端打开一个记事本,如果点击落在记事本上,光标会定位到点击位置。更直观的办法是接收端开一个画图程序,点击会留下一个点。
这里有个细节:SetCursorPos设置的是屏幕物理坐标,如果你的接收端显示器缩放是 150%,SetCursorPos(100, 100)实际落在物理像素 (100,100),而你在发送端看到的逻辑坐标可能对应物理 (150,150)。所以发送端必须用ClientToScreen转成物理坐标再发。验证方法:发送端把鼠标移到窗口客户区左上角,打印screenPt.x, screenPt.y,接收端SetCursorPos后光标应该落在屏幕左上角附近。如果偏了,检查convert_to_screen是否为 true。
第三步,拖拽。发送端按住左键从 A 点拖到 B 点,接收端应该看到光标从 A 对应的屏幕位置按下,移动到 B,释放。验证拖拽是否成功,可以在接收端开一个支持拖拽的程序(比如把文件从桌面拖到文件夹),看是否触发拖拽行为。如果只移动不拖拽,说明LEFTDOWN没发出去或者被拦截。
用 TaoToken 验证模型通道:在发送端加一个功能,按 F1 让模型生成一组坐标序列,比如“从 (200,300) 拖到 (600,500)”。调用模型对话接口:
curl.exe https://taotoken.net/api/v1/messages ` -H "Content-Type: application/json" ` -H "x-api-key: sk-你的Key" ` -H "anthropic-version: 2023-06-01" ` -d "{\"model\":\"claude-sonnet-4-20250514\",\"max_tokens\":256,\"messages\":[{\"role\":\"user\",\"content\":\"生成从(200,300)到(600,500)的拖拽坐标序列,每步间隔20像素,输出JSON数组,每项含x和y\"}]}"返回的 JSON 数组解析后,依次发EV_MOVE,中间穿插LEFTDOWN和LEFTUP,就能实现“模型生成动作,代码执行”的闭环。这一步验证成功,说明你的 API 通道和鼠标控制链路都通了。
成功结果的标志:接收端光标跟随发送端移动,点击和拖拽都能在目标程序上生效,模型返回的坐标序列能被正确解析并回放。如果这些都做到了,恭喜你,链路跑通了。
实测下来,局域网内延迟通常在 5~20ms,肉眼几乎无感。如果延迟明显,检查是不是每次移动都发了包导致刷屏,可以加一个最小移动阈值,比如坐标变化小于 3 像素就不发。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth 对照
这一节把常见报错逐个对照。这些错误大多出在 API 通道配置上,不是鼠标代码本身的问题,但会卡住你很久。
401 Unauthorized。最常见的原因是 Key 没填对或 Base URL 写错。检查config.toml里的api_key是否以sk-开头,base_url是否是https://taotoken.net/api。如果你用的是环境变量,确认TAOTOKEN_API_KEY已经export或set。PowerShell 里临时设置:
$env:TAOTOKEN_API_KEY = "sk-你的Key"另一个原因是请求头字段名不对。Anthropic 兼容通道用x-api-key,OpenAI 兼容通道用Authorization: Bearer。TaoToken 的文档里写得很清楚,按你用的 SDK 选对字段。401 还会在 Key 被删除或过期时出现,去控制台确认 Key 状态。
local proxy failed。这个错误通常出现在你本地配了代理,但代理没启动或端口不对。TaoToken 的请求不需要走本地代理,把HTTP_PROXY、HTTPS_PROXY环境变量清掉再试:
Remove-Item Env:HTTP_PROXY -ErrorAction SilentlyContinue Remove-Item Env:HTTPS_PROXY -ErrorAction SilentlyContinue如果你在用 Cline 或 Claude Code,检查它们的设置里有没有proxy字段,删掉或留空。这个错误和鼠标控制无关,纯粹是网络层配置问题。
reading choices 相关报错。这个一般出现在 OpenAI 兼容格式的响应解析里,报错信息类似cannot read property 'choices' of undefined。原因是返回体不是预期的 OpenAI 格式,可能是 Base URL 拼错了路径,或者 Model ID 不存在。检查你的请求 URL 是不是https://taotoken.net/api/v1/chat/completions,Model ID 是不是控制台里列出的可用模型。如果返回体是 Anthropic 格式(有content字段),而你的代码按choices解析,就会报这个错。统一用 Anthropic 格式解析,或者确认你调的是哪个兼容端点。
OAuth 相关报错。如果你用 Claude Code 的 OAuth 登录流程,报错可能是OAuth token expired或invalid_grant。TaoToken 的接入推荐直接用 API Key,不走 OAuth。在 Claude Code 的配置里把认证方式改成 API Key,Base URL 填https://taotoken.net/api,Key 填sk-开头的那串。OAuth 流程涉及回调地址和 token 刷新,调试成本高,用 Key 更直接。
鼠标代码本身的坑。SetCursorPos返回 false,通常是权限问题——接收端程序没有以管理员身份运行,而目标窗口是管理员权限的。以管理员身份跑接收端即可。mouse_event无效,检查是否被 UIPI 拦截,同样用管理员权限。坐标偏移,检查 DPI 缩放,在接收端程序清单里加dpiAware:
<application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> </windowsSettings> </application>recv 返回 0 或 SOCKET_ERROR。说明对端关闭了连接。检查发送端是否崩溃退出,或者网络是否断开。在RecvAll里加日志,打印每次recv的返回值,定位是半包还是断连。
结构体大小不一致。发送端和接收端如果编译选项不同(比如一个 32 位一个 64 位),#pragma pack(1)能保证布局一致,但int32_t要确认两边都是 4 字节。用static_assert(sizeof(MousePacket) == 13, "pack error")在编译期检查。
排错的核心思路:先确认 API 通道通(curl 能返回),再确认网络通(ping + 端口),最后确认鼠标 API 有权限(管理员)。三层分开查,不要混在一起。
6. 继续往下走:把统一 Key 接入你的鼠标控制工具链
链路跑通之后,你可以做几件事让它更实用。第一,把坐标序列的生成交给模型,你只需要用自然语言描述动作,比如“把窗口从左上拖到右下”,模型输出坐标数组,代码回放。第二,加一个录制功能,发送端记录一段鼠标操作,序列化成 JSON,接收端回放,用于自动化测试。第三,把 TCP 换成 WebSocket,方便跨网段或加一层鉴权。
TaoToken 在这里的价值是统一 Key。你不需要为每个模型单独配 Key,换模型只改model_id。模型对话入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite ,你可以在网页上先试 prompt,确认输出格式再写进代码。API Key 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。如果你打算长期做这类工具,Coding Plan 在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
最后给一个实用技巧:在发送端加一个热键(比如 F2)切换“录制/回放”模式,录制时把MousePacket存进std::vector,回放时按时间戳重发。时间戳用GetTickCount64(),回放时按差值Sleep。这样你录一次拖拽,就能反复回放,调试接收端逻辑时特别省事。
代码不用一次写完美,先跑通移动,再加点击,再加拖拽,每步验证一次。遇到报错按第 5 节对照排查。通道用 curl 先验,鼠标用管理员权限跑,坐标用ClientToScreen统一。这三条记住,基本不会卡住。