Easy-Tools v0.3.0:一个用 ctypes + PySide6 写的 Windows 桌面效率工具(中键动作面板 / 事件链 / 本地 OCR)
源码:https://github.com/FortC/Easy-Tools | License:Apache-2.0 | 版本:v0.3.0
技术栈:Python 3 + PySide6 + 纯 ctypes Win32 + llama.cpp | 代码量约 11.5k 行 / 26 个测试文件
下载地址
一、写在前面:为什么又造一个轮子
QWin、Quicker、AutoHotkey、Power Automate 都已经是成熟方案,这个项目并不比它们功能多。本文写给想自己实现一个桌面动作面板,或者想看一个完整桌面工具从 PRD 到发布的全过程的开发者。
Easy-Tools 的定位很窄,但目标明确:
| 设计约束 | 具体做法 |
|---|---|
| 触发要零成本 | 鼠标中键/侧键全局钩子,短按呼出、拖动保持原生滚动 |
| 面板要认上下文 | 两级识别:进程名/标题正则(L1,零成本)+ 本地 OCR(L2,带防抖缓存) |
| 能力要可编排 | 事件链 =触发器 → 条件 → 动作序列的 JSON 流水线 |
| 数据要留在本机 | 内置 GGUF 模型本地推理;OCR 可选依赖;云端调用需显式选择 |
| 依赖要少 | Win32 接入层不用 pywin32 / pynput,全部用标准库 ctypes |
二、目录结构
easy-tools/ ├── main.py # 入口:托盘、单实例、初始化顺序 ├── easy_tools/ │ ├── core/ │ │ ├── app.py # 应用生命周期、托盘、GUI 线程队列 │ │ ├── chain.py # 事件链引擎(节点调度、变量作用域) │ │ ├── context.py # 上下文识别:L1 进程/标题 + L2 OCR 融合 │ │ ├── ocr.py # OCR 服务(后台线程、防抖、结果缓存) │ │ ├── config_store.py # 配置读写、保存即生效 │ │ ├── question_bank.py # Excel 题库解析、列映射、检索索引 │ │ ├── builtin_model.py # 内置 GGUF 模型封装 │ │ ├── ccswitch.py # 外部模型配置导入 │ │ ├── update_client.py # 更新检查 │ │ └── actions/ │ │ ├── base.py # 动作基类 + 注册机制 │ │ ├── openers.py # 打开应用/文件/URL/命令 │ │ ├── text_input.py # 文本投递:粘贴 / 逐字键入 │ │ ├── clipboard.py # 剪贴板动作 │ │ └── processors.py # 40+ 可复用处理器(同时是链步骤) │ ├── win32/ # ★ 纯 ctypes 接入层 │ │ ├── mouse.py # WH_MOUSE_LL 低级钩子、按键判定 │ │ ├── input.py # SendInput 合成按键/文本 │ │ ├── window.py # 前台窗口、进程名、标题 │ │ ├── clipboard.py # 剪贴板读写 │ │ ├── gdi.py # 屏幕/窗口位图抓取 │ │ └── api.py # 结构体声明、错误码翻译 │ ├── ui/ │ │ ├── panel.py # 动作面板(搜索/Tab/分页/定位) │ │ ├── settings.py # 配置中心(14 页侧边导航) │ │ ├── chain_editor.py # 事件链可视化编辑器 │ │ ├── question_bank_page.py │ │ ├── capture_overlay.py # 框选遮罩层 │ │ ├── widgets.py # 自绘控件 │ │ ├── theme.py # 主题与 QSS │ │ └── icons/ # Lucide SVG 图标集(50 个) │ ├── config/ │ │ ├── schema.py # 配置结构定义 │ │ └── defaults.json # 默认动作 / 规则 / 事件链 │ └── diagnostics/ │ └── capture_workflow.py # 截图 → OCR → 提问 全流程 ├── installer/ # C# 编译的安装器(.NET Framework) ├── tests/ # 26 个测试文件 ├── docs/ # 设计方案 / 隐私设计 / 开发与发布规范 └── _doc/prd/ # 6 份需求文档(PRD)三、Win32 接入层:为什么不用 pywin32
这是整个项目最有立场的一个技术决策。
3.1 依赖问题
requirements.txt里刻意没有pywin32和pynput:
# 刻意不列 pywin32 / pynput:Win32 接入层用纯标准库 ctypes 实现# (见 easy_tools/win32/),理由见 easy_tools/win32/__init__.py。# 少两个原生依赖,就少两类"换 Python 版本装不上"的问题。实际收益:原生扩展(.pyd)是 Python 小版本绑定的。用户在 Python 3.11 / 3.12 / 3.13 之间切换时,pywin32 常常要重新编译或找不到匹配 wheel。ctypes 是标准库,跟着解释器走,不存在这个问题。整个 Win32 层(鼠标钩子、SendInput、窗口枚举、剪贴板、GDI 抓图)不到 1400 行,全是结构体声明 + 函数指针。
3.2 全局鼠标钩子
核心是WH_MOUSE_LL低级钩子:
SetWindowsHookExW(WH_MOUSE_LL,hook_proc,hmod,0)hook_proc必须是可持久化的函数指针,不能是临时 lambda——否则回调触发时已被回收,直接崩。项目里用一个模块级函数 +CFUNCTYPE持有引用:
HOOKPROC=WINFUNCTYPE(c_longlong,c_int,UINT,WPARAM,LPARAM)_proc=HOOKPROC(_mouse_proc)# 关键:模块级持有,防止 GC_hhk=user32.SetWindowsHookExW(WH_MOUSE_LL,_proc,hmod,0)3.3 短按 vs 拖动的判定
中键在浏览器/编辑器里是滚动,直接劫持会毁掉用户习惯。判定逻辑:
按下 → 记录 (x, y, timestamp) ├── 位移 < drag_threshold_px 且 释放时间 - 按下时间 < tap_timeout │ → 判定为「呼出面板」,并按配置决定是否 swallow(吞掉这次点击) └── 否则 → 完全交给系统,触发原生中键滚动drag_threshold_px和消费模式(native_passthrough/swallow_tap)都在配置中心可配。这个阈值是 v0.3.0 统一到所有触发键的——中键、X1、X2 走同一套判定逻辑。
对应的回归测试在tests/test_mouse_hook.py,钩子结构体声明有tests/test_win32_decls.py守着。
3.4 文本投递的两种实现
粘贴模式(兼容性最好):
backup=get_clipboard()# 1. 备份set_clipboard(text)# 2. 写入send_keys(Ctrl+V)# 3. 模拟粘贴QTimer.singleShot(delay,lambda:set_clipboard(backup))# 4. 延迟恢复第 4 步的延迟恢复是关键——立刻恢复会让目标应用还没读完就丢数据。
逐字键入模式:SendInput逐字符构造KEYEVENTF_UNICODE,天然支持中英文数字混排和换行(\r→ 回车)。速度可配基础间隔 + 随机抖动,按Esc随时中断。
四、上下文识别:两级融合
4.1 L1 — 进程名 + 标题正则(零成本)
fg=get_foreground_window()proc=get_process_name(fg)# → "WINWORD.EXE"title=get_window_title(fg)# → "报销单2026.docx - Word"直接读,耗时在微秒级。规则匹配支持进程名、标题正则、OCR 关键词三个条件。
4.2 L2 — 本地 OCR(带防抖和缓存)
# 窗口切换后防抖 ~300ms 再触发识别# 结果按 (hwnd, size, mtime) 缓存# 后台线程执行,面板弹出不等它关键设计:面板弹出不等 OCR。先按 L1 规则把面板渲染出来,L2 结果到达后再动态补充分组。所以识别再慢也不影响弹窗手感。
4.3 融合策略
{"processes":["WINWORD.EXE","wps.exe"],"match":{"titleRegex":"\\.docx?$","ocrKeywords":["插入","页面布局"]},"action_ids":["word.cleanup","word.trim","word.md_table"]}匹配到的动作组会排在公共动作之前——用户在 Excel 里,90% 的意图是处理表格。
五、事件链引擎
5.1 数据结构
{"id":"chain_trim_transpose","name":"整理选中材料","trigger":{"type":"manual"},"steps":[{"type":"action","id":"clipboard.copy","output":"sel"},{"type":"action","id":"text.drop_blank_lines","input":"{sel}"},{"type":"condition","expr":"has_table"},{"type":"action","id":"table.transpose","input":"{step2.output}"},{"type":"action","id":"input.paste","input":"{step4.output}"}]}步骤类型:action/condition/loop/wait/python/clipboard(处理器)。
变量系统:{clipboard}、{selection}、{ocr_text}、{stepN.output}、{var.name}在步骤间传递。
5.2 处理器复用
设计上的一个巧思:剪贴板处理器和事件链步骤是同一套东西。40+ 个处理器(大小写转换、日期格式化、千分位、去重、排序、表格转 CSV、Markdown 表格互转……)既能在面板里当按钮点,也能直接拖进链里当步骤。
# easy_tools/core/actions/processors.py# 注册的处理器同时是链的可执行节点case.upper/case.lower/case.title/case.camel/case.snake/case.kebab date.format/date.now/date.weekday number.thousands_sep/number.fixed_decimals/number.sum/number.strip_sep data.dedupe/data.sort_lines/data.padding/data.normalize_width/data.add_numbers table.transpose/table.csv/...5.3 Python 节点的隔离设计
Python 节点在独立进程中运行,有超时保护,卡死不会拖住主界面。
但必须说清楚:这不是沙箱。获授权的脚本拥有当前 Windows 账户的全部权限——能读写文件、访问网络、自己调PrintWindow截屏,甚至能绕过 Easy-Tools 对自身窗口设置的SetWindowDisplayAffinity排除。
所以 UI 上必须手动勾选信任:
运行授权 ☐ 我信任此脚本,并接受它使用当前 Windows 账户权限代码里对应的也是老老实实的双重检查(UI 状态 + 运行前再校验),不是装饰。
5.4 v0.3.0 修的事件链 Bug
这个 bug 挺典型,值得单独说:
- 链卡片根本没接点击/选中事件,所以「编辑选中 / 删除选中 / 立即运行选中」永远提示"请先选中"
- 编辑对话框关闭时引用了
QDialogButtonBox.Accepted——Qt 里根本没有这个常量(只有Accepted),直接抛AttributeError崩溃
修法:卡片单击选中(高亮)、双击直接进编辑;常量改正。
教训:PySide6 里枚举是Qt.ItemDataRole.UserRole这种形式,不是QDialogButtonBox.Accepted。这类错误 IDE 不会提示,只能靠 review 和测试兜住。
六、隐私设计:写成硬约束,不是口头承诺
docs/privacy-design.md里明确写了适用规则,且规定新增功能不得绕过:
6.1 窗口捕获排除
开启隐私模式后,所有顶层窗口继承CapturePrivacyFilter的捕获排除状态(基于SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE))。
关键约束(也是大多数工具做不到的):
Easy-Tools 发起截图、OCR、Canvas 等内部流程时,不得临时移除自身窗口的捕获排除属性。
很多工具的"隐私模式"是靠截图时临时取消排除实现的,然后忘记加回去——结果就是自己截到了自己的面板。这里明确禁止了这种做法。
新建对话框必须由 Qt 应用持有,让全局过滤器能观察到显示事件,不允许创建绕过过滤器的独立原生窗口。
6.2 能力边界写清楚
不吹牛的部分:
- 只覆盖受支持的 Windows/DWM 捕获 API
- 不能防止直接读进程内存、辅助功能接口读取、外部摄像设备拍摄
- Easy-Tools 自己发起的截图仍可处理目标应用内容
6.3 数据流
| 数据 | 默认去向 |
|---|---|
| OCR 文本、选区、剪贴板、知识库内容 | 仅本机 |
| 云端模型调用 | 用户显式选择后,问题/OCR 内容/检索片段离开本机,界面明确提示 |
| CC Switch 凭据 | 仅调用时只读,不写入配置 / 常规日志 / 错误弹窗 |
| 调试日志 | 不记录完整问题、OCR 内容、剪贴板内容、文件内容、令牌 |
七、UI 工程:PySide6 踩过的坑
7.1 GUI 主线程绝不阻塞
这是ET-PERF-001的核心验收点,起因是用户反馈"偶发无响应"。排查后定的规矩:
- 题库导入、表头读取、配置确认 → 移到有上限的后台任务
- GUI 列表刷新不重新读 Excel(只读内存中的已解析数据)
- OCR 和内置模型限制 CPU 并发,给界面留资源
- 模型启动/停止不在 GUI 线程等长锁
- 重复提问/截图任务不无限积压,显示"处理中"状态
7.2 托盘启动的初始化顺序
PRD 里记了一条很具体的约束:
启动时托盘通知槽必须先定义后连接,回调再次检查隐私通知开关;最终 EXE 的正常启动路径能进入托盘就绪阶段,不出现未定义回调异常。
这种 bug 只在特定时序下出现,测试很难覆盖,所以直接写进验收点。
7.3 图标系统
v0.2.1 把全站 emoji 换成了 Lucide SVG:
- 50 个矢量图标(ISC 许可,免费可商用)
- 动作面板 27 个默认动作、面板顶部设置/关闭按钮、复选框勾选样式全部替换
- 配置中心 14 个页面各配语义对应图标
- 高 DPI 屏按 2 倍像素渲染(
QIcon+QPixmap手动setDevicePixelRatio)
图标加载做了优雅降级——图标文件缺失时条目照常创建,只是没图标,不阻塞启动:
mark=self._MARKS.get(title)ifmarkandicons.has_icon(mark):item.setIcon(icons.icon(mark,"#475569",18))7.4 显示层和存储层分离
easy_tools/ui/labels.py是个很典型的设计。原始配置中心直接把内部代码甩给用户:
类型 chain.run 参数 {'snippet_id': 'snip_a', 'mode': ''}这个模块专门做「翻译」:
defkind_label(kind):# chain.run → "运行事件链"defprocessor_label(pid):# text.strip → "去除首尾空白"defmode_label(mode):# "type" → "逐字键入"边界写得很清楚:只做翻译,不改存储格式。用户看到的中文绝不会被写回配置。找不到映射时如实显示原文,绝不假装认识——宁可漏翻,不能错翻。
八、题库检索的一个设计细节
知识库导入 Excel 后,需要配置列映射:题目、题型、正确答案、答案内容、选项列。
其中「仅显示答案内容」这个选项值得单独说。
很多题库的选项顺序是随机的(同一道题,A/B/C/D 的内容每次都不一样)。如果按B去匹配答案,命中率会随机下降。
勾上这个选项后,工具直接显示正确答案对应的那段内容(即答案内容列的值),而不是字母。选项打乱也不影响。
这个功能明显是实际用过题库才加得出来的——它是业务知识的产物,不是技术推导的产物。
性能上做了预计算:题干规范化 + 词项索引在导入时算好,配置或文件变化后重建索引,移除题库时释放缓存。避免每次查询都遍历 Excel。
九、安装器:.NET FrameworkC# 单文件
installer/下是一个用 C# 写的安装器(csc.exe编译),不走 NSIS/InnoSetup。
它解决的问题比"拷贝文件"多:
- 升级识别:同时查注册表记录 + 常规旧版本目录(默认目录、临时目录)
- 精准结束进程:只结束该安装目录下的进程,不误杀同名程序
- 配置保留:覆盖升级时保留用户配置(APPDATA)和非程序文件
- 旧文件清理:对比安装清单,清理新版本已移除的旧文件
- 卸载安全:按清单删除 +校验绝对路径边界;保留清单外文件和用户配置;注册表和快捷方式只清理归属当前安装目录的项
- 不使用递归 CMD/PowerShell,不保留后台重启循环,清理重试有上限
打包产物:build.py(PyInstaller,目录模式,~451MB 含模型)+build_installer.py(单文件安装器,~408MB)。模型权重models/*.gguf因超过 GitHub 单文件限制不入库。
十、开发流程:PRD 驱动
_doc/prd/下 6 份需求文档,每份都有编号和验收点:
| 编号 | 主题 |
|---|---|
| ET-UX-001 | 动作面板与文本输入 |
| ET-EVENT-001 | 事件链构建器 |
| ET-QA-001 | 截图题库问答 |
| ET-PRIV-001 | 窗口捕获隐私 |
| ET-PERF-001 | 卡顿排查与稳定性 |
| ET-UPDATE-001 | 灰度发布 |
docs/开发与发布规范.md定义了完整的版本发布和 GitHub Releases + DownloadCenter 灰度流程。
有意思的一条约定:PRD 与记录不由 AI 标记为完成。工具能写代码能改文档,但"需求是否真的验收通过"得人来判。
十一、快速上手(开发者)
git clone https://github.com/FortC/Easy-Tools.git cd Easy-Tools python-m pip install-r requirements.txt# 可选:OCR 后端(不装照常运行,配置中心会显示"不可用 + 原因")python-m pip install rapidocr_onnxruntime python main.py打包(Windows,需 PyInstaller + .NET Framework C# 编译器 + 模型文件):
python build.py# 目录程序python build_installer.py# 单文件安装器跑测试:
python-m pytest tests/-q十二、小结
Easy-Tools 不是功能最多的桌面效率工具,但有几个实现值得抄:
- 纯 ctypes 消灭原生依赖问题——1400 行换来跨 Python 版本零摩擦
- 短按/拖动判定——劫持全局按键时保住原生行为的正确做法
- 两级上下文识别 + 面板不等 OCR——识别精度和弹窗速度可以兼得
- 处理器与链步骤同构——一份实现,两个入口
- Python 节点进程隔离 + 显式信任勾选——诚实地说明边界,而不是假装沙箱
- 隐私写成硬约束文档——禁止"截图时临时取消自身排除"这类偷懒实现
如果你也在写 Windows 桌面工具,这几个坑可以避开。
联系方式:lamthebest@foxmail.com | 欢迎提 Issue 和 PR