AutoWrite 自动打字机开发记:从模拟按键到发送消息
总有些场合,文字得靠键盘一个字一个字”敲”进去——目标输入框不吃复制粘贴,或者你只是想让朋友亲眼看着一段话”自己”出现在聊天框里。AutoWrite 就是干这个的:你把文本交给它,它替你把每个键按下去。项目做了两版,正好是 Windows 下模拟输入的两条技术路线——第一版把自己变成键盘,第二版直接给窗口发消息。第一版整理后传上了 GitHub:AutoWrite(MIT 协议)。
第一版思路:把自己变成键盘
最早的版本(V0.0.1,2023 年 4 月)核心只有一个念头:程序不碰目标窗口,只负责假装自己是键盘。Windows 提供的 keybd_event 就是干这个的——给它一个虚拟键码(VK code),系统就当作这个键被按了一下:
1 | void write_word(int vk)//输入数字、字母 |
按下、弹起,两个事件,一个键就”敲”完了。字母数字都好办,麻烦的是符号——它们和主键区不是一对一的关系。比如 * + 这类键,键盘上根本没有独立的”*键”,它们住在数字小键盘上,虚拟键码是另一套:
1 | void WriteCharacter(int vk)//输入符号 |
ASCII 码 42(*)要翻译成小键盘的 106(VK_MULTIPLY),45(-)对应的是 VK_OEM_MINUS——这张”ASCII 到虚拟键码”的对照表,就是自动打字机的第一块地基。
大小写是另一课。键盘上字母键只有一把,大小写靠的是 Shift 这个修饰键:
1 | void ShiftDown()//按下shift |
AutoWrite 主循环把整套逻辑串起来:遍历字符串,按字符类别分发到上面三套机制:
1 | void AutoWrite(std::string word)//输入一串句子 |
这里藏着自动打字机的第一课:write_word(zm - 32) 把 'a' 换成 'A' 的键码发出去——不带 Shift 按字母键,出来的就是小写。键盘事件的世界里没有”小写键码”,只有”字母键码 + Shift 状态”的组合;真正需要 Shift 陪跑的反而是大写字母。符号分支的守卫条件 zm>41 && zm<48 用的还是 ASCII 码区间——42 到 47 正是 * + , - . / 一族。
每敲一个键 Sleep(10)——太快了有些程序的消息循环跟不上,会丢字。这个”每个键之间留一口气”的节奏问题,后面还会回来找两次麻烦。当时的 main 也特别直白:两秒倒计时之后,打 10 遍 nihao,每遍跟一个回车,纯粹的开机自检。
1 | int main() |
升级成正经程序:GUI 版
命令行版能用,但每次都得先点开目标窗口再切回终端回车,手忙脚乱。于是到了 V0.0.x 的后期,它长成了一个大方的 Win32 程序:窗口里一个多行文本框,把想打的字粘进去,点一下”点我开始”,然后你有两秒钟把光标移到真正的目标输入框里,剩下的事交给程序。
1 | case WM_COMMAND: |
这里有个容易忽略的细节:拿文本框内容不能直接读,得先问长度、再按长度开缓冲区——GetWindowTextLength + malloc(n+1) + GetWindowText 三连。早期的版本这里是个写死的 2048 字节数组(更新记录 V0.0.1 里”增加 buffer 区设置为 2048”就是它),超长文本直接爆,V0.0.2 改成了动态申请,才有了这段标准三连。
按键的核心也从 keybd_event 换成了新一代的 SendInput:
1 | void write_word(unsigned char vk) |
注意注释里保留的老写法——keybd_event 和 SendInput 干的是同一件事,但 SendInput 把一批按键事件打包成原子操作一次性注入,中间不会被真实的键鼠输入插队;keybd_event 是一次一个事件,两个调用之间你的手如果真的碰了键盘,序列就被污染了。对自动打字机来说这个区别是实质性的,所以换代,老代码留着当注释当纪念。
这一版的完整代码在仓库里:
1 |
|
这一版还有两个工程化的小升级值得记一笔。图标不再从文件加载:早期注释里的写法是运行时 LoadImage 去 读 MYICON.ico,图标文件和 exe 分家,发给别人就丢图标;后来改成 .rc 资源脚本把 ico 编进 exe,LoadIcon(MAKEINTRESOURCE(IDI_MYICON)) 直接从模块里取。控制台窗口被藏起来:ShowWindow(GetConsoleWindow(), SW_HIDE),编译出的程序是个纯粹的窗口应用,背后不带一个黑框。
更新记录:五个版本号,五个 bug
这个项目的文件夹里一直躺着一份手写的更新记录,是最朴素但也最真实的开发日志:
1 | AutoWrite V0.0.1 |
五条记录里有三条是”某类字符打不出来”:数字、最后一个字母、单引号。这正是模拟键盘路线的宿命——每一种字符都得有人替它安排好按哪个键。数字忘了接、循环边界少走一步、引号这种藏在 VK_OEM_7 里的冷门键,谁没安排谁就打不出来。V0.0.5 把单引号补进 WriteCharacter 的 switch 之后,这个字符集就冻结了:字母、数字、空格、-、.、',GUI 里的合法性校验也是按这张表来的。
V0.0.2 的”静态编译 dll 办法”值得单独说:程序在自己电脑上跑得好好的,发到别人电脑上弹”缺少 xxx.dll”——动态链接的运行库别人机器上没有。-static-libgcc -static-libstdc++ 把运行库直接打进 exe,体积大一点,换来双击就能跑。
第二版:换掉整个输入模型
键盘模拟用了一年多,2024 年年中重写时我决定换掉整个模型。模拟键盘的本质是往系统级输入队列里插键,字打给”当前焦点”——它快不了(每键之间要 Sleep),也精准不了(焦点飘了就打错地方)。更优雅的思路是:拿到目标窗口的句柄,直接把字符消息塞进它的消息队列,绕过键盘状态,定点投递。
定点投递的第一步是拿到”真正有输入焦点”的那个控件句柄。直接 GetForegroundWindow 拿到的是主窗口,而光标闪动的往往是里面的子控件(比如聊天输入框)。这需要一个不太出名的 API 三连:
1 | // 获得当前激活的窗口句柄 |
GetFocus 只能拿到自己线程内的焦点窗口,对别的线程无能为力。AttachThreadInput 把自己的线程暂时”挂”到目标窗口的输入线程上,共享同一份输入状态——这时 GetFocus 就能拿到对方线程里真正闪着光标的控件了。用完立刻解除附加。这是 Win32 时代留下的老技巧,MSDN 对它的警告一大堆,但”拿别家程序的焦点控件句柄”这一件事,至今没有更体面的办法。
拿到句柄之后,字符就不用”按键”了,直接 PostMessage 投递:
1 | for(int j = 0; j < strlen(s); ) |
ASCII 字符走 WM_CHAR,一个字节一步;(s[j] & 0x80) >> 7 判断最高位是不是 1——GBK 编码下中文每个字节的最高位都是 1,这是区分中英文最朴素也最好用的办法。是中文就走 WM_IME_CHAR,一次投递两个字节。
而这两个字节的组装方式,是整个重写版里最有趣的一段:
1 | unsigned long long int transmit(char* s) |
一行看着像乱码的位运算,干的事是字节对调。拆开看:GBK 编码的”测”是 0xB2 0xE2,在内存里按顺序躺在 s[0]、s[1]。小端机器把这两个字节读进 int,低地址在低位——& 0xFF 取出来的是 s[0],& 0xFF00 >> 8 取出来的是 s[1]。WM_IME_CHAR 的约定是 wParam 高 8 位放首字节(lead byte),低 8 位放次字节,而内存顺序恰好相反,所以要把两个字节换个位置再拼起来:s[1] 原地取高八位腾地方,s[0] 左移八位坐进去,0xB2E2 完成对调。没有这步对调,目标窗口收到的每个”中文”都是乱码。
这个模型的字符串节奏也和键盘版完全不同:Sleep(1) 甚至可以更激进,因为消息投递没有键盘缓冲区排队的压力;回车单独用 SendInput 模拟——字符走定点投递,而”回车”往往要触发的是对话框层面的默认按钮(比如聊天窗口的”发送”),那是主窗口的消息循环在处理,模拟一个真实的回车键最保险。
字库:把要打的字和打字机分开
重写版还有个工程上的小进步:要打的文本不再写死在 main 里,而是集中放到一个”字库”头文件 AutoWriteFont.h 里,用字符串数组组织,一行一句,空字符串结尾:
1 | char *guisuishou[] = { |
诗、段子、长文本各占一个数组,main 里想打哪段就传哪段。字库和打字机分离之后,加新内容不用碰任何逻辑代码。
长文本还有个实际问题:一次性灌进去,中间的标点不该停顿吗?重写版自己写了一个 my_strtok——标准库 strtok 只认单字节分隔符,全角的”,!。?”在 GBK 里是两个字节,硬切会把汉字腰斩。自研版在比较时多加一条:两边都是全角标点区间(0xFF01~`0xFF5E`)的字符也算匹配,然后把长文按”,!。?”切成句子,一句一句投递。这个函数是先单独扔进 test.c 里测通了,才搬回主程序的——先写测试再集成,对这种字符串切片函数来说性价比极高。
两条路线,各自的位置
| 第一版:模拟键盘 | 第二版:消息投递 | |
|---|---|---|
| 原理 | SendInput 往系统输入队列插键 |
PostMessage 直达控件消息队列 |
| 打给谁 | 当前焦点窗口(谁有焦点打谁) | 指定句柄(指哪打哪) |
| 中文 | 要先切输入法,靠 IME 逐字上屏 | WM_IME_CHAR 直接投双字节 |
| 速度 | 每键 Sleep(10) 起步 |
Sleep(1) 都算奢侈 |
| 盲区 | 无——任何程序都当真键盘 | 只对标准 Edit 类控件生效 |
模拟键盘是”万能的笨办法”:慢,但所有程序都把它当真键盘,自绘输入框、游戏、甚至 BIOS 级别的界面都吃这一套。消息投递是”聪明的小路”:快、准、能打中文,但离开标准控件就失灵。AutoWrite 两版都留着——工具箱里多一把枪,永远不亏。
第二版的完整代码和字库目前躺在本地工程里,仓库里的是第一版的定稿。如果这个从”假装键盘”进化到”直接写信”的小东西对你有启发,源码在 AutoWrite(MIT),随便玩。
