# snap **Repository Path**: destiny001/snap ## Basic Information - **Project Name**: snap - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-11-04 - **Last Updated**: 2026-11-04 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Snap 离线截屏工具。Electron + 零构建(主进程和渲染层都是原生 JS,没有打包器)。 ## 屏幕安全模型(最重要的一节,动 overlay 之前先读这个) v0.1.1 出过一次严重事故:按热键后**满屏纯黑、Esc/Enter/拖拽全部无反应、 整台电脑无法操作,只能硬重启**。 根因是渲染层 `overlay.js` 里的一行顶层声明: ```js const snap = window.snap; // ← 致命 ``` `contextBridge.exposeInMainWorld('snap', ...)` 在全局对象上建立的是**不可配置属性**, 顶层 `const` 与之同名会抛**解析期** `SyntaxError`,整个脚本一行都不执行: - `onInit` 没注册 → 冻结图从未赋值 → 窗口只有 `backgroundColor`,纯黑 - `keydown` 没注册 → Esc、Enter 全失灵 - `mousedown/mousemove` 没注册 → 拖拽毫无反应 而 overlay 是盖住几乎全部屏幕、层级 `pop-up-menu` 的窗口,**「强制退出」对话框被画在它下面**, 托盘菜单栏同样被盖住 —— 所有逃生路径同时失效。(2026-11-03 起顶上那条**活菜单栏**不再被盖住了, 见「为什么不藏菜单栏 / Dock 了」—— 那是多出来的一条逃生路径,5 层保险一个没少。) 修掉那一行只是治标。真正的教训是:**只要「渲染层失效」还能变成「用户被锁死」, 同类事故就一定还会再来。** 所以现在有 5 层互不依赖的保险: | # | 机制 | 挡住什么 | |---|---|---| | 1 | **显示闸门**:窗口从 `show:false` 起步,只在渲染层回 `overlay:ready` 后才显示 | 渲染层死了 → 窗口永远不出现 → 用户完全无感 | | 2 | **看门狗**:3 秒内没收齐所有屏幕的 ready 就整场取消 | 渲染层卡死、脚本解析失败 | | 3 | **Esc 三层兜底**:渲染层 keydown / 主进程 `before-input-event` / 截图期间临时注册的**全局** Esc 热键 | 页面脚本死了(前两层都失效)时,连键盘焦点都不需要就能退出 | | 4 | **渲染层异常上报**:`window.onerror` + `unhandledrejection` → `overlay:fail` | 运行期 JS 错误 | | 5 | **进程级兜底**:`render-process-gone` / `unresponsive` / 抢不到键盘焦点 → 立即取消 | 渲染进程崩溃、窗口拿不到焦点 | 另外两处:底色不再是纯黑(`#1c1c1e`),以及捕获到的图若**全黑**会被拦下并转走授权提示 (`nativeImage.isEmpty()` 只查尺寸,纯黑图完全能通过它,所以必须自己采样像素判断)。 > ⚠️ 两条不能碰的红线: > 1. `src/renderer/overlay.js` 里**绝不**声明名为 `snap` 的顶层绑定。 > 2. **绝不**在渲染层回 `overlay:ready` 之前 `show()` overlay 窗口。 > > 还有一条容易忽略的:`endSession()` 是唯一收尾入口,必须幂等 —— > 全局 Esc 热键漏注销的话,会一直吞掉整个系统的 Esc 键。 ## 自测与安全演习 改完 overlay / 贴图 / 标注相关代码,**先跑这些**,别直接开 App: ```bash pnpm verify # 下面四个一起跑 pnpm selftest # overlay 渲染层 154 项断言,含工具栏、属性栏、标注、马赛克像素级验证、挪框、改大小、自绘光标与入场提示 pnpm hotkey-test # 热键纯逻辑 72 项:校验/黑名单/显示/菜单文字(普通 node,不用起界面) pnpm settings-selftest # 设置窗口 30 项:真实页面 + IPC 频道接线一致性 pnpm hotkey-e2e # 真起应用的端到端 16 项:写盘→重启读回→菜单显示 pnpm pin-selftest # 贴图窗口 16 项:层级红线(静态扫描)+ 尺寸/图/关闭路径 pnpm drill:dead # 安全演习:渲染层必死 → 断言看门狗兜住、窗口一次都没显示 pnpm drill:live # 安全演习:正常路径 → 断言所有屏幕都就绪并显示 ``` 全部用退出码给结论(0 = 通过)。`selftest` 的第二相会**故意重新制造**最初那个 `const snap` 命名冲突,断言 `ready` 永不回告 —— 这正是看门狗赖以工作的判据。 它同时证明了一件反直觉的事:**解析期错误连 `window.onerror` 都触发不了** (上报数为 0),所以看门狗不是"多一层保险",而是唯一能兜住这类故障的机制。 `pin-selftest` 里有一半是**静态扫描**,因为 Electron 没有「读出窗口当前层级」的 API,只能从源码上守:断言 `main.js` 里不出现 `'screen-saver'`,且 `createPin` 用的是 `'floating'`。这条红线就是上次锁住整台电脑的那个层级,宁可用字符串扫描守着。 ### 演习为什么不直接跑 electron,而是套一层 `tools/drill.js` 因为 `verify` 曾经**一直是假绿的**。`electron . --drill=dead` 有两条「静默退出、 退出码 0」的路径,而 pnpm 只看退出码: 1. 受限 shell 里 Chromium 自己的沙箱初始化失败,进程直接退出: `sandbox initialization failed: Operation not permitted` 2. **Snap 是常驻托盘的。只要它已经在跑,`requestSingleInstanceLock()` 就返回 false → `app.quit()`,新起的进程(也就是演习)什么都不做就退出 0。** 第 2 条几乎必然发生 —— 平时托盘里就开着 Snap。所以 `tools/drill.js` 做了三件事: - 给演习一个**独立的 `--user-data-dir`**(单实例锁按 userData 路径算,绕开第 2 条) - 带上 `--no-sandbox`(绕开第 1 条) - **必须**在输出里看到 `[drill/] PASS` 才算过;只要没看到就判失败, 并明确说「这意味着演习根本没跑到结论,而不是通过了」 这条守卫自己也被测过:用一个「静默退出 0」的假 electron 跑,必须返回 1。 一个没被测过的守卫,正是当初 verify 假绿的成因。 同理,`selftest` / `pin-selftest` 也带 `--no-sandbox` —— 否则在受限 shell 里 Chromium 的 GPU 进程会 SIGTRAP 崩掉。它们会**响亮地失败**(不是静默通过), 但带上这个参数就能在任何 shell 里真跑。 `drill:dead` 是这里最有说服力的一个:它证明「渲染层彻底死掉」这条路径下, 用户屏幕上**不会出现任何东西**: ``` [snap] 截图结束: ready-watchdog [drill/dead] 结束原因=ready-watchdog 显示过窗口=0 看门狗触发=true [drill/dead] PASS ``` 两个演习都用合成图,**不需要屏幕录制权限**,所以随时可跑。`drill:live` 会真实显示 overlay 约 2 秒后自动收场,另外带一个 8 秒的进程级硬超时(进程一死窗口随之消失)。 #### 判据是「每块屏都得亮出一个窗口」 `drill:live` 的判据是 `显示过窗口 === 屏幕数 && 没盖满 === 0`,日志里两个数都打出来: ``` [drill/live] 结束原因=drill-live-complete 屏幕=2 显示过窗口=2 看门狗触发=false 没盖满=0 [drill/live] PASS ``` 原来只写了 `显示过窗口 > 0`,在**多屏下有个洞**:第二块屏的 overlay 要是压根没起来, `显示过窗口` 仍然是 1,而 `没盖满` 也是空的(只有真的显示过的窗口才会被量覆盖几何), 于是演习照样 PASS —— 用户看到的是「一块屏正常、另一块屏完全没反应」。 2026-10-30 接上外接显示器(2560x1440 @1x 主屏 + 内建视网膜 @2x 副屏、副屏在负坐标) 之后这个洞才暴露出来,所以收紧成「必须等于屏幕数」。 两条反向测试都做过,确认这条判据不是空的:谎报屏幕数 → `屏幕=3 显示过窗口=2 ... FAIL`; 让第二块屏不显示 → `显示过窗口=1 屏幕=2 ... FAIL`。 这套双屏(**两块屏缩放比不同、副屏 x 为负**)现在是真的在跑,正是两轴换算最容易出错的 场景 —— 它不会再因为「只有一块屏」而被跳过。 #### 演习里的时间线:进截图到底卡在哪(2026-11-03) 「按了热键要等一会儿才进截图」这种问题,靠感觉调是调不出来的,所以在演习里加了一条 时间线,每次跑都打出来: ``` [drill/live] 时间线 + 0ms 像素就绪 [drill/live] 时间线 + 84ms 屏 3 建窗完 [drill/live] 时间线 + 91ms 屏 3 冻结图落盘完(2560x1440) [drill/live] 时间线 + 111ms 屏 1 建窗完 [drill/live] 时间线 + 122ms 屏 1 冻结图落盘完(3456x2234) [drill/live] 时间线 + 198ms 屏 3 页面加载完(开始解码) [drill/live] 时间线 + 252ms 屏 3 渲染层就绪(图已解码) [drill/live] 时间线 + 265ms 屏 3 已显示 [drill/live] 时间线 + 312ms 屏 1 渲染层就绪(图已解码) [drill/live] 时间线 + 318ms 屏 1 已显示 ``` 时间线**从「像素已经在内存里」开始算**:`desktopCapturer.getSources()`(系统抓屏)那段 是系统实现,不归我们管,混进来只会把每一轮都拖慢同样多,反而看不清真正的差别。 ##### 量出来的问题:主进程同步编码 PNG 原来每块屏的冻结图都是 `fs.writeFileSync(file, image.toPNG())`。`toPNG()` 是**同步**的、 跑在主进程上:一张 3456x2234 的真实桌面图要 **250~400ms**(合成图只有几十毫秒,因为 渐变压得太好 —— 这也是这条问题长期没被发现的原因,所以合成图已经改成「大块平坦背景 + 窗口硬边 + 文字状横线 + 少量高熵照片块」,让它像真屏幕)。这段时间主进程整个卡住,建窗、 下发 `overlay:init`、渲染层加载全被排在后面。 ##### 改成不压缩的 BMP,并且让建窗和准备像素重叠 - 冻结图改成自己封装的 **32 位不压缩 BMP**(54 字节头 + 原始像素):`toBitmap()` 给的 字节序就是 BMP 要的 BGRA,高度写负数省掉一次翻转,32 位像素每行天然 4 字节对齐。 编码落盘从 65~278ms 降到 **7~16ms**,解码也更快(不用解压)。 格式的三处要点在 `writeFrozenBmp()` 的注释里,动一处就会错颜色或上下颠倒。 **渲染层一行没改** —— 它照旧是 `` + `decode()`。 - 建窗挪到编码之前,并用 `sendInitIfReady()` 做了道闸门(页面加载完 + 像素备好,谁后到 谁负责发),于是「建窗/页面加载」和「准备像素」两段重叠。注意**不能先把所有屏的窗都 建完**再发图:建窗是主进程上的同步活儿,每块屏 80ms 上下,那样第一块屏反而更晚出现。 ##### 同一台机器上的 A/B(各跑 3 轮,交替,看中位数) | | 末块屏落盘 | 末块屏显示 | |---|---|---| | 改之前(PNG) | 278ms | **408ms** | | 改之后(BMP) | 120ms | **283ms** | 真实屏幕内容比合成图更难压,PNG 编码更慢,所以实际省下的比这张表更多。 另外单独量过(2992x1934、桌面式内容):`toPNG+写盘+解码` 437ms → BMP 路 ~55ms。 **这条时间线是回归防线**:以后谁再把同步编码放回这条链路,`drill:live` 的日志会立刻 难看,不需要用户来体感。 ## 当前进度:M3 ✅(箭头 + 自由画笔收尾) 已经能用的部分: - 全局热键 `Cmd+Shift+A` 唤起,附带菜单栏托盘入口 - 抓取全部屏幕的冻结图,**每块屏开一个 overlay 窗口**(macOS 不允许单窗口跨屏) - 螺丝壳里做道场:把物理像素和 DIP 的换算做在两个轴上,多屏分辨率不同也不跑偏 (现在真挂在 2560x1440@1x + 1728x1117@2x 双屏上,`drill:live` 每次都会断言两块屏都盖满) - 全局热键可配置(菜单栏 →「设置快捷键…」,录一下就行,可恢复默认) - 拖拽选区 + 实时物理像素尺寸 + 选中后拖着挪框(手型)+ 拖边框改大小(8 个方向) - **选区工具栏**(选完自动出现在选区下方,纯图标): 取消 / 矩形 / 箭头 / 马赛克 / 画笔 / 文字 五个标注工具 / 撤销 / 贴图 / 截图 / 另存为 - **自绘光标**:按热键进来**立刻**就是醒目的白线黑边大十字(不依赖系统采纳光标, 详见下面「光标为什么自己画」);切到马赛克则变成**圆圈**,圆圈直径就是颗粒 大小,一眼看出会码多粗;文本 / 手型 / 改大小这些交给系统原生光标 - **入场一眼可见**:按热键进来时顶部提示会放大并写「已进入截图」,1.6 秒后自动 收回(详见下面「进没进截图」那一节) - **标注都有属性栏**(工具激活时在工具栏下方展开第二行): - 矩形:形状(矩形 / 椭圆)、线条粗细(细 / 中 / 粗)、颜色(6 色) - 箭头:箭头大小(小 / 中 / 大)、粗细(细 / 中 / 粗)、线型(实线 / 虚线 / 点线)、颜色(6 色) - 马赛克:样式(马赛克 / 模糊 / 纯黑)、圆圈与颗粒大小(8 / 16 / 24 / 40) - 画笔:粗细(细 / 中 / 粗)、颜色(6 色) - 文字:字号(小 / 中 / 大)、颜色(6 色) - **标注**:矩形/椭圆框、箭头(单向、三角头,**几何全部在 DIP 空间算完再逐点映射**, 不会被导出的双轴缩放拉歪)、马赛克(**圆圈画笔涂抹**,按下就拖,涂到哪码到哪)、 自由画笔(平滑曲线,点一下是一个圆点)、文字输入(无底色无边框,只有字本身); `Cmd+Z` 或工具栏撤销 - `Enter` 确认进剪贴板 + 落盘 / `P` 贴图 / `S` 另存为 / `R`/`A`/`M`/`B`/`T` 切工具 / `Esc` 取消 / 不拖选直接 `Enter` 截整屏 - 贴图窗口:`floating` 层级、可拖到任意位置、`Esc` 或双击关掉、托盘可一键全关 - 抓取分辨率自适应:被重采样的屏幕会用其**原生尺寸单独重抓** 还没做(按计划顺序): | 阶段 | 内容 | |---|---| | M2 | ~~选区边框拖拽调整(改大小)~~ ✅、方向键微调 | | M2.5 | ~~选中后拖着挪框~~ ✅ | | M3 | ~~箭头标注~~ ✅、~~自由画笔~~ ✅(马赛克画笔 ✅ 已有) | | M4 | 历史面板、隐藏 Dock 图标(设置页 ✅ 已有快捷键设置) | | 之后 | Windows 构建(需要 UTM 上的 Windows 11 ARM 虚拟机,把平台代码关在 `platform` 层里) | ### 选区工具栏 拖选结束后,工具栏出现在**选区下方**;下方空间不够会自动翻到上方。只有图标, 没有任何文字: | 图标 | 动作 | 快捷键 | 行为 | |---|---|---|---| | ✕ 叉 | **取消截图** | `Esc` | 整个会话结束,什么都不留下(和按 `Esc` 完全同一条路) | | ▢ 方框 | 标注:矩形 | `R` | 在选区内拖出一个框(形状/粗细/颜色见属性栏) | | ↗ 箭头 | 标注:箭头 | `A` | 从起点拖到终点,**拖动方向就是指向**(大小/粗细/线型/颜色见属性栏) | | ▦ 四格 | 标注:马赛克 | `M` | **圆圈画笔**:在选区内按下就涂,涂到哪码到哪,点一下是一个圆点 | | ✎ 笔 | 标注:画笔 | `B` | 自由手绘平滑曲线,点一下是一个圆点(粗细/颜色见属性栏) | | T | 标注:文字 | `T` | 点一下出现输入框(只有字,无底色无边框),`Enter` 或点到别处提交 | | ↶ 回箭头 | 撤销 | `Cmd+Z` | 撤掉上一步标注;没有可撤销时按钮是灰的 | | 📌 图钉 | 贴图 | `P` | 把选区钉在屏幕上(`floating` 层级,不抢焦点、盖不住系统弹窗) | | ✓ 对勾 | 截图 | `Enter` | 进剪贴板 **+** 落盘到 `~/Pictures/Snap/` | | ⬇ 下载 | 另存为 | `S` | 弹保存对话框选路径;**取消会回到 overlay 继续选**,不结束会话 | 五个标注工具是**开关**:按下高亮、状态写在 `aria-pressed` 上,**再点一次就取消**, 这样才有办法回到纯框选状态。五个工具互斥,同时只有一个是按下态。 **「取消」刻意放在最左边**,和「截图 / 另存为」隔着整排按钮 —— 它是这一排里唯一 不可撤销的操作(点错了刚截好的图就连选区一起没了),离常用的确认动作越远越好。 它走的是和 `Esc` 一模一样的通道(`overlay:cancel` → 主进程 `endSession`), 刻意不另外造一条:键盘和按钮只有一条取消路径,就不可能出现两者行为不一致。 > 原来还有一对跟着鼠标的十字标线(`#guide-v` / `#guide-h`)。用了之后觉得没用, > 已经**整个删掉**了:HTML 元素、CSS 样式、`showGuides/hideGuides` 以及三处调用点 > 一处不剩。`pnpm selftest` 里留了一条断言盯着 DOM 里 `[id^="guide-"]` 必须是 0 个, > 免得以后有人觉得「引导线挺好看」又给加回来。 图标一律是**内联 SVG**:不引外部文件、不受 CSP 影响,也不会出现图标字体没加载 变成方块的情况。 #### 工具属性栏(工具栏的第二行) 工具激活时,工具栏下方展开第二行放这个工具的选项(`#options`,加上 `.on` 才显示): | 工具 | 可调项 | |---|---| | 矩形 | 形状(矩形 / 椭圆)、线条粗细(细 2 / 中 4 / 粗 8 DIP)、颜色(6 色) | | 箭头 | 箭头大小(小 12 / 中 18 / 大 26 DIP,指**三角头基准长度**)、粗细(细 2 / 中 4 / 粗 8 DIP)、线型(实线 / 虚线 / 点线)、颜色(6 色) | | 马赛克 | 样式(马赛克 / 模糊 / 纯黑)、圆圈与颗粒大小(8 / 16 / 24 / 40 DIP) | | 画笔 | 粗细(细 2 / 中 4 / 粗 8 DIP)、颜色(6 色) | | 文字 | 字号(小 16 / 中 24 / 大 36 DIP)、颜色(6 色) | 箭头的几个刻意设计(见 `overlay.js` 的 `paintArrow` / `draftIsValid`): - **「大小」只管三角头,杆由「粗细」管**。头部长度取 `max(档位, 线宽 × 2)`,保证 头永远比杆粗出可见的一截;托底系数取 2 而不是更大,是为了三档在粗线时也能区分开 (取 3.2 的话,8px 粗杆会把「小」托底到和「中」一样大)。 - **水平箭头(h 恒为 0)必须能画**。有效性按**两点距离**判,不沿用矩形的「w、h 都得 够大」—— 否则最常见的横箭头会被当成误触悄悄丢掉。`selftest` 有专门一条断言盯着。 - **几何全部在 DIP 空间算完再逐点映射**。导出时 kx/ky 是两个独立缩放系数,若先映射 两端点再用设备像素拼三角,斜箭头的头会被拉歪;逐点过同一变换则预览和导出严格一致。 - **虚线/点线的间距跟线宽走**,头部永远实心(虚线也得让人看清指向哪)。 - 端点**夹**进选区而不是裁剪:裁掉半截箭头就变成一根没头的杆。 几个刻意的选择: - **做成同一条工具栏的第二行,而不是弹出面板。** 弹面板会盖住正在截的内容;更要命的 是它一旦拿到焦点,overlay 的键盘硬保证(`Esc` 随时取消、`Enter` 确认)就悬了 —— 这条路上这个项目已经出过锁屏事故。在同一条工具栏里点按钮不会让窗口换焦点。 - **点完按钮必须 `blur()`。** 按钮握着焦点时,之后按 `Enter` 会被浏览器解释成 「再点一次这个按钮」而不是「确认截图」—— 看起来就像 Enter 失灵了。`selftest` 里 有一条断言盯着点完之后的 `document.activeElement` 不许是那个按钮。 - **属性一出现,工具栏会变高变宽,必须重新 `placeTools()`**,否则两行的工具栏可能戳出 屏幕外(`selftest` 断言两行时底边仍在窗口内)。 - **不做系统取色器。** macOS 的系统取色面板是一个独立窗口,会抢走 overlay 的焦点 —— 和上一条是同一类风险。6 个固定颜色覆盖了标注的实际需要。 - 选项是**声明式**的(`OPTION_SPECS` 一份数据),渲染和点击都读它,以后加一项只要加 一行,不会出现「按钮加了但点击没接上」。 > 选项目前**不跨截图会话保存**:每次唤起 overlay 都回到默认值(红色 / 细 / 马赛克 / 16)。 > 想让它记住的话,得走一遍设置项的读写(`loadSettings` → `overlay:init` → 回传保存), > 那是一处独立的改动,没有混在这次里做。 #### 马赛克画笔:圆圈直径 = 颗粒大小 马赛克不是「拖一个矩形」,而是**一支圆圈画笔**:按下就涂,涂到哪码到哪;在同一个地方 点一下就是一个圆点。圆圈直径**同时**是画笔宽度和颗粒(单元格)边长 —— 光标那个圈多大, 码出来的格子就多大,所见即所得。 实现上有一个关键取舍:**整块选区先做成马赛克/模糊/纯黑,再用笔画路径当蒙版裁一刀**, 而不是「沿着轨迹一个圆一个圆地盖」。 - 逐圆盖的话,每次重画都要做几百次 `clip + drawImage`,拖一下就卡; - 圆与圆重叠的地方会被反复采样,接缝处会花; - 整块做一次、描边求交,代价是常数级的,而且三种样式能共用同一条路径。 蒙版靠的是 `destination-in`:Canvas2D 没有「按描边裁剪」这个操作,但 `destination-in` 保留的是两者 alpha 的交集 —— 描到的地方留下、没描到的地方被抹掉,正好就是要的效果。 另外两处细节: - **采点按距离**(走够 `size/3` DIP 才记一个点),否则一秒能进上百个点,每次重画都要 重走一遍整条路径;收笔那一下会把鼠标松开的位置补上,笔画不会停在最后一个采样点上。 - **过程预览按时间节流(16ms),不能用 `requestAnimationFrame`**:自测和演习里的窗口是 隐藏的,而 Chromium 在不可见的窗口里根本不调度 rAF —— 用了 rAF,自测会永远等不到重画, 变成假绿。收笔那一下仍然走同步重画,所以最终状态不会因为节流而丢。 `selftest` 里对这支画笔的断言(都是像素级的,不是「看起来对」): | 断言 | 为什么非要这么测 | |---|---| | 画笔粗细 = 选中的圆圈直径(4/8/20) | 「圆圈大小就是马赛克大小」是用户明确要的 | | 沿线**最短等色行程**正好等于颗粒边长 | 直接把「一格有多宽」量出来,而不是只看「有没有变」 | | 模糊是连续渐变、马赛克是分格(灰阶数差 3 倍以上) | 三种样式不能是「同一条路径画成同一个样子」 | | 笔画以外一个像素都没动 | 这是画笔和拖矩形最关键的差别(旧实现会码掉整块) | | 同一笔在预览(×1)和导出(×2)下覆盖范围**完全一致** | 最危险的失效是「预览遮住了、导出没遮住」,这条直接在绘制内核上对比 | #### 光标为什么只给十字「自己画」 **只有十字是自绘的**,其余形态继续交给系统光标:文本的 I 型、选区里的手型、 贴边的 8 个改大小、工具栏按钮的 `pointer`。它们是成熟的原生表现,重画一遍只会 更差,而且系统光标的跟随是零延迟的。 **为什么十字非得自己画**(实测,macOS 27 / Electron 39): - 只写 `style.cursor` 而鼠标**不动**,系统**一个光标事件都收不到**; - Chromium 只在**光标值真的发生变化**、且正在处理鼠标事件时才把光标推给系统; - 于是出现「按了热键还是箭头,动一下鼠标才变成大十字」—— 而这时用户恰恰在 判断「我到底进没进截图」。更糟的是**按下鼠标才开始框选的那一刻**,系统终于 处理了一个真实事件,光标才变成十字,看起来就像「先箭头、按下去才变十字」。 0.4.7 到 0.5.0 的解法是主进程补合成鼠标事件「踢一脚」(`kickEntryCursor`): `show()` 之后发 `overlay:shown`,渲染层用两个事件做握手(孪生十字 → 真身), 指望系统把光标变化当成真事。**0.5.1 实测这套是空转**:用探针 (`webContents.sendInputEvent` + 在渲染层里数 `mousemove`)确认事件**确实进了 DOM**(注入的 `400,400` 在渲染层日志里出现了三次),但系统照样不采纳 —— 事件 送到了,光标没变。这条路已整条删除。 现在改成自绘,链路是: 1. 主进程在 `overlay:init` 里带上 `cursorX / cursorY` —— **按热键那一刻**指针在 本窗口内容区的位置。用 `getContentBounds()` 算而不是减 `display.bounds`, 因为窗口 y 被菜单栏压过一截(见上面「不藏菜单栏」)。 2. 渲染层 init 一落地就把 `#xcursor` 摆到那个位置、`display:block`,同时把 `body` 的 `cursor` 设成 `none`(不设的话系统光标会和自绘的重叠成两个十字)。 3. 之后 `mousemove` 只负责挪位置(`translate3d`,纯合成、不触发布局),而且这段 代码放在**所有早退之前** —— 拖边框、挪框、画标注的时候十字也一路跟着走。 三条退化路径都必须留住,宁可退回系统那根细十字,也绝不能「看不见光标」: 指针不在这块屏上(`pointerKnown=false`)、十字图生成失败(`toDataURL` 抛异常)、 `#xcursor` 元素缺失 —— 三种情况一律退回系统 `crosshair`。 十字图按 `devicePixelRatio` 生成(1x/2x/3x 各缓存一份),线宽和中心缺口等比缩放, 高分屏上不会糊。马赛克那个圆圈仍然走 CSS `cursor` (`url("data:...") d d, crosshair`)—— 它是系统光标,形状由直径决定,不需要自绘。 光标仍然只有 `applyCursor()` 一个出口;它现在多了一档 `'auto'`(压在工具栏上时 交给按钮自己的 `cursor`,我们什么都不画)。用 `canvas` 画完 `toDataURL()` 而不是 放 png 文件,是因为圆圈直径可调、CSP 的 `img-src` 早就放行了 `data:`、也少一个 必须跟着打包的资源文件;macOS 的光标图上限是 128×128,尺寸都夹在这个范围内。 #### 「进没进截图」必须一眼看得出来 用户的原话是「我按快捷键进入截图了,我都不知道有没有正确进入截图」。光标现在可靠了 (见上一节),但这条提示仍然保留两条理由:它同时说明「键盘已经交给这个窗口」, 而且它**完全不依赖任何绘制** —— 渲染层只剩下最小能力时也一定能显示出来。 实现:顶部那条提示胶囊在入场时放大到 20px、加一圈白边、多写「已进入截图」四个字, 1.6 秒后自己收回原样;用户一按下鼠标就立刻收(`overlay.js` 的 `showEntryCue` / `hideEntryCue`),免得挡住他要拖的地方。 #### 挪选区(手型)与改大小(8 个把手) 选区画好之后,在**选区里**按下左键拖动,整个框会跟着鼠标走,底下的冻结画面不动 ——也就是框里换成了别处的画面。鼠标移到选区里会变成手型(`grab`),按住时是 `grabbing`。位置**夹在屏幕内**,因为越界之后再按 `rect` 去裁就会裁到画面外面。 三条容易搞错的细节: - **标注不会跟着框跑。** 标注坐标是绝对 overlay DIP(和 `rect` 同一坐标系),本来 就贴在画面上;移框时只需要用新的 `rect.x/rect.y` 当 `originX/originY` 重画一遍, 标注就自然留在原来的画面内容上。我一开始以为标注是「相对选区」的,差点写成把 标注按 `-dx/-dy` 反向平移 —— 那样一块马赛克会在挪动后落到**别的内容**上去, 这是隐私事故而不是观感问题。`pnpm selftest` 里有专门一条断言量标注的**绝对** 位置前后一字不差,而且做过反向测试:把那句错误平移植回去,它立刻报 `前=300,150 后=280,130` 并判失败。 - **激活标注工具时不能挪框。** 工具激活时在选区里按下 = 画标注,这是优先关系: 工具判断排在挪框判断前面。否则选区就永远挪不动了。 - **光标只能由 `applyCursor()` 一处决定。** `setTool` 原先直接写 `document.body.style.cursor`,而内联样式优先级高于任何选择器 —— 所以 CSS 里再 写 `body.movable { cursor: grab }` 是**永远不会生效**的(我第一版就是这么写的)。 现在除了 `body` 上那条给「脚本没跑起来」兜底的 `cursor: crosshair`,没有别处 再碰光标,全部走内联样式。**别绕开它直接写 `body.style.cursor`** —— 那样自绘 十字就不会跟着让位,屏幕上会出现两个光标。 #### 拖边框改大小 贴着边框 `RESIZE_HIT`(6 DIP) 以内就是把手,8 个方向:四边改一个方向、四角同时改 两个方向,鼠标形状分别是 `ew/ns-resize` 和 `nwse/nesw-resize`。用的是「贴着边框 的一条带」而不是画 8 个小方块 —— 边本身就是把手。 三条关键设计: - **基准是「按下那一刻的框 + 鼠标位移」,不是「让边跳到鼠标位置」。** 后者在鼠标 没精确压在边上时(最多差 6 DIP)会让框跳一下。 - **对面那条边锚住不动**,尺寸夹住下限 `MIN_DRAG`(4 DIP) —— 拖过头只会停在最小值, 不会翻面成负数;整体再夹进屏幕内。两条断言都做过反向测试:去掉最小夹取 → 断言 报错;让左边跟着右边一起动 → 断言报「对边一动不动」失败。 - **优先级:标注工具 > 把手 > 挪框 > 重新框选。** 顺序反了就会出问题:把手排在挪框 后面的话,贴着边永远只能挪动、改不了大小;工具排在把手后面的话,开着工具就没法 画贴边的标注。 验「改大小时标注不跟着动」这条断言时踩过一个坑:一开始直接拿旧选区上的墨迹来量, 结果失败。原因是墨迹里混着之前那些**被选区上边裁掉一半**的旧标注 —— 拖上边把选区 撑大后,被裁掉的部分露了出来,绝对外框自然就变了。这不是实现错,是断言量错了对象。 改成先换一个干净的选区(换选区本身会清空标注)、只留一笔四周留足余量的标注再量, 断言才有效。 #### 标注是怎么合成的(这里只有一个绘制函数) 标注全部存在渲染层,画在一张和选区**完全重合**的 `` 上。确认时 渲染层再开一张**选区物理尺寸**的画布,把冻结图 1:1 裁进去、把标注按物理比例重画 一遍,导出 PNG 交给主进程。 关键点: - **预览和导出共用同一个 `paintAnnotations()`**,只是缩放系数不同(预览是 `dpr`,导出是 `物理/ DIP`)。所以不可能出现「预览是一种样子、导出是另一种样子」。 - **底图 1:1 裁出来,不经过任何缩放** —— 加了标注也不会比原来的原生裁剪路径糊。 - **没有标注时 `dataUrl` 是 `null`**,主进程仍走 `nativeImage.crop` 的原生无损 路径。所以这个功能对不使用标注的场景是零开销、零风险的。 #### 为什么让渲染层合成,而不是主进程 马赛克要**取源像素做块平均**,而主进程没有 canvas。在主进程再写一套像素级实现 等于把绘制逻辑维护两遍,预览和产物迟早会分叉。而这两者一旦分叉,最危险的场景 就是**预览盖住了、产物没盖住** —— 用户以为遮住了,实际把原文发出去了。 所以宁可只保留一份绘制逻辑。实测验证过(`overlay-selftest`): ``` 马赛克区域内像素确实被改变 原=240,240,240 → 合成=128,128,128 差=112 同一个块内两点被抹成同一个值 原图差=224 → 合成后差=0 马赛克区域之外一个像素都没动 差=0 ``` 第二条是关键:只比「变了没有」是不够的 —— 万一实现退化成模糊,中心点也会变, 但块内不会相等。 #### 标注失败必须「失败关闭」 马赛克是用来遮隐私的。如果合成失败却退回去交一张没打码的原图,用户会以为 已经遮住了 —— 这比没有这个功能危险得多。所以: - 渲染层合成抛异常或结果为空 → **取消整场截图**,绝不交出原图。 - 反过来,绘制过程中的意外(画不出来)会**丢掉标注、保住截图**: 标注是附加功能,不该因为它坏了就让人截不了图。 两条方向相反,是刻意的:**能画出正确结果就画,画不出正确的就宁可什么都不给。** #### 文字输入与 `Esc` 文字输入框是就地打字,`Enter` 或点到别处提交。**打字时按 `Esc` 会取消整场截图**, 而不是只关掉输入框 —— 因为全局 `Esc`(`globalShortcut`)是「随时退出」的硬保证, 它优先于窗口内的任何按键。这是安全模型的要求,不是疏忽。 另外工具栏按钮点完就让出焦点:否则按钮握着焦点时按 `Enter` 会被浏览器解释成 「再点一次这个按钮」,把「`Enter` = 确认」这条硬保证吃掉。 ### 关于「截图模糊」 根因和解法都写在这里,因为这坑很容易再踩回去。 `desktopCapturer.getSources` 只有一个 `thumbnailSize`,一次抓全部屏幕时只能取 所有屏物理尺寸的**最大值**当盒子。而它会拿每个源的图去适配这个盒子,于是 **比盒子小的那块屏会被放大重采样** —— 比如 2560×1440 的屏被拉到 3456×1944, 图变大但细节没有增加,看起来就是糊的。 解法:先照旧一次抓全部(保证多屏是**同一时刻**的快照),然后逐屏比对 「抓到的像素数」和「这块屏的物理像素」。只有对不上的屏,才用**它自己的原生尺寸** 单独重抓一次,并且**只在结果更接近原生时才采用**。 这样做的好处是:无论底层是「等比适配」还是「拉伸到盒子」,单独调用时盒子和目标 屏尺寸相同,两条路都会给出原生像素;而正常情况(尺寸本来就对)不多付任何代价。 `pnpm diagnose` 会把「抓到的像素」和「该屏物理像素」并排打出来,尺寸不对时还会 顺手验证单屏重抓能不能修好,直接给结论,不用猜。 ### 为什么不藏菜单栏 / Dock 了(2026-11-03 改法) **老做法**:用 `setSimpleFullScreen(true)` 盖住菜单栏和 Dock。它确实能盖住 (探针 A/B/C/D 里只有它行),但那套「隐藏菜单栏 + 隐藏 Dock」是**整个 App 的 presentation options**,而且**多屏下退出时撤不掉**:两块屏各一个 overlay 时, 退出整屏覆盖后每个窗口都回读 `isSimpleFullScreen=false`,可选项还挂着 —— 进程内量工作区一直停在整屏尺寸(2560x1440),等 3.5 秒都不恢复;只有**一块屏** 时才正常(1317 → 1440 → 1317)。0.4.9 打了个补丁(收尾 `app.hide()` 让出前台, 系统就把菜单栏/Dock 放回来),代价变成结束时一段系统滑入动画:**「会回来,但是 闪了一下」**。 **现做法**:干脆不藏。 - AppKit 不允许普通窗口盖住菜单栏:请求 `y=0` 实得 `y=30`(屏1 是 33)。但 **高度照给不误** —— 实测请求 1440 就是 1440,不会缩成工作区高度。所以窗口 建成「x/宽度 = 整屏,y = 工作区上沿,高度 = 整屏」,比屏幕多出来的那一截 挂在屏幕下沿之外,正好把底部 Dock 那条一起盖住(Dock 层级比 `pop-up-menu` 低,盖住就看不见也点不到)。`menuBarInset()` = `workArea.y - bounds.y`。 - 渲染层拿到 `insetY` 之后把冻结图**往上顶同样距离**(`#shot` 的 `top = -insetY`, 高度仍用图片自己的 DIP 高度),于是冻结图和真实屏幕 **1:1 对齐**:图片第 r 行 就在屏幕 y=r 上。顶上那 30 DIP 只有一样东西盖不住 —— 活菜单栏。 - 底部 Dock 那条显示的是**冻结图里的 Dock**,不是活的:它没被隐藏,只是被 overlay 盖住了。overlay 一撤立刻回来,没有动画,整套系统选项也不再碰, 所以「Dock 不回来」这个毛病从结构上不可能再发生。 - 由此多了一层坐标偏移:窗口坐标 → 冻结图坐标要 `+ insetY`。交给主进程的 `rect`(物理像素,裁剪用)和 `dipRect`(DIP,贴图定位用)都走这层换算。 - 唯一选不到的地方是顶上那 30 DIP(窗口盖不到)。贴到窗口最上沿时会把它吸附 进导出范围,这样「拖到四个角」仍然是整屏截图。 代价(知情后选的): - 截图时顶部那条是**我们自己的**菜单栏 —— 所以 `Menu.setApplicationMenu` 只留 「应用名 + 编辑」两项(默认那套 File/Edit/View/Window/Help 挂上去太扎眼, 留 Edit 是为了保住文本框的 ⌘C/⌘V)。 - 顶上 30 DIP 是活菜单栏(点得到、但点的是我们自己的菜单);底下 Dock 那条 显示的是冻结的 Dock,选得到、点不动。 运行时断言:`drill:live` 检查每个窗口是否正好是「x/宽度 = 整屏、y = 工作区上沿、 高度 = 整屏、底边不低于屏幕底边」,且 `menuBarInset` 等于菜单栏高度。这条断言 做过反向测试 —— 把高度改回工作区高度(底部 Dock 就露出来、还能点到),它立刻报: ``` [drill/live] 没盖满=2 [drill/live] 没盖满:屏3 窗口=0,30 2560x1317 期望=0,30 2560x1440 inset=30 [drill/live] 没盖满:屏1 窗口=-1728,33 1728x1084 期望=-1728,33 1728x1117 inset=33 [drill/live] FAIL ``` selftest 里另有 8 条盯着这层换算(`insetY=30` 下普通选区的偏移、贴顶/贴底吸附、 交给主进程的两个矩形、带 inset 的整屏导出仍是 2048x1400)。这个偏移算错了不会崩, 只会静默导出偏 30 像素的图 —— 最坏的那种 bug,必须有断言压着。 历史探针数据(`tools/menubar-probe.js`,换算法之前的记录,留着以后查): ``` A 现状(y=0 + pop-up-menu) bounds=0,28 1496x967 simple=false 顶✘露28 → 没盖全 C show 之后 setSimpleFullScreen bounds=0,0 1496x967 simple=true 顶✔ 底✔ → 整屏覆盖 E3 撤销全屏后 bounds=0,28 1496x967 simple=false 顶✘露28 → 没盖全 ``` ## 跑起来 ```bash pnpm install pnpm start ``` 启动后没有主窗口,只有菜单栏上的图标。按 `Cmd+Shift+A` 或点托盘菜单的「截图」。 ## 装成正经 App(推荐,别长期用 pnpm start) 开发模式(`pnpm start`)跑的是 `node_modules/electron/dist/Electron.app`, 它的 bundle 名是 **Electron**、bundle id 是 `com.github.Electron`。所以: - 「系统设置 → 隐私与安全性 → 屏幕录制」里出现的是 **Electron**,不是 Snap - 这条权限会和你机器上**其他所有 Electron 开发应用共享** - 而且这条记录**只有在你触发过一次权限请求之后才会出现**,之前列表里是空的 打包成真正的 `Snap.app` 之后,TCC 记录才是干净的 **Snap**: ```bash pnpm dist:mac cp -R dist/mac-arm64/Snap.app /Applications/ open /Applications/Snap.app ``` 然后去「屏幕录制」里找 **Snap**。 ### 签名身份:为什么不能用 ad-hoc(重要) **症状**:系统设置里屏幕录制明明已经给 Snap 打开了开关,但每次按热键还是弹 「『Snap』想要录制此电脑的屏幕」,授权怎么都不生效。 **原因**:TCC 记住一个 App 靠的是它的 **designated requirement(DR)**。 ad-hoc 签名(`codesign -s -`)的 DR 里含 **cdhash**: ``` designated => identifier "com.liufusong.snap" and cdhash H"<每次打包都不一样>" ``` 所以**每次重新打包都会变成一个新 App**,旧授权对不上。这不是 bug,是 ad-hoc 签名的固有属性。 **解决**:用一张固定的本地自签名证书签名。DR 随之变成基于**证书根指纹**、 与 cdhash 无关: ``` designated => identifier "com.liufusong.snap" and certificate root = H"cdff81e9..." ``` 于是授权一次长期有效,以后重打包不用再动系统设置。 ```bash pnpm signing-cert # 幂等:建好「专用钥匙串」并放进签名身份 pnpm dist:mac # 会先调用上面这步,再用该身份签名 ``` `security find-identity -p codesigning <专用钥匙串>` 会把它标成 `CSSMERR_TP_NOT_TRUSTED` —— **这是正常的**。codesign 不需要系统信任也能签, TCC 认的是证书根指纹,不走 Apple 信任链。这也是刻意不去动系统信任设置的原因 (那需要管理员密码)。 #### 为什么签名要用「专用钥匙串」 **症状**:每次重新打包,macOS 弹出二十来个「请输入登录钥匙串密码」, 要一个一个输入。 **原因**:签一个 Electron app,`codesign` 要对**每一个** Mach-O 单独签一次。 Snap.app 里有 15 个这样的文件,加上几个 framework/bundle 的封装签名, 一次构建要访问私钥约 20 次: ``` Snap 主可执行文件 Snap Helper / Helper (GPU) / Helper (Plugin) / Helper (Renderer) 4 个 Electron Framework libEGL / libGLESv2 / libvk_swiftshader / libffmpeg .dylib 4 个 chrome_crashpad_handler Squirrel / Mantle / ReactiveObjC / ShipIt 4 个 ``` 而私钥的访问授权在现代 macOS 上除了老的 **ACL**,还多了一层 **partition list**。 `security import -A` 只设了 ACL,**不会**帮你设好 partition list,默认状态就走 「需要用户确认」—— 于是上面那 20 次访问,每次都弹一个密码框。这正是 CI 里导入 证书后雷打不动要补跑 `set-key-partition-list` 的原因。 给登录钥匙串补设 partition list 是可行的,但必须提供登录密码 (`set-key-partition-list -k`),而且会改动你日常使用的钥匙串。所以改成: **单独建一个钥匙串,密码由脚本自己定义。** 自己知道密码 ⇒ 那一步是非交互的 ⇒ 补设 partition list 不花任何代价 ⇒ 之后 codesign 全程静默。 ```bash # 位置与密码(密码不是机密,详见脚本头部说明) ~/.snap-signing/snap-signing.keychain ~/.snap-signing/key.pem / cert.pem ``` 它刻意**不在**系统的钥匙串搜索列表里,所以签名时必须显式指定 —— `tools/sign-app.sh` 已经这么做了: ```bash codesign --force --deep --keychain ~/.snap-signing/snap-signing.keychain \ --sign "Snap Local Signing" dist/mac-arm64/Snap.app ``` > **实测**:整包签名(约 20 次私钥访问)**耗时 1 秒、退出码 0、零弹窗**。 ⚠️ 一个遗留物:登录钥匙串里还留着当初那份身份副本,它**没有**设好 partition list,所以**不指定 `--keychain` 直接跑 `codesign` 仍然会弹密码框**。本仓库的脚本 一律显式指定,不受影响。想彻底清掉的话,用「钥匙串访问」搜 `Snap Local Signing`, 把里面的私钥和证书都删掉即可(删的时候会要一次密码)。 确认当前包用的是哪个身份: ```bash codesign -d -r- /Applications/Snap.app # 看 designated requirement codesign -dvvv /Applications/Snap.app | grep -i "Authority\|TeamIdentifier" ``` > 如果 DR 里出现的是 `cdhash H"..."`,说明这个包是 ad-hoc 签的 —— > 那么每次重打包你都得重新授权。 `spctl -a -t exec` 会显示 **rejected** —— 这是预期的,因为没做公证。 但本地构建的 app 不带 `com.apple.quarantine` 属性,Gatekeeper 不会拦。 **代价是:这个包只能你自己用,发给别人对方会被拦。** ### Electron 为什么钉在 39.x Electron 40+ 的 postinstall 依赖 ESM-only 的 `@electron/get@5`,而 `require()` 一个 ES Module 需要 Node 20.19+ / 22+。本机 nvm 的 default 别名是 **18.15.0**,所以任何新开的终端都落在 Node 18 上,装 40+ 会直接 `ERR_REQUIRE_ESM`,连二进制都下不下来。39.8.10 是最后一个用 CJS `@electron/get@2` 的版本。 **选 39 而不是 45 的真正理由是兼容性**:39 在 Node 18 和 Node 24 上都能装, 所以这个项目不管你 `nvm use` 到哪个版本都不会坏。放开到 45 就等于把项目 绑死在 Node 20.19+/22+ 上。 ### Node 架构必须注意(否则 Electron 会跑在 Rosetta 下) `electron` 的 postinstall 按 `process.arch` 下载对应架构的二进制。如果用的是 x86_64 的 Node,装出来的 Electron 就是 x64,在 M 系列芯片上全程跑 Rosetta ——更慢、更吃内存,而且不会有任何报错提示你。 所以装依赖前确认一下: ```bash node -p "process.platform + ' / ' + process.arch" # 期望 darwin / arm64 ``` 本机现状(实测): | Node | 来源 | 架构 | 说明 | |---|---|---|---| | v18.15.0 | nvm(default 别名) | arm64 | ✅ 当前项目就是用它装的,Electron 也是 arm64 | | v24.18.0 | nvm | **x86_64** | ⚠️ Intel 构建,需要 Rosetta | | v24.18.0 | `/usr/local/bin`(universal) | x86_64 + arm64 | ✅ 原生跑是 arm64 | 如果哪天要让项目用 Node 24,建议先修 nvm 那份 Intel 构建: ```bash nvm uninstall 24.18.0 && nvm install 24 # 重新下 arm64 版 nvm alias default 24 # 可选:把默认切过去 ``` 切过去之后本项目**不需要改任何东西**——Electron 39 在 Node 24 上照样能装能跑。 ### 如果你在沙箱化的 shell 里跑(一般不用管) 两个环境变量会让 Electron 表现异常,正常终端不会有: - `ELECTRON_RUN_AS_NODE=1` —— Electron 会退化成纯 Node, `require('electron')` 返回的是路径字符串而不是 API 对象, 报错是 `Cannot read properties of undefined (reading 'handle')`。 - 受限文件系统 —— Chromium 要写 `~/Library/Application Support`,被挡时会 报 `sandbox initialization failed`。临时绕法是 `electron . --no-sandbox --user-data-dir=/tmp/snap-profile`。 ## 实测状态 | 项 | 状态 | |---|---| | 主进程启动、无异常退出 | ✅ 实测通过 | | 托盘图标加载(`assets/trayTemplate.png`) | ✅ 实测通过 | | 全局热键 `Cmd+Shift+A` 注册 | ✅ 实测通过(未被占用) | | 渲染层 18 项断言 + 「脚本死掉」回归相 | ✅ `pnpm selftest` 18/18 通过 | | 安全演习:渲染层死亡 → 看门狗兜住、**零窗口显示** | ✅ `pnpm drill:dead` PASS | | 安全演习:正常路径 → 两块屏都就绪、显示、拿到焦点 | ✅ `pnpm drill:live` PASS | | 安全演习跑在**已安装的 0.2.0 二进制**上 | ✅ `显示过窗口=0 看门狗触发=true` 退出码 0 | | Esc 在渲染层走通到主进程 | ✅ 自测断言(收到 1 次 cancel) | | DIP→物理像素换算(DIP 100×100 @2x → 物理 200×200) | ✅ 自测断言通过 | | **真实抓屏 → overlay → 拖选 → 裁剪 → 剪贴板/落盘** | ⚠️ **仍需你手动实测** —— 演习用合成图,绕过了权限和真实鼠标 | 最后一条是 M1 的核心,也是唯一必须手动验的:按热键后能不能正常出选区、 拖选后进剪贴板。在它验证之前,别把这个版本当成「已经能用」。 > 顺带一个坑:`globalShortcut.register('CommandOrControl+Shift+3')` 会返回 > `true`,但那不代表安全 —— macOS 的系统截图快捷键在更高层处理, > 注册它会顶掉系统自带行为。所以「注册成功」不能当作热键安全的证据, > 默认值必须主动避开 `Cmd+Shift+3/4/5`。 ## 首次授权(macOS 必须做一次) 1. `open /Applications/Snap.app` 让它跑起来 2. 按一次 `Cmd+Shift+A` —— 先触发请求,系统才会弹窗、Snap 才会出现在列表里 3. 系统设置 → 隐私与安全性 → **屏幕录制** 4. **如果列表里已经有旧的 Snap:先选中它、点「−」删掉。** 旧条目属于旧的签名身份,留着只会让你以为"已经授权了却还一直弹" 5. 点列表下方的 **「+」**,选择 `/Applications/Snap.app` 6. 打开它右侧的开关(会要密码或指纹) 7. **重启 Snap** —— 托盘图标菜单里有「重新启动 Snap」。 权限对**已经在运行的进程不生效**,这一步不能省,否则会陷入 「弹窗 → 授权 → 还是弹窗」的死循环 8. 再按 `Cmd+Shift+A` 抓到画面失败时弹的那个对话框里,**第一个按钮就是「重启 Snap」**, 所以第 7 步可以直接从那里点。 用上自签名证书之后(见上面「签名身份」一节),这一整套**只需要做一次**, 以后重新打包不用再来一遍。 ### 为什么不能只靠自动弹窗(这里踩过一个坑) - **`systemPreferences.getMediaAccessStatus('screen')` 在「从未申请过」时也返回 `denied`。** 它底层就是 `CGPreflightScreenCaptureAccess()` 这个布尔函数, 区分不了「没申请过」和「被拒绝过」。所以**绝对不能**因为它是 `denied` 就 提前 return —— 那样就永远不会去调 `desktopCapturer.getSources()`,而那是 唯一能触发系统授权请求、让 App 出现在列表里的入口。结果是个死循环: 用户按热键 → 被提示去设置里勾选 → 但列表里根本没有这个 App。 - **权限不足时 `getSources()` 是直接抛 `Failed to get sources.`**,不是返回 空列表。所以调用点必须同时接住「抛错」和「拿到空图」两种情况。 用 `pnpm start` 跑的话授权对象写的是 **Electron**(bundle id `com.github.Electron`),装成 Snap.app 之后才是 **Snap**。 ### 排查工具 ```bash /Applications/Snap.app/Contents/MacOS/Snap --diagnose ``` 打印权限状态(请求前后)、每块屏幕的 bounds/scaleFactor、每个源的尺寸和是否 为空(**包括全黑检测**),然后自行退出。这个模式本身就是一次真实抓取, 所以它也会触发授权弹窗。 > ⚠️ 如果它报 `.../MacOS/Snap: bad option: --diagnose`(退出码 9), > 那不是 Snap 的问题:说明当前环境里有 `ELECTRON_RUN_AS_NODE=1`, > 二进制被当成纯 Node 跑了(`--version` 会打印 Node 的 `v22.x` 而不是 > Electron 的版本,可据此确认)。先 `unset ELECTRON_RUN_AS_NODE` 再跑。 > 判断依据:同一条命令,`node --diagnose` 报的是一模一样的 > `node: bad option: --diagnose`。 安全演习也可以直接对已安装的那份跑: ```bash unset ELECTRON_RUN_AS_NODE /Applications/Snap.app/Contents/MacOS/Snap --drill=dead --no-sandbox --user-data-dir=/tmp/snap-drill ``` > 这个循环已经解决了:现在用固定的自签名证书签名,授权一次长期有效。 > 详见上面「签名身份」一节。 ## 改热键 **不用改代码**:菜单栏图标 →「设置快捷键…」,在窗口里直接按下你想要的组合, 点保存即刻生效并写进配置(重启后仍然是它)。 配置在 `~/Library/Application Support/Snap/settings.json`: ```json { "hotkey": "Command+Shift+B" } ``` 也可以在命令行直接设(脚本化用,走的是和设置窗口同一段逻辑): ```bash Snap --set-hotkey=Command+Shift+B ``` ### 允许什么、不允许什么 规则只有一条硬性的:**必须带上 ⌘ / ⌃ / ⌥ 里至少一个**。`A` 或 `Shift+A` 当全局热键会把这个键从整个系统里吃掉 —— 打字就废了。这种事不能靠「用户应该 不会这么选」来兜,所以直接拒绝,并且把后果写在界面上。 另外拦了一批**归系统管**的组合(⌘Q 退出、⌘⇧Q 注销、⌃⌘Q 锁屏、⌥⌘⎋ 强制退出、 ⌘Tab、⌘空格、⌃←→ 切桌面、⌘⇧3/4/5/6 系统截图)。抢过来会让用户连退出应用、 锁屏、切窗口都做不到。⌘Q 写成 `CommandOrControl+Q` 同样会被拦住 —— 先按平台 展开再查黑名单。 ### 三件容易做错的事 - **托盘菜单上的热键写在标签里,不是用 `MenuItem.accelerator`。** accelerator 会被 NSMenu 当成按键等效项处理,菜单开着时按下去会同时触发菜单项**和**已注册的全局 热键,也就是 `startCapture()` 被调两次;而它从 `if (session) return` 到真正赋值 `session` 之间隔着 `await`,两次调用真的可能都进去。为了右边对齐好看引入这种 竞态不值得。 - **`loadSettings()` 必须在 `createTray()` 之前。** 踩过一次:原来是 createTray → loadSettings → registerHotkey,菜单是拿默认热键建的,配置里那个值 要等下次刷新菜单才生效 —— 菜单写着 ⌘⇧A、实际注册的是 ⌘⇧B,而 `refreshTray` 只在贴图增减时调用,这个错误状态会一直挂着。纯逻辑测试全绿,界面上是错的; 最后是端到端测试靠「启动日志里那行菜单文字」抓出来的,所以那一行日志保留着, `pnpm hotkey-e2e` 也盯着它。 - **设置窗口拿到焦点时先把热键注销掉。** 用户要录的很可能就是当前那个热键,而它 已经被全局注册了 —— 按下去会直接开始截图、整屏被盖住,人根本没法在输入框里 打字。失焦或关掉窗口时再注册回来。 ### 启动时热键注册不上怎么办 自定义的热键如果被别的应用抢先,会**自动回落到默认值**并弹一次窗说明 —— 而不是就此没有热键。托盘菜单永远还在,「截图」永远点得到,但热键是主要入口, 能救就救。配置文件损坏或被手改成非法值时同理:只记一行日志、回落到默认值, **绝不允许**因为一个坏配置导致应用起不来或者托盘建不出来。 ## 目录 ``` snap/ ├─ src/main/main.js 主进程:托盘、热键、抓屏、显示闸门、看门狗、裁剪、贴图 ├─ src/main/preload.js overlay 的窄接口 contextBridge(init 缓冲 + ready/fail 回告) ├─ src/main/pin-preload.js 贴图窗口的窄接口(只暴露 close,越窄越不容易出问题) ├─ src/renderer/overlay.* 拖选 + 坐标换算 + 选区工具栏 ├─ src/renderer/pin.* 贴图窗口(拖动交给 -webkit-app-region,不做坐标运算) ├─ assets/ 图标(托盘模板图 + .icns) ├─ tools/overlay-selftest.js overlay 回归测试(两相,含"脚本死掉"复现) ├─ tools/pin-selftest.js 贴图测试(层级红线静态扫描 + 尺寸/图/关闭路径) ├─ tools/make-signing-cert.sh 创建/复用自签名代码签名证书(幂等) ├─ tools/sign-app.sh 用该身份签 app,并校验 DR 不含 cdhash └─ tools/make-icons.py 图标生成器(纯标准库,不依赖 Pillow) ``` > 写这几个 shell 脚本时踩过一个坑:`set -u` 下 `"「$VAR」"` 这种写法 > (`$VAR` 紧跟多字节字符)bash 会把字节并进变量名,报 `VAR: unbound variable`。 > 本仓库的脚本里**一律用 `${VAR}`**。 重新生成图标: ```bash pnpm icons ``` ## 两个实现上的关键决定 **先冻结、再画框。** 热键按下时立刻把整屏图抓到内存,再把图铺满 overlay 让用户在图上方拖选。反过来的做法(先画框再抓)会闪烁、会把选框截进结果、 跨屏还会漏。 **overlay 不做透明窗口。** 冻结图本来就铺满整个窗口,所以窗口可以完全不透明 ——这样少掉一整类 macOS 透明窗口合成的问题和性能开销。 ## 打包产物(本机已实测) ```bash pnpm dist:mac # -> dist/mac-arm64/Snap.app ``` | 项 | 结果 | |---|---| | electron-builder 打包 | ✅ 通过 | | `Info.plist` 的 Name / ID / Executable / 图标 | ✅ `Snap` / `com.liufusong.snap` / `Snap` / icon.icns 已打入 | | 主可执行文件架构 | ✅ arm64 | | 打包后、签名前的 `codesign --verify` | ❌ 失败(`Info.plist=not bound`、`Sealed Resources=none`) | | 追加 `codesign --force --deep --sign -` 之后 | ✅ `codesign --verify` 通过,`Identifier` 变成 `com.liufusong.snap` | | 打包版实际启动 | ✅ 存活 10 秒、零报错 | | `spctl -a -t exec` | rejected(**预期**:未公证;本地包无 quarantine 属性,所以不拦) | 注意 `--deep` 在新版 macOS 上已被标记弃用,只是目前仍然有效。如果哪天它失效了, 需要改成从内到外逐层签名(先 `Contents/Frameworks` 里的 framework,再签外层 app)。