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:
@@ -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
|
||||||
@@ -31,6 +31,13 @@ struct MacMenuContent: View
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if auth.session != nil
|
||||||
|
{
|
||||||
|
Divider()
|
||||||
|
Button("上传本机剪贴板") { engine.uploadClipboard() }
|
||||||
|
Button("拉取云剪贴板") { engine.pullClipboard() }
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
Button("打开主窗口") { openWindow(id: "main") }
|
Button("打开主窗口") { openWindow(id: "main") }
|
||||||
Button("退出") { NSApplication.shared.terminate(nil) }
|
Button("退出") { NSApplication.shared.terminate(nil) }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct MacRootView: View
|
|||||||
{
|
{
|
||||||
@Environment(EngineController.self) private var engine
|
@Environment(EngineController.self) private var engine
|
||||||
@Environment(AuthManager.self) private var auth
|
@Environment(AuthManager.self) private var auth
|
||||||
|
@AppStorage("macClipboardAutoSync") private var autoSync = true
|
||||||
@State private var selection: MacSection = .transfers
|
@State private var selection: MacSection = .transfers
|
||||||
|
|
||||||
var body: some View
|
var body: some View
|
||||||
@@ -54,6 +55,16 @@ struct MacRootView: View
|
|||||||
{
|
{
|
||||||
NoticeOverlay()
|
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
|
@ViewBuilder
|
||||||
@@ -198,6 +209,7 @@ struct MacSettingsView: View
|
|||||||
{
|
{
|
||||||
@Environment(EngineController.self) private var engine
|
@Environment(EngineController.self) private var engine
|
||||||
@Environment(AuthManager.self) private var auth
|
@Environment(AuthManager.self) private var auth
|
||||||
|
@AppStorage("macClipboardAutoSync") private var autoSync = true
|
||||||
@State private var nameDraft = DeviceNameStore.value
|
@State private var nameDraft = DeviceNameStore.value
|
||||||
|
|
||||||
private var signalingLabel: String
|
private var signalingLabel: String
|
||||||
@@ -229,6 +241,7 @@ struct MacSettingsView: View
|
|||||||
}
|
}
|
||||||
Section("云剪贴板")
|
Section("云剪贴板")
|
||||||
{
|
{
|
||||||
|
Toggle("自动同步本机复制(上行)", isOn: $autoSync)
|
||||||
Button("上传本机剪贴板") { engine.uploadClipboard() }
|
Button("上传本机剪贴板") { engine.uploadClipboard() }
|
||||||
Button("拉取云剪贴板到本机") { engine.pullClipboard() }
|
Button("拉取云剪贴板到本机") { engine.pullClipboard() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -558,6 +558,14 @@ final class EngineController: NSObject
|
|||||||
sendCommand("clipboardUpload", payload: [ "content": content ])
|
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")。
|
// 剪贴板下行:让引擎拉一次云剪贴板,回来写入 UIPasteboard(见 handleNotify "clipboard")。
|
||||||
func pullClipboard()
|
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 let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
|
||||||
{
|
{
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
// 先武装自写守卫(令剪贴板自动同步 monitor 认出刚拉取写入的内容为自身回声、不再上传),再写。
|
||||||
|
MacClipboardMonitor.shared.armSelfWrite(content)
|
||||||
NSPasteboard.general.clearContents()
|
NSPasteboard.general.clearContents()
|
||||||
NSPasteboard.general.setString(content, forType: .string)
|
NSPasteboard.general.setString(content, forType: .string)
|
||||||
#else
|
#else
|
||||||
|
|||||||
Reference in New Issue
Block a user