恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
用 GetGUIThreadInfo + GetCaretPos 监控 Windows 焦点光标:一次可复现的尝试
首页
资讯中心
/
用 GetGUIThreadInfo + GetCaretPos 监控 Windows 焦点光标:一次可复现的尝试
用 GetGUIThreadInfo + GetCaretPos 监控 Windows 焦点光标:一次可复现的尝试
发布时间:2026/10/4 17:29:32
1. 从 GetForegroundWindow 到 hwndCaretWindows 焦点光标监控到底难在哪先说清楚我们要做的事在 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直接变 0hwndCaret是 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。Key 只在创建时完整显示一次复制下来存到环境变量里别硬编码进源码。第二确认你要用的模型 ID。不同模型对输入长度、是否支持流式的支持不一样。如果你只是做焦点文本的短补全选一个响应快的就行如果要做长上下文分析选上下文窗口大的。模型列表在文档里能查到文档入口https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。第三想清楚调用方式。如果你只是偶尔验证一下接口通不通用模型对话页面手动发一条就行地址https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。但如果你要把焦点监控做成长期运行的桌面 Agent建议直接上 Coding Plan省得每次手动配入口https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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 KeyContent-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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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/apiKey 填进去Model ID 填对。Claude Code 的接入配置里通常有ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY两个环境变量按文档填。如果你用的是 Codex 的auth.json里面要写全 Base URL、Key、Model ID 三件套缺一个都会认证失败。再补一个非报错但很烦的问题坐标偏移。如果你在多显示器或者高 DPI 缩放下发现 caret 矩形位置偏了检查你的进程有没有声明 DPI 感知。在 manifest 里加dpiAwaretrue/dpiAware或者启动时调SetProcessDpiAwarenessContext。不声明的话系统会给你做虚拟化缩放ClientToScreen出来的值就是错的。6. 把焦点监控接进你的工作流从采集到后续处理代码能跑、坐标能对、接口能通之后剩下的就是怎么把它用起来。我的建议是分两层采集层只做一件事把CaretInfo结构体填好通过一个队列丢给处理层处理层决定是打日志、发 HTTP、还是触发本地动作。这样采集频率和处理耗时解耦不会因为网络慢把定时器拖卡。如果你要做的是无热键键盘增强焦点监控只是输入真正的逻辑在焦点变化时做什么。比如检测到焦点从编辑器切到浏览器自动切换输入法状态或者检测到 caret 在某个特定窗口弹出候选词面板。这些逻辑都可以放在处理层用采集层给的结构化数据驱动。要把采集结果做语义处理走 TaoToken 的统一通道就行。短文本补全、焦点内容分类这类任务用模型对话页面先手动验证 prompt 效果地址https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。验证好了再写进 C 的 HTTP 调用里。如果你要做的是长期运行的桌面 Agent涉及多轮调用和状态管理直接上 Coding Plan 更省事入口https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。最后说个现实预期GetGUIThreadInfo这套方案在原生 Win32 控件上是可靠的在 DirectUI 和浏览器里拿不到 caret 是框架层面的限制不是你代码的问题。别在这上面死磕把拿不到当成一种正常状态处理你的工具反而更稳。真要继续深挖浏览器场景方向是研究各浏览器自己的 accessibility 接口或者 UI Automation那是另一条路了。