MenuBarExtra SwiftUI app sharing TetherKit wire layer with iOS. Auto-sends clipboard changes via NSPasteboard polling (500ms), auto-writes received messages to NSPasteboard. XcodeGen project.yml. Both apps build clean; server tested end-to-end with curl. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
89 lines
3.3 KiB
Swift
89 lines
3.3 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import TetherKit
|
|
|
|
/// macOS side: same TetherClient wire layer, but we CAN poll NSPasteboard
|
|
/// in the background — so clipboard changes auto-send without user action.
|
|
@MainActor @Observable
|
|
final class MacTetherStore {
|
|
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "http://tether.lan:8765"
|
|
var room: String = UserDefaults.standard.string(forKey: "room") ?? ""
|
|
var feed: [Message] = []
|
|
var connected = false
|
|
var lastError: String?
|
|
|
|
private let deviceID = Host.current().localizedName ?? UUID().uuidString
|
|
private var client: TetherClient?
|
|
private var receiveTask: Task<Void, Never>?
|
|
private var pollTask: Task<Void, Never>?
|
|
private var lastChangeCount = NSPasteboard.general.changeCount
|
|
|
|
func connect() {
|
|
guard let base = URL(string: serverURL.trimmingCharacters(in: .whitespaces)),
|
|
!room.isEmpty else {
|
|
lastError = "Set a server URL and room id."
|
|
return
|
|
}
|
|
UserDefaults.standard.set(serverURL, forKey: "serverURL")
|
|
UserDefaults.standard.set(room, forKey: "room")
|
|
disconnect()
|
|
|
|
let source = "macos-\(ProcessInfo.processInfo.hostName)"
|
|
let cfg = TetherClient.Config(baseURL: base, room: room,
|
|
from: deviceID, source: "macos-app")
|
|
let c = TetherClient(config: cfg)
|
|
client = c
|
|
connected = true
|
|
lastError = nil
|
|
|
|
// Receive: clipboard messages from peers → write to NSPasteboard.
|
|
receiveTask = Task {
|
|
for await msg in await c.clipboardStream() {
|
|
feed.insert(msg, at: 0)
|
|
if feed.count > 100 { feed.removeLast() }
|
|
if let text = msg.text {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
lastChangeCount = NSPasteboard.general.changeCount // don't echo back
|
|
}
|
|
}
|
|
connected = false
|
|
}
|
|
|
|
// Send: poll NSPasteboard for local changes → push to room.
|
|
lastChangeCount = NSPasteboard.general.changeCount
|
|
pollTask = Task {
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
let count = NSPasteboard.general.changeCount
|
|
guard count != lastChangeCount else { continue }
|
|
lastChangeCount = count
|
|
if let text = NSPasteboard.general.string(forType: .string), !text.isEmpty {
|
|
try? await c.send(text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func disconnect() {
|
|
receiveTask?.cancel(); receiveTask = nil
|
|
pollTask?.cancel(); pollTask = nil
|
|
connected = false
|
|
}
|
|
|
|
func send(_ text: String) {
|
|
guard let c = client else { return }
|
|
Task {
|
|
do { try await c.send(text) }
|
|
catch { lastError = "Send failed: \(error.localizedDescription)" }
|
|
}
|
|
}
|
|
|
|
func copyToPasteboard(_ msg: Message) {
|
|
guard let text = msg.text else { return }
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
lastChangeCount = NSPasteboard.general.changeCount
|
|
}
|
|
}
|