用 Swift 从零开发一款 macOS 菜单栏剪贴板历史管理器(附截屏+标注)
用 Swift 从零开发一款 macOS 菜单栏剪贴板历史管理器(附截屏+标注)
本文记录我开发 ZClipboard(一款 macOS 菜单栏剪贴板历史管理器)的完整过程,重点分享开发中踩过的那些"官方文档不写、Stack Overflow 上答得似是而非"的坑,以及最终的解决方案。涉及:TCC 权限体系、全局快捷键、截图标注、坐标系转换、代码签名等。
一、为什么又一个剪贴板工具?
市面上的剪贴板工具要么功能臃肿、要么要钱、要么不开源。我想要的是:
- 菜单一键唤出 全局快捷键
⌘⇧V - 最近 8 条 历史,去重,复制相同内容自动置顶
- 截屏标注 类似钉钉的
⌘⇧A,选区 + 框/箭头/画笔/马赛克 - 纯原生 Swift,不要 Electron
最终成果:~2700 行 Swift,48 个单元测试,完整打包成 .app + .dmg。
二、技术选型
| 需求 | 方案 |
|---|---|
| 剪贴板监听 | NSPasteboard + Timer 轮询 |
| 菜单栏图标 | NSStatusItem + 自绘模板图标 |
| 全局快捷键 | CGEvent / NSEvent 全局监听 |
| 浮动面板 | NSPanel(nonactivatingPanel,不抢焦点) |
| 截屏 | CGWindowListCreateImage / ScreenCaptureKit |
| 存储 | SQLite(SQLiteClipStore) |
| 权限 | Accessibility + Screen Recording |
三、TCC 权限:整个项目最大的坑
macOS 的隐私权限是这套工具链里最容易让人抓狂的部分。先说清楚两类权限的本质区别:
3.1 Accessibility(辅助功能) vs Screen Recording(屏幕录制)
| Accessibility | Screen Recording | |
|---|---|---|
| 用途 | 模拟按键、监听全局事件 | 截屏、录屏 |
| 检测 API | AXIsProcessTrusted() |
❌ 无公开 API |
| 触发弹窗 | AXIsProcessTrustedWithOptions 可以主动弹 |
没有任何 API 能触发弹窗 |
| 失效后 | 可重新弹窗授权 | 只能手动在设置里删掉再加回来 |
最后一行是关键。我用 ScreenCaptureKit 来检测+触发录屏授权:
static func checkAndRequest(onResult: @escaping (Bool) -> Void) {
Task.detached {
let granted: Bool
do {
// 首次调用会触发系统弹窗 "X wants to record this computer's screen"
let content = try await SCShareableContent.excludingDesktopWindows(
false, onScreenWindowsOnly: true)
granted = !content.windows.isEmpty
} catch {
// -3801 = 用户拒绝
granted = false
}
DispatchQueue.main.async { onResult(granted) }
}
}
为什么不用已废弃的 CGWindowListCreateImage?因为它在很多情况下不会触发授权弹窗,而是静默返回桌面壁纸,你以为截到了其实是个空图。SCShareableContent 则能可靠触发弹窗。
3.2 adhoc 签名导致授权反复失效(终极坑)
开发阶段用 codesign --sign -(adhoc 签名)是最省事的。但这里藏着一个致命问题:
adhoc 签名的 designated requirement(DR)就是 cdhash 本身。
designated => cdhash H"b3dd1942..." ← 这个 hash 跟着二进制走
你每次 swift build 产生的二进制都不同,cdhash 就变。macOS 的 TCC 把授权记录挂在 (bundle id + DR) 上,DR 一变 → 记录失效。于是出现这个死循环:
改代码 → rebuild → cdhash 变 → Accessibility/Screen Recording 双双失效
→ Accessibility 还能重新弹窗授权
→ Screen Recording 没法弹窗 → 只能手动到设置里删掉应用重新加!
解法:用固定的自签名证书替代 adhoc。
固定证书后,DR 变成:
designated => identifier "com.zclipboard.app"
and certificate leaf = H"97fc8e80..." ← 绑定证书,不是 cdhash
证书不变,DR 就永远稳定,rebuild 再多遍,权限都不掉。一次性配置,永久受益。
3.3 自签名证书的正确姿势
这个坑我在 Stack Overflow 上没找到完整答案,踩了一下午才凑齐。一个能被 codesign 认可的代码签名证书,三个 X509 扩展缺一不可:
basicConstraints = critical, CA:FALSE ← 不能是 CA
keyUsage = critical, digitalSignature ← 缺了这个 codesign 报 "no identity found"
extendedKeyUsage = codeSigning ← 必须声明用途
很多教程只写了 extendedKeyUsage=codeSigning,结果 codesign --sign "证书名" 一直报 no identity found,根本原因就是漏了 keyUsage。
还有一个 OpenSSL 3.x 的坑:导出 p12 时必须加 -legacy 标志,否则 macOS 的 security 工具会报 MAC verification failed during PKCS12 import:
openssl pkcs12 -export -inkey key.pem -in cert.pem \
-out cert.p12 -passout pass:密码 -legacy # ← 这个 -legacy 很关键
我把整个证书生成流程写成了一个幂等脚本 setup-codesign-cert.sh,一键搞定。核心逻辑:
# 1. openssl 生成自签名证书(CA:FALSE + digitalSignature + codeSigning)
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 3650 ...
# 2. 导出 legacy p12 并导入 keychain
openssl pkcs12 -export ... -legacy
security import cert.p12 -k login.keychain-db -P 密码 -A
# 3. 标记为信任根(自签名证书默认 NOT_TRUSTED)
security add-trusted-cert -d -r trustRoot cert.der
运行一次后,build.sh 自动检测并使用该证书:
CERT_NAME="ZClipboard-Development"
if security find-identity -v -p codesigning ... | grep -q "$CERT_NAME"; then
SIGN_ID="$CERT_NAME" # 用固定证书
else
SIGN_ID="-" # 回退 adhoc
fi
codesign --force --deep --sign "$SIGN_ID" "$APP"
四、截屏功能:坐标系地狱
截屏功能听起来简单——截个图嘛。实际上多屏 + Retina + 翻转坐标系,能让你怀疑人生。
4.1 多屏识别错误的根因
最初我用"拼一张大图再切片"的方案:
// ❌ 错误方案:把所有屏幕拼成一张大图,按坐标切片
let combinedImage = CGWindowListCreateImage(.infinite, ...)
let slice = combinedImage.cropping(to: selectionRect)
问题:副屏在主屏左边时,坐标是负数(-2560),切片逻辑全乱;而且 Retina 下物理像素(8576×2880)和逻辑点(1728×1117)对不上,切出来的图是错位的。
正确方案:按屏幕逐个截取。
// ✅ 对每个屏幕单独调用一次截图,系统自动处理缩放和坐标
for screen in NSScreen.screens {
let image = CGWindowListCreateImage(
screen.frame, // 系统帮你转换坐标系
.optionOnScreenOnly,
kCGNullWindowID,
[.bestResolution]
)
}
系统会自动处理 Retina backing store,你拿到的就是屏幕真实分辨率。
4.2 翻转坐标系 vs 底部原点坐标系
这是 macOS 图形编程的经典坑。同一套代码里至少有三种坐标系:
| 坐标系 | 原点 | 谁用 |
|---|---|---|
| 翻转(左上) | 左上角 | NSView(当 isFlipped = true) |
| 屏幕底部原点 | 左下角 | NSWindow、NSScreen |
| Core Graphics | 左上角 | CGContext、截图 API |
我的标注画布 AnnotationCanvasView 设了 isFlipped = true(左上原点,方便鼠标拖拽计算),但窗口位置是底部原点。于是截完图要把选区坐标转成窗口坐标时:
// 选区在画布坐标系里是左上原点,转成窗口坐标系(底部原点)
let selectionTopBL = screen.maxY - rect.minY
window.setFrame(
origin: NSPoint(x: rect.minX, y: selectionTopBL),
size: rect.size
)
少转一次,Y 坐标就全偏。这个 bug 表现为"副屏截屏后贴图位置偏上"。
4.3 缩放手柄把画面拉伸了(而不是裁剪)
标注编辑器做了 8 个缩放手柄(四角+四边)。最初拖动手柄时,画面被拉伸而不是重新裁剪——因为 canvas 的 autoresizingMask 在窗口缩放时把图片也拉伸了。
修复:
// 窗口缩放时,不要让 canvas 自动拉伸图片
canvas.autoresizingMask = [] // ← 关掉自动缩放
// 而是根据新的窗口尺寸,重新从原图裁剪
func applyResize(newRect: CGRect) {
let cropped = sourceImage.cropping(to: newRect)
imageView.image = cropped
imageView.frame = NSRect(origin: .zero, size: cropped.size) // ← 重设 frame
}
五、浮动面板不抢焦点的秘密
剪贴板面板的交互要求很特殊:唤出它时,不能让当前正在输入的 App 失去焦点(否则你粘贴时就粘到面板里了)。
NSWindow 默认会激活 App、抢走键盘焦点。要用它的子类 NSPanel,并设成 nonactivatingPanel:
let panel = NSPanel(
contentRect: ...,
styleMask: [.nonactivatingPanel, .titled, .resizable],
backing: .buffered,
defer: false
)
panel.becomesKeyOnlyIfNeeded = true
panel.hidesOnDeactivate = true
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.level = .floating
但这里又有个连锁问题:nonactivatingPanel 不抢焦点 → 全局键盘监听和鼠标 hover 在面板里不生效。我试了 NSTrackingArea、NSEvent.addLocalMonitorForEvents,都不灵。
最终方案是手动拦截事件(CGEventTap 或 sendEvent 重写),绕过 AppKit 的事件派发链。这块踩坑极多,值得单独写一篇。
六、可观测性:看不见的日志
开发系统级 App,print() 和 os.Logger 在终端里根本看不见(因为 GUI App 没有标准输出)。调试时两眼一抹黑。
我写了一个 FileLogger,把日志同时写到文件和 os.Logger:
enum LogCategory: String { case permission, hotkey, panel, screenshot, clipboard }
func logDebug(_ message: String, category: LogCategory) {
let line = "[\(timestamp)] [\(category.rawValue)] \(message)"
osLog.debug("\(line, privacy: .public)")
fileLogger.write(level: .debug, category: category, message: line)
}
日志路径固定在 ~/Library/Application Support/zclipboard/app.log,startApp.sh 会自动 tail -f 它。这套机制让我抓到了好几个"看不见的 bug":面板高度为 0、面板完全透明、键盘事件没派发……全是靠日志才发现的。
七、打包发布流水线
最后做了一个 build.sh,一条命令从源码到 .dmg:
preflight(检查工具链)
→ gen-icon(PIL 生成图标 → iconutil 打 icns)
→ swift build -c release
→ swift test(48 个测试)
→ 组装 .app bundle(Info.plist + 资源)
→ codesign(用固定证书)
→ hdiutil 打 .dmg
图标生成也踩了个坑:sips 不支持 SVG 转 PNG,最后用 Python PIL 直接画 PNG:
# scripts/gen-icon.py —— 直接用 PIL 画图标
from PIL import Image, ImageDraw
img = Image.new('RGBA', (1024, 1024), (0,0,0,0))
draw = ImageDraw.Draw(img)
# ... 画剪刀/剪贴板图形 ...
img.save('AppIcon_1024.png')
再用 iconutil -c iconset 把一整套尺寸打包成 .icns。
八、项目结构
Sources/
├── ZClipboard/ # App 主目标(~2000 行)
│ ├── AppDelegate.swift
│ ├── Panel/ # 浮动面板
│ ├── Screenshot/ # 截屏 + 标注(9 个文件)
│ ├── Permissions/ # TCC 权限检测
│ └── Support/ # FileLogger 等
├── ZClipboardCore/ # 可测试的纯逻辑库
│ └── Screenshot/AnnotationGeometry.swift # 纯几何,无 UI 依赖
└── E2EDriver/ # 端到端测试驱动
把几何计算(箭头顶点、马赛克分块)放进 ZClipboardCore,这样能用 XCTest 测试,不依赖 AppKit:
// AnnotationGeometry.swift —— 纯函数,可测试
func arrowheadVertices(start: CGPoint, end: CGPoint,
length: CGFloat, halfWidth: CGFloat)
-> (tip: CGPoint, left: CGPoint, right: CGPoint)
func mosaicBlocks(rect: CGRect, blockSize: CGFloat) -> [[CGRect]]
九、总结
整个项目最耗时的不是写功能,而是和 macOS 各种"设计如此"的机制搏斗:
- TCC 权限:adhoc 签名 → cdhash 变 → Screen Recording 失效且无法重弹。固定自签名证书是根治方案。
- 截屏坐标:多屏 + Retina + 翻转坐标系,按屏逐个截取最稳。
- 非激活面板:不抢焦点的代价是失去事件派发,要手动拦截。
- 可观测性:GUI App 必须有文件日志,否则没法调。
- 代码签名:证书的三个 X509 扩展缺一不可,OpenSSL 3 要
-legacy。
如果这些坑你正在踩,希望本文能帮你省下一些时间。如果你也在做 macOS 系统级工具开发,欢迎留言交流。
本文基于实际开发过程整理,涉及的关键代码示例均已给出。如有疑问或想讨论某个实现细节,欢迎留言。
浙公网安备 33010602011771号