492563ae44
- MacClipboardMonitor:NSPasteboard changeCount 轮询 500ms + ProcessInfo.beginActivity(.userInitiatedAllowingIdleSystemSleep) 防 App Nap,本机复制的普通文本自动上传云剪贴板。移植桌面 desktop/platform/clipboard.go 的 Monitor 回环守卫(selfHash 自写回声消费一次并解除 + lastHash 去重)与 clipboard_darwin.m 过滤(org.nspasteboard.{Concealed,Transient,AutoGenerated} 敏感标记跳过 / Handoff shared-pasteboard 暂存路径跳过 / 富文本从 RTF·RTFD 派生纯文本)
- EngineController:加 uploadClipboardText(_:)(monitor 直传已解析文本,绕过再读 pasteboard 避免暂存路径等边角,静默不弹空提示);macOS 写剪贴板前 armSelfWrite 武装守卫(手动拉取写入的内容不被 monitor 再上传成回环)
- 设置加「自动同步本机复制(上行)」开关(@AppStorage macClipboardAutoSync,默认开);MacRootView 按开关 onAppear/onChange 启停、登出(视图消失)即停;菜单栏加剪贴板上传 / 拉取(登录态)
- 下行自动(远端 → 本机自动写剪贴板)仍待办:共享引擎 JS 仅在手动 clipboardPull 时回灌原生 "clipboard" 通知,远端 SSE clipboard:update 在 WebView 壳不落原生(桌面走 Wails 专路 ApplyRemoteClipboard);补齐须改 web/src 引擎 + prod 部署(Mac 从线上加载 engine.html)+ 按 device_type=macos 门控免误伤 iOS。手动拉取(设置 / 菜单栏)已可用
- 验证:just mac-build(CommilitiaDropMac.app)+ iOS 模拟器(CommilitiaDrop.app)均 BUILD SUCCEEDED
119 lines
4.8 KiB
Swift
119 lines
4.8 KiB
Swift
#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<String> = [
|
||
"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
|