Mac 剪贴板自动同步(上行)+ 菜单栏快捷(Phase 4a)

- 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
This commit is contained in:
2026-07-07 23:53:52 +08:00
parent 5bee5b8942
commit 492563ae44
4 changed files with 148 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
#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
+7
View File
@@ -31,6 +31,13 @@ struct MacMenuContent: View
.foregroundStyle(.secondary)
}
if auth.session != nil
{
Divider()
Button("上传本机剪贴板") { engine.uploadClipboard() }
Button("拉取云剪贴板") { engine.pullClipboard() }
}
Divider()
Button("打开主窗口") { openWindow(id: "main") }
Button("退出") { NSApplication.shared.terminate(nil) }
+13
View File
@@ -9,6 +9,7 @@ struct MacRootView: View
{
@Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth
@AppStorage("macClipboardAutoSync") private var autoSync = true
@State private var selection: MacSection = .transfers
var body: some View
@@ -54,6 +55,16 @@ struct MacRootView: View
{
NoticeOverlay()
}
// 剪贴板自动同步(上行):登录进壳即按开关启停;开关变化即时生效;登出(本视图消失)停。
.onAppear { syncClipboardMonitor() }
.onChange(of: autoSync) { syncClipboardMonitor() }
.onDisappear { MacClipboardMonitor.shared.stop() }
}
private func syncClipboardMonitor()
{
if autoSync { MacClipboardMonitor.shared.start { engine.uploadClipboardText($0) } }
else { MacClipboardMonitor.shared.stop() }
}
@ViewBuilder
@@ -198,6 +209,7 @@ struct MacSettingsView: View
{
@Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth
@AppStorage("macClipboardAutoSync") private var autoSync = true
@State private var nameDraft = DeviceNameStore.value
private var signalingLabel: String
@@ -229,6 +241,7 @@ struct MacSettingsView: View
}
Section("云剪贴板")
{
Toggle("自动同步本机复制(上行)", isOn: $autoSync)
Button("上传本机剪贴板") { engine.uploadClipboard() }
Button("拉取云剪贴板到本机") { engine.pullClipboard() }
}
@@ -558,6 +558,14 @@ final class EngineController: NSObject
sendCommand("clipboardUpload", payload: [ "content": content ])
}
// 指定文本上传云剪贴板(供 Mac 剪贴板自动同步 monitor 直传已解析文本,绕过再读 pasteboard——
// 避免富文本 Handoff 暂存路径等边角;静默,不弹空提示)。
func uploadClipboardText(_ content: String)
{
guard !content.isEmpty else { return }
sendCommand("clipboardUpload", payload: [ "content": content ])
}
// 剪贴板下行:让引擎拉一次云剪贴板,回来写入 UIPasteboard(见 handleNotify "clipboard")。
func pullClipboard()
{
@@ -997,6 +1005,8 @@ extension EngineController: WKScriptMessageHandler
if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
{
#if os(macOS)
// 先武装自写守卫(令剪贴板自动同步 monitor 认出刚拉取写入的内容为自身回声、不再上传),再写。
MacClipboardMonitor.shared.armSelfWrite(content)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(content, forType: .string)
#else