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