☰
用 GetGUIThreadInfo + GetCaretPos 监控 Windows 焦点光标:一次可复现的尝试
2026/10/4 17:29:32 网站建设 项目流程

1. 从 GetForegroundWindow 到 hwndCaret:Windows 焦点光标监控到底难在哪

先说清楚我们要做的事:在 Windows 桌面端实时拿到"当前哪个窗口有输入焦点、光标(插入符)在屏幕上的哪个位置"。这个能力听起来很小,但它是很多键盘增强工具、输入法候选框跟随、悬浮提示、自动化脚本的地基。你打开记事本敲字,那个一闪一闪的竖线就是插入符(caret),我们要的就是它的矩形坐标。

整条链路其实分三步:先用GetForegroundWindow拿到前台顶级窗口句柄,再用GetWindowThreadProcessId把窗口句柄换成 GUI 线程 ID,最后用GetGUIThreadInfo从这个线程信息里取出hwndCaret和rcCaret。如果你只想要坐标,GetCaretPos更省事,一个LPPOINT就够。但实测下来,GetCaretPos的坑比GetGUIThreadInfo还多,因为它读的是"调用线程自己的插入符",跨进程时经常直接返回失败。

我试过在记事本、资源管理器这类原生 Win32 控件上跑,GetGUIThreadInfo返回的flags == 1、hwndCaret有效、rcCaret也能拿到合理矩形,一切正常。但一换到 QQ、WPS、迅雷这类 DirectUI 窗口,或者 Chrome、Edge 的输入框,flags直接变 0,hwndCaret是 NULL。这不是你代码写错了,而是这些框架自己绘制光标,根本没走系统的 caret 机制。

所以这篇的目标很明确:给你一套可复制的 C++ 轮询代码,把"能拿到的场景稳定拿到",把"拿不到的场景明确识别出来",而不是假装它能通吃。拿到坐标之后,如果你还想做后续处理(比如把焦点窗口标题、坐标、时间戳发给一个统一接口做日志或增强逻辑),可以走 TaoToken 的统一 Key/API 通道,后面第 3、4 节会给具体配置。

适合谁看:写过一点 Win32、想给自己的桌面工具加"无热键"能力的人;或者你只是好奇系统级焦点监控到底能做到什么程度。下面所有代码都在 VS2022 + Windows 10/11 上验证过,直接能编译。

2. TaoToken 前置准备:统一 Key 与 API 通道怎么配

在写监控代码之前,先把"采集结果往哪送"这件事定下来。我的做法是:本地轮询只负责采集,采集到的结构化数据(前台窗口标题、进程名、caret 矩形、是否有效)通过 HTTP 发给一个统一入口,由它决定后续怎么处理。这样监控逻辑和后端处理解耦,换处理方式不用动 C++ 代码。

TaoToken 在这里扮演的就是那个统一入口。它的 API 地址是https://taotoken.net/api,兼容常见的对话补全调用格式,你拿一个 Key 就能同时调不同模型,不用为每个模型单独维护一套鉴权和地址。对桌面工具来说这点很实用:今天想用某个模型做焦点文本的语义补全,明天想换一个,改个 model 字段就行。

前置准备分三件事,都不复杂:

第一,注册并拿到 API Key。进控制台创建 Key,页面在https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。Key 只在创建时完整显示一次,复制下来存到环境变量里,别硬编码进源码。

第二,确认你要用的模型 ID。不同模型对输入长度、是否支持流式的支持不一样。如果你只是做焦点文本的短补全,选一个响应快的就行;如果要做长上下文分析,选上下文窗口大的。模型列表在文档里能查到,文档入口https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。

第三,想清楚调用方式。如果你只是偶尔验证一下接口通不通,用模型对话页面手动发一条就行,地址https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。但如果你要把焦点监控做成长期运行的桌面 Agent,建议直接上 Coding Plan,省得每次手动配,入口https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。

这里有个关键点:Base URL 填https://taotoken.net/api,不要带任何路径后缀,也不要加 UTM 参数到 API 地址上。Key 走Authorization: Bearer <你的Key>请求头。Model ID 按文档里给的字符串原样填。这三件套(Base URL + Key + Model ID)配错任何一个,后面第 5 节的 401 就会找上你。

环境变量建议这样设,Windows 下用 setx 或者直接在系统设置里加:

setx TAOTOKEN_API_KEY "sk-你的Key" setx TAOTOKEN_BASE_URL "https://taotoken.net/api"

设完重开一个终端才生效。C++ 里用GetEnvironmentVariable读,别写死在代码里,不然你提交到 Git 就麻烦了。

3. 可复制配置:C++ 轮询代码 + 定时器 + JSON 片段

这一节是核心,给你能直接编译的代码。整体思路:主线程创建一个定时器,每 100ms 触发一次采集;采集函数先拿前台窗口,再拿线程 ID,再调GetGUIThreadInfo,判断flags和hwndCaret,最后把rcCaret从客户坐标换算成屏幕坐标。

先看采集函数。注意GUITHREADINFO用之前必须把cbSize设成结构体大小,否则函数直接失败,这是最常见的低级错误:

#include <windows.h> #include <string> #include <cstdio> struct CaretInfo { bool valid = false; HWND hwndFore = nullptr; HWND hwndCaret = nullptr; RECT rcScreen{}; DWORD threadId = 0; std::wstring title; }; CaretInfo CaptureCaret() { CaretInfo info; HWND hwndFore = GetForegroundWindow(); if (!hwndFore) return info; info.hwndFore = hwndFore; DWORD pid = 0; DWORD tid = GetWindowThreadProcessId(hwndFore, &pid); if (!tid) return info; info.threadId = tid; GUITHREADINFO gti{}; gti.cbSize = sizeof(GUITHREADINFO); if (!GetGUIThreadInfo(tid, &gti)) return info; // flags 含 GUI_CARETBLINKING(0x00000001) 表示有闪烁插入符 if (!(gti.flags & GUI_CARETBLINKING)) return info; if (!gti.hwndCaret) return info; info.hwndCaret = gti.hwndCaret; // rcCaret 是相对 hwndCaret 客户区的坐标,换算到屏幕 POINT ptTopLeft{ gti.rcCaret.left, gti.rcCaret.top }; POINT ptBottomRight{ gti.rcCaret.right, gti.rcCaret.bottom }; if (!ClientToScreen(gti.hwndCaret, &ptTopLeft)) return info; if (!ClientToScreen(gti.hwndCaret, &ptBottomRight)) return info; info.rcScreen.left = ptTopLeft.x; info.rcScreen.top = ptTopLeft.y; info.rcScreen.right = ptBottomRight.x; info.rcScreen.bottom = ptBottomRight.y; wchar_t buf[512] = {}; GetWindowTextW(hwndFore, buf, 511); info.title = buf; info.valid = true; return info; }

几个容易踩的点。第一,rcCaret的坐标系是相对hwndCaret的客户区,不是屏幕坐标,必须用ClientToScreen换算,而且左上和右下两个点都要换,不能只换左上再自己加宽高,因为 DPI 缩放和窗口边框会让宽高算错。第二,GUI_CARETBLINKING这个宏的值是 1,但别直接写flags == 1,用宏更清晰,也避免以后微软加新 flag 时你判断错。第三,GetWindowTextW对某些跨进程窗口可能拿不到标题,这是正常的,别把它当成失败条件。

然后是定时器。用SetTimer最简单,回调里调采集,把结果打印或发出去:

#define TIMER_ID_CARET 1001 void CALLBACK CaretTimerProc(HWND hwnd, UINT, UINT_PTR id, DWORD) { if (id != TIMER_ID_CARET) return; CaretInfo ci = CaptureCaret(); if (ci.valid) { wprintf(L"[caret] title=%ls rect=(%ld,%ld)-(%ld,%ld)\n", ci.title.c_str(), ci.rcScreen.left, ci.rcScreen.top, ci.rcScreen.right, ci.rcScreen.bottom); } else { wprintf(L"[caret] no caret\n"); } } // 在窗口初始化处: SetTimer(hwnd, TIMER_ID_CARET, 100, CaretTimerProc);

100ms 是个折中值。太快(比如 16ms)会让 CPU 占用上去,而且插入符本身闪烁周期约 530ms,采太密没意义;太慢(比如 500ms)会漏掉快速切换焦点的瞬间。实测 100ms 在记事本和浏览器里都能稳定跟上。

如果你要把采集结果发给 TaoToken 做后续处理,请求体用 JSON,结构建议这样,字段名和你的后端约定好:

{ "model": "你的模型ID", "messages": [ { "role": "user", "content": "当前焦点窗口标题:记事本\ncaret 屏幕矩形:(320,240)-(322,262)\n请判断用户正在输入什么类型的文本。" } ], "stream": false }

发送时请求头带Authorization: Bearer <Key>,Content-Type: application/json,地址https://taotoken.net/api加上对话补全的路径(具体路径以文档为准)。C++ 里发 HTTP 可以用 WinHTTP,也可以用 libcurl,看你项目习惯。注意别把 Key 拼进 URL,一定放请求头。

4. 验证请求与成功结果:坐标校验 + 接口连通性

代码跑起来之后,先别急着接后端,第一步是验证坐标对不对。方法很简单:打开记事本,点进编辑区,让光标停在某个已知位置,看控制台打印的矩形。然后你在记事本里敲几个字,光标右移,矩形 left 应该跟着变大,top 基本不变。如果 left 没变,说明你ClientToScreen用错了,或者rcCaret读的是旧值。

更严格的校验:用GetCaretPos交叉验证。在同一个线程里调GetCaretPos,它返回的是相对当前线程焦点窗口的客户坐标,和GetGUIThreadInfo的rcCaret应该一致(前提是你在目标线程里调,跨线程调GetCaretPos会失败,这也是它不好用的原因)。两个值对不上,优先信GetGUIThreadInfo。

浏览器场景要单独测。Chrome 的地址栏和网页输入框,GetGUIThreadInfo经常返回flags == 0,因为 Chromium 自己管光标。这时候你的代码应该优雅地报告"无 caret",而不是崩或者卡住。你可以加一个降级逻辑:拿不到 caret 时,至少把前台窗口标题和进程名记下来,作为"焦点变化事件"上报,虽然没坐标,但知道用户切到了浏览器。

接口连通性验证,先用命令行确认 Key 和地址没问题,再写 C++。用 curl 发一条最小请求:

curl -X POST "https://taotoken.net/api/你的补全路径" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"你的模型ID","messages":[{"role":"user","content":"ping"}],"stream":false}'

返回 200 且 body 里有正常的补全内容,说明三件套配对了。如果返回 401,看第 5 节。如果返回 404,多半是路径写错了,回文档核对。如果返回 400,检查 JSON 是不是合法,特别是中文有没有转义问题。

成功的结果长这样:控制台每 100ms 打印一行 caret 矩形,你在记事本里移动光标,矩形跟着变;切到浏览器,打印变成no caret;切回记事本,又恢复。同时后端能收到你发的 JSON,返回处理结果。到这一步,整条链路就通了。

5. 本篇常见错误排查:401、local proxy failed、reading choices、OAuth

这一节按真实报错来,你遇到哪个对哪个。

401 Unauthorized。九成是 Key 的问题。检查三件事:Key 有没有复制完整(前后有没有空格)、请求头是不是Authorization: Bearer sk-xxx这个格式(Bearer 后面有一个空格)、Key 是不是已经过期或在控制台被删了。还有一种情况是你把 Key 放进了 URL 查询参数而不是请求头,有些网关不认。重新去https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=生成一个新 Key 试。

local proxy failed。这个报错通常出现在你用某个客户端工具(比如 Cline、Codex 这类)配置了本地代理端口,但那个端口没起来,或者 Base URL 填成了http://127.0.0.1:xxxx而不是https://taotoken.net/api。解决:把 Base URL 改回https://taotoken.net/api,关掉本地代理设置。如果你确实需要本地转发,确认转发进程在跑,端口没被占。

reading choices 相关报错(类似cannot read property 'choices' of undefined)。这是响应体结构和你代码预期不一致。常见原因:请求失败但你没检查 HTTP 状态码就直接解析 JSON,拿到的其实是错误对象,里面没有choices字段。修法:先判断状态码是不是 200,再解析;解析前打印原始 body 看一眼。另一个原因是stream: true时返回的是 SSE 流,不是单个 JSON,你按普通 JSON 解析当然没有choices。要么改成stream: false,要么按 SSE 逐行解析data:前缀。

OAuth 相关报错。如果你用的是 Claude Code 这类工具,它可能默认走 OAuth 登录而不是 API Key。报错里出现 OAuth、token refresh 失败之类,说明它在尝试走账号授权流程。你要做的是在配置里显式指定用 API Key 模式,把 Base URL 设成https://taotoken.net/api,Key 填进去,Model ID 填对。Claude Code 的接入配置里通常有ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY两个环境变量,按文档填。如果你用的是 Codex 的auth.json,里面要写全 Base URL、Key、Model ID 三件套,缺一个都会认证失败。

再补一个非报错但很烦的问题:坐标偏移。如果你在多显示器或者高 DPI 缩放下发现 caret 矩形位置偏了,检查你的进程有没有声明 DPI 感知。在 manifest 里加<dpiAware>true</dpiAware>,或者启动时调SetProcessDpiAwarenessContext。不声明的话,系统会给你做虚拟化缩放,ClientToScreen出来的值就是错的。

6. 把焦点监控接进你的工作流:从采集到后续处理

代码能跑、坐标能对、接口能通之后,剩下的就是怎么把它用起来。我的建议是分两层:采集层只做一件事,把CaretInfo结构体填好,通过一个队列丢给处理层;处理层决定是打日志、发 HTTP、还是触发本地动作。这样采集频率和处理耗时解耦,不会因为网络慢把定时器拖卡。

如果你要做的是"无热键"键盘增强,焦点监控只是输入,真正的逻辑在"焦点变化时做什么"。比如检测到焦点从编辑器切到浏览器,自动切换输入法状态;或者检测到 caret 在某个特定窗口,弹出候选词面板。这些逻辑都可以放在处理层,用采集层给的结构化数据驱动。

要把采集结果做语义处理,走 TaoToken 的统一通道就行。短文本补全、焦点内容分类这类任务,用模型对话页面先手动验证 prompt 效果,地址https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。验证好了再写进 C++ 的 HTTP 调用里。如果你要做的是长期运行的桌面 Agent,涉及多轮调用和状态管理,直接上 Coding Plan 更省事,入口https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。

最后说个现实预期:GetGUIThreadInfo这套方案在原生 Win32 控件上是可靠的,在 DirectUI 和浏览器里拿不到 caret 是框架层面的限制,不是你代码的问题。别在这上面死磕,把"拿不到"当成一种正常状态处理,你的工具反而更稳。真要继续深挖浏览器场景,方向是研究各浏览器自己的 accessibility 接口或者 UI Automation,那是另一条路了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询