#if os(macOS) import AppKit import CryptoKit import Foundation // Mac 剪贴板自动同步(上行):轮询 NSPasteboard.general.changeCount(~500ms),本机复制的普通文本 // 自动上传云剪贴板。移植桌面 desktop/platform/clipboard.go 的 Monitor 策略(selfHash 回环守卫 + // lastHash 去重)与 clipboard_darwin.m 的过滤(org.nspasteboard.{Concealed,Transient,AutoGenerated} // 敏感标记跳过、Handoff/shared-pasteboard 暂存路径跳过、富文本从 RTF/RTFD 派生纯文本)。beginActivity // 防 App Nap 挂起轮询。下行(远端 → 本机)仍走手动拉取(设置 / 菜单栏)——自动下行须引擎 JS 侧改动 + // prod 部署(Mac 从线上加载 engine.html),见 handoff。 final class MacClipboardMonitor { static let shared = MacClipboardMonitor() private init() {} // 采纳文本上传去哪:app 在 start 时接好(→ engine.uploadClipboardText)。 private var upload: ((String) -> Void)? private var timer: Timer? private var activity: NSObjectProtocol? private var lastChangeCount = 0 private var lastHash = "" // 上次采纳文本的哈希(去重) private var selfHash = "" // 最近一次自写文本的哈希(回环守卫) private static let pollInterval: TimeInterval = 0.5 var isRunning: Bool { timer != nil } func start(upload: @escaping (String) -> Void) { self.upload = upload guard timer == nil else { return } lastChangeCount = NSPasteboard.general.changeCount // 起始不追认当前已有内容 // 防 App Nap:非前台时系统会挂起 Timer,此 activity 令轮询在后台仍运行。 activity = ProcessInfo.processInfo.beginActivity( options: .userInitiatedAllowingIdleSystemSleep, reason: "clipboard sync") let t = Timer(timeInterval: Self.pollInterval, repeats: true) { [weak self] _ in self?.poll() } RunLoop.main.add(t, forMode: .common) timer = t } func stop() { timer?.invalidate() timer = nil if let activity { ProcessInfo.processInfo.endActivity(activity) } activity = nil } // 引擎写本机剪贴板(下行 / 手动拉取)前调用:把即将写入的内容标为「自写」,使随之而来的 // changeCount 变化被识别为自身回声、不再上传。对齐桌面 Monitor.WillWrite。 func armSelfWrite(_ text: String) { selfHash = Self.hash(text) } private func poll() { let cc = NSPasteboard.general.changeCount guard cc != lastChangeCount else { return } lastChangeCount = cc guard let text = Self.readText() else { return } // 非文本 / 敏感 / 暂存路径 → 忽略 let h = Self.hash(text) if h == selfHash { selfHash = ""; lastHash = h; return } // 自写回声:消费一次并解除守卫 if h == lastHash { return } // 内容未变 lastHash = h upload?(text) } // 读剪贴板纯文本 + 过滤(移植 clipboard_darwin.m)。 private static func readText() -> String? { let pb = NSPasteboard.general let sensitive: Set = [ "org.nspasteboard.ConcealedType", "org.nspasteboard.TransientType", "org.nspasteboard.AutoGeneratedType", ] if let types = pb.types, types.contains(where: { sensitive.contains($0.rawValue) }) { return nil } var s = pb.string(forType: .string) if s == nil || isStagingPath(s!) { s = plainFromRich(pb) } guard let out = s, !isStagingPath(out), !out.isEmpty else { return nil } return out } // Universal Clipboard / Handoff 暂存路径(富文本复制时纯文本可能是 .../shared-pasteboard/xxx.rtfd // 这类路径而非内容),绝不当剪贴板文本上传。 private static func isStagingPath(_ s: String) -> Bool { s.contains("/shared-pasteboard/") || s.contains("com.apple.coreservices.useractivityd") } private static func plainFromRich(_ pb: NSPasteboard) -> String? { for (type, doc) in [ (NSPasteboard.PasteboardType.rtfd, NSAttributedString.DocumentType.rtfd), (NSPasteboard.PasteboardType.rtf, NSAttributedString.DocumentType.rtf) ] { if let data = pb.data(forType: type), let a = try? NSAttributedString(data: data, options: [ .documentType: doc ], documentAttributes: nil) { return a.string } } return nil } private static func hash(_ s: String) -> String { SHA256.hash(data: Data(s.utf8)).map { String(format: "%02x", $0) }.joined() } } #endif