用 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)
屏幕底部原点 左下角 NSWindowNSScreen
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 在面板里不生效。我试了 NSTrackingAreaNSEvent.addLocalMonitorForEvents,都不灵。

最终方案是手动拦截事件(CGEventTapsendEvent 重写),绕过 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 各种"设计如此"的机制搏斗:

  1. TCC 权限:adhoc 签名 → cdhash 变 → Screen Recording 失效且无法重弹。固定自签名证书是根治方案。
  2. 截屏坐标:多屏 + Retina + 翻转坐标系,按屏逐个截取最稳。
  3. 非激活面板:不抢焦点的代价是失去事件派发,要手动拦截。
  4. 可观测性:GUI App 必须有文件日志,否则没法调。
  5. 代码签名:证书的三个 X509 扩展缺一不可,OpenSSL 3 要 -legacy

如果这些坑你正在踩,希望本文能帮你省下一些时间。如果你也在做 macOS 系统级工具开发,欢迎留言交流。


本文基于实际开发过程整理,涉及的关键代码示例均已给出。如有疑问或想讨论某个实现细节,欢迎留言。

posted @ 2026-06-24 02:40  finfou的自留地  阅读(0)  评论(0)    收藏  举报