From 492563ae44fc45f29ea49becc5c8ecada8e5b810 Mon Sep 17 00:00:00 2001 From: commilitia Date: Tue, 7 Jul 2026 23:53:52 +0800 Subject: [PATCH] =?UTF-8?q?Mac=20=E5=89=AA=E8=B4=B4=E6=9D=BF=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=90=8C=E6=AD=A5=EF=BC=88=E4=B8=8A=E8=A1=8C=EF=BC=89?= =?UTF-8?q?+=20=E8=8F=9C=E5=8D=95=E6=A0=8F=E5=BF=AB=E6=8D=B7=EF=BC=88Phase?= =?UTF-8?q?=204a=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ios/CDrop/MacApp/MacClipboardMonitor.swift | 118 ++++++++++++++++++ ios/CDrop/MacApp/MacMenuContent.swift | 7 ++ ios/CDrop/MacApp/MacRootView.swift | 13 ++ .../Sources/Engine/EngineController.swift | 10 ++ 4 files changed, 148 insertions(+) create mode 100644 ios/CDrop/MacApp/MacClipboardMonitor.swift diff --git a/ios/CDrop/MacApp/MacClipboardMonitor.swift b/ios/CDrop/MacApp/MacClipboardMonitor.swift new file mode 100644 index 0000000..cd084b3 --- /dev/null +++ b/ios/CDrop/MacApp/MacClipboardMonitor.swift @@ -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 = [ + "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 diff --git a/ios/CDrop/MacApp/MacMenuContent.swift b/ios/CDrop/MacApp/MacMenuContent.swift index d8eb18c..0d3f074 100644 --- a/ios/CDrop/MacApp/MacMenuContent.swift +++ b/ios/CDrop/MacApp/MacMenuContent.swift @@ -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) } diff --git a/ios/CDrop/MacApp/MacRootView.swift b/ios/CDrop/MacApp/MacRootView.swift index 324dd4e..cdd77c0 100644 --- a/ios/CDrop/MacApp/MacRootView.swift +++ b/ios/CDrop/MacApp/MacRootView.swift @@ -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() } } diff --git a/ios/CDrop/Sources/Engine/EngineController.swift b/ios/CDrop/Sources/Engine/EngineController.swift index eb5f425..0cf4a74 100644 --- a/ios/CDrop/Sources/Engine/EngineController.swift +++ b/ios/CDrop/Sources/Engine/EngineController.swift @@ -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