我一直想要一个「桌面上的活物」。
不是那种会弹广告的助手,也不是开机自启、占着一整块屏幕的小组件,而是一个安安静静待在角落、偶尔动一下的东西。写代码的时候余光瞥见它在伸懒腰,那种感觉和盯着一个静止的图标完全不一样。
所以今年我给自己立了个项目:一只像素风的猫,常住桌面右下角,不打扰人,也不联网。
按现在的惯例,这种桌面小工具的第一反应都是 Electron。我也是这么开始的。
第一个版本就撞了墙
Electron 的 Hello World 在我这台机器上从来没有起来过。程序启动瞬间退出,日志里只留下一行:
FATAL:platform_channel.cc(83) Check failed: . : 拒绝访问。 (0x5)退出码是 -2147483645,换算成 Windows 的状态码是 STATUS_BREAKPOINT。
我查了一阵,没能定位到根因。这里要说清楚,这很可能只是我这台机器的个例,跟组策略、安全软件或者权限配置有关,不代表 Electron 本身有什么问题。但对当时的我来说,结论很实际:我需要一个在这台机器上一定能跑起来的方案,而我连它为什么起不来都说不清。
于是我把 Electron 整条路砍了。
退路变成了正路
砍掉 Electron 之后,剩下的选择其实不多。我最后选了一个很不现代的搭配:PowerShell 5.1 加 WinForms,单进程,没有构建步骤,没有第三方依赖。
理由只有一个,Windows 自带。任何一个装了 Windows 10 或 11 的机器上,这段代码都能直接跑,不需要用户先装运行时,不需要我打包一个几十兆的环境,也不需要我解释「为什么它会报错」。
事后回看,这个选择意外地合适。主程序是一个 .ps1 文本文件,十一万字符,两千四百多行,用记事本就能打开;发布包是一个 93 KB 的压缩文件,解压双击就能用,没有安装程序;内存常驻在几十兆的量级,比浏览器的一个标签页还轻。
代价当然也有。它是解释执行的,启动时有一个短促但肉眼可见的延迟。更要紧的是,PowerShell 这门语言的边角行为比我想象的多得多,这一点我在后面单独写了一节。
让一个窗口看起来不像窗口
桌面宠物的第一个技术问题,是怎么让窗口不像窗口。
WinForms 里做这件事主要靠三个设置。窗体边框设成无边框(FormBorderStyle 设为 None),这样就没有标题栏和边框。再把 TransparencyKey 设成一个几乎不会被用到的颜色,比如 RGB(1, 2, 3),凡是被画成这个颜色的像素都会变成透明的洞。
但只做这两步会有明显闪烁,拖动的时候尤其明显。原因是重绘:窗口每移动一次,都要先擦掉旧背景再画新内容,中间那一帧就露馅了。要压住它,得打开 OptimizedDoubleBuffer,同时把 AllPaintingInAllStates 和 UserPaint 一起打开,让所有绘制都走我这一个入口。三样加在一起,猫拖动起来才是连续的。
顺便说一个和隐私有关的取舍。不少同类工具会截取屏幕来判断桌面状态,我一开始也想过这条路。但我发现这台机器上 Graphics.CopyFromScreen 拿到的位图是过期的,它返回的是旧壁纸而不是当前画面,于是我干脆放弃了这个方向。现在这只猫对屏幕内容一无所知,它只知道鼠标在哪里。
钩子只挂了一半
猫要能被人叫醒,所以需要一个全局鼠标钩子。用 SetWindowsHookEx 挂上 WH_MOUSE_LL,就能拿到系统级的鼠标事件流,代价是必须在很短时间内处理完并交还给系统,否则整台机器的鼠标都会发涩。
我在这个回调里只判断一种消息,WM_LBUTTONDOWN。右键、滚轮、移动全部原样放过。
这么写不是因为我懒,而是因为碰得越少越好。全局钩子本质上是在别人的输入路径上插一脚,每多判断一个消息,就多一份把别人的操作弄出问题的可能。右键对这只猫没有任何用途,那就不该去碰它。
猫是怎么动起来的

动画素材来自一个以 CC BY 3.0 授权的像素猫素材包,一共十几个动作序列。它们被切成帧,按状态播放:待机、伸懒腰、撒欢、跳舞,还有被摸头时的一小段反馈。
状态由两部分共同决定。一部分是时间,长时间没人理它,它会自己发呆。另一部分是你的互动方式,点她一下是摸头(点头顶 +2、点身体 +1),双击她会回到屏幕中央,按住拖动可以把她拎起来、松手时落下去,落地之前有一小段下落动画。
这些互动会积累一个好感度数值,分成六档。从陌生开始,随着互动次数增加,依次变成熟悉、朋友、好朋友、挚友,最后是心之友。这个数值连同它的互动历史,存在程序目录下的一个 JSON 文件里,不联网,也不上传。
我在 PowerShell 上踩的五个坑
这一节可能是全文最有用的部分。如果你也打算用 PowerShell 写点带界面的小工具,这几个坑我替你踩过了。
第一个是编码。PowerShell 5.1 读取不带 BOM 的 UTF-8 脚本文件时,会按 ANSI 去解码。这意味着文件里所有的中文都会变成乱码,而更麻烦的是,乱码之后的字节可能刚好吃掉后面的引号,让整个文件的语法直接崩掉。最要命的地方在于,报错信息本身也是乱码的,你看到的是一串没人能读懂的东西。解决办法只有一个:把脚本存成带 BOM 的 UTF-8,并且在运行之前用 Parser::ParseFile 先做一次语法检查。
第二个跟 -like 操作符有关。它把方括号当作通配符的字符集来处理,所以 English 这种写法永远匹配不上任何东西。我因为这个写错过一次检查脚本,还据此得出过「文件里没有这一行」的错误结论。凡是要做字面子串判断的地方,老老实实用 .Contains()。
第三个是 New-Object 后面的括号。写 New-Object System.Drawing.Rectangle(985 - [int]($cw / 2), 0, 10, 10) 时,我以为括号里是算术表达式,实际上 PowerShell 把它解析成了多个参数,然后报出一个完全看不懂的错误:[System.Object[]] 不包含名为 op_Subtraction 的方法。正确做法是先把坐标算进两个变量,再传进去。
第四个比较隐蔽,它坑掉了我一个真实的推送脚本。git push 会把正常进度写在标准错误流上,比如 To https://... 和 * [new branch]。如果脚本开头设了 $ErrorActionPreference = 'Stop',PowerShell 会把这段标准错误提升成终止性错误。结果就是脚本已经把代码推到了第一个远端,然后在接收输出的那一刻当场崩溃,第二个远端一次都没轮到。修法是在调用外部命令前后把 $ErrorActionPreference 临时放回 Continue,并且只以 $LASTEXITCODE 判断成败。
第五个最阴,因为它根本不报错,只是行为不对。我给一个函数的参数起名叫 $Args,而 $Args 在 PowerShell 里是自动变量,保存着函数自己的未绑定参数。于是 @Args 展开出来的不是我传进去的数组,而是空的,那条命令最终被调用成 git -C <仓库路径>,没有子命令,git 只会打印一段用法说明。
这五个坑没有一个是聪明的坑,全是语言的边角行为。写代码本身可能只占三成时间,剩下七成花在弄明白「它为什么会这样」上。
它现在还不是什么

我不想把这篇写成产品通稿,所以缺什么我直接说。
AI 对话没有做,而且不打算做了。最初的项目文档里它是核心功能,但要做成它,使用者就得先去模型服务商注册账号、充值、再拿一个密钥填进配置文件。一只桌面宠物如果要求玩它的人先注册再付钱,那它就不再是「双击就能用」的东西了。我宁愿它少一件事,但这件事必须是白送的。所以它现在只会按内置的台词说话,不会真的聊天,这是想清楚之后的取舍,不是没做完。
它没有代码签名,所以从网上下载之后会触发 Windows 的 SmartScreen 警告。这是个人项目的常态,一张代码签名证书一年要几百到几千元,而对这个规模的项目来说,让使用者多点一次「仍要运行」更划算。如果你看到那个蓝色警告框,选「更多信息」,再选「仍要运行」,就能打开。
它只支持 Windows。整个方案建立在 WinForms 和 Win32 API 上,换平台等于重写。
此外还有一些小的。托盘图标现在只是一个 16×16 的青色椭圆;开机自启要自己把快捷方式放进启动目录;数据存在程序目录里而不是用户目录,换机器要手动搬一次。
关于数据和联网
这一点我特意说清楚:这只猫没有任何联网代码。整个工程里搜不到 HttpClient、WebClient 之类的字样。它不请求网络,不上传数据,也没有遥测。它只做三件事:画自己,听鼠标,在你点她的时候加一点好感度。
唯一的文件写入,是程序目录下的一个数据文件夹,里面是它的状态记录和一个日志文件。
顺便说一个我特意量过的数。我这台机器是 20 线程的,她空闲的时候大约占单核的百分之零点四到零点八,换算成整机大概是百分之零点零二 —— 任务管理器里就是 0。内存六十兆上下,每一帧的绘制成本约零点一二毫秒。她动起来的时候和发呆的时候几乎一样,因为负责重绘的心跳本来就在以每秒约六十三次的频率跑着,换一个动作只是换一张图。

三件只有我自己看得见的错
发布之后最有用的反馈,是三件从截图上完全看不出来的事。
第一件,她头顶上方有一大片区域是能点的。窗口是 340×460,可她的画只有 126×210,而我最初没有把窗口其余的部分填成透明键色,于是在 Windows 眼里那一整块都属于她 —— 你明明点的是桌面,落到她身上的却是摸头。修法是每一帧先用键色把窗口铺满,再把命中判定从矩形改成按当前这一帧的像素去认。
第二件,她被拖不到屏幕的最上面。拖动的时候我把窗口的顶端夹在屏幕上边缘,可她的头画在窗口顶端下方两百多像素的地方,于是永远差那么一截,耳朵碰不到顶。现在窗口允许伸出屏幕之外,留出的余量按她最高的一套动作来算。
第三件,右键菜单里「大小」那一栏的勾,永远停在程序启动时的那一档。因为菜单是启动时构建的,勾也是那一刻算出来的,之后再没更新过。现在每次打开子菜单都按当前缩放重新算。
三件事都不影响功能,但都会让人在第一次用的时候觉得哪里不对劲,而这类感觉很难被写成一句 bug 报告。我很感谢第一个愿意写下来的人。
关于
项目以 MIT 许可开源,源码在 GitHub(https://github.com/wt0812/desktop-cat),国内有一个 Gitee 镜像(https://gitee.com/wutong2005/desktop-cat)。动画素材来自 CC BY 3.0 授权的素材包,署名写在仓库的 CREDITS 文件里。
如果你也在 Windows 上用一些不太正统的技术做过小工具,欢迎聊聊。
本文在润色环节使用了 AI 服务辅助写作,提示词为「按少数派风格指南把技术笔记整理成第一人称文章」。笔者已对上述内容做进一步检查和调整。
