Drop TetherKit's TetherClient/SSEClient/Message from the macOS app; drive tethercore.Engine instead (RoomIdentity still from TetherKit via selective import). The Mac now gets direct P2P over the RTC data channel for free, and the native Swift app is no longer a parallel wire-protocol implementation. Background NSPasteboard polling (auto-send on copy) and write-on-receive are preserved. Feed UI moves to a FeedItem view-model (RTC messages carry ts=0 → fall back to local time). Verified end-to-end against the live server: RECEIVE (bus → Mac pasteboard via pbpaste) and SEND (pbcopy → bus) both confirmed. Generalize core/build-ios.sh → build-apple.sh: builds iOS device + sim + macOS arm64 into one TetherCore.xcframework and vendors it into both apps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
124 lines
4.3 KiB
Swift
124 lines
4.3 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import enum TetherKit.RoomIdentity // selective: avoids TetherKit.Message clashing
|
|
// with the engine's Message (compiled into this target)
|
|
// Engine + Message + MessageHandler come from the generated tethercore.swift.
|
|
|
|
/// UI-facing clipboard item. Decouples the view from the FFI `Message` record.
|
|
struct FeedItem: Identifiable, Equatable {
|
|
let id = UUID()
|
|
let text: String
|
|
let source: String
|
|
let date: Date
|
|
}
|
|
|
|
/// macOS side: drives the shared `tethercore.Engine` (SSE + RTC). Unlike iOS we
|
|
/// CAN poll NSPasteboard in the background, so clipboard changes auto-send.
|
|
@MainActor @Observable
|
|
final class MacTetherStore {
|
|
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "http://tether.lan:8765"
|
|
var room: String = RoomIdentity.derive()
|
|
var feed: [FeedItem] = []
|
|
var connected = false
|
|
var lastError: String?
|
|
var hasAttemptedConnect = false
|
|
|
|
private let deviceID = Host.current().localizedName ?? UUID().uuidString
|
|
private var engine: Engine?
|
|
private var bridge: EngineBridge?
|
|
private var pollTask: Task<Void, Never>?
|
|
private var lastChangeCount = NSPasteboard.general.changeCount
|
|
|
|
func connectIfSaved() {
|
|
guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return }
|
|
connect()
|
|
}
|
|
|
|
func connect() {
|
|
let url = serverURL.trimmingCharacters(in: .whitespaces)
|
|
guard !url.isEmpty else {
|
|
hasAttemptedConnect = true
|
|
lastError = "Invalid server URL."
|
|
return
|
|
}
|
|
hasAttemptedConnect = true
|
|
UserDefaults.standard.set(serverURL, forKey: "serverURL")
|
|
disconnect()
|
|
|
|
let b = EngineBridge(store: self)
|
|
let e = Engine(server: url, room: room, from: deviceID, source: "macos")
|
|
e.start(handler: b)
|
|
engine = e
|
|
bridge = b
|
|
connected = true
|
|
lastError = nil
|
|
|
|
// Send: poll NSPasteboard for local changes → push to room.
|
|
lastChangeCount = NSPasteboard.general.changeCount
|
|
pollTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
guard let self else { return }
|
|
let count = NSPasteboard.general.changeCount
|
|
guard count != self.lastChangeCount else { continue }
|
|
self.lastChangeCount = count
|
|
if let text = NSPasteboard.general.string(forType: .string), !text.isEmpty {
|
|
self.engine?.send(text: text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func disconnect() {
|
|
engine?.stop()
|
|
engine = nil
|
|
bridge = nil
|
|
pollTask?.cancel()
|
|
pollTask = nil
|
|
connected = false
|
|
}
|
|
|
|
func send(_ text: String) {
|
|
engine?.send(text: text)
|
|
}
|
|
|
|
func copyToPasteboard(_ item: FeedItem) {
|
|
writePasteboard(item.text)
|
|
}
|
|
|
|
private func writePasteboard(_ text: String) {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
lastChangeCount = NSPasteboard.general.changeCount // don't echo our own write back
|
|
}
|
|
|
|
// Called from the engine bridge, already on the main actor.
|
|
fileprivate func ingest(_ item: FeedItem) {
|
|
feed.insert(item, at: 0)
|
|
if feed.count > 100 { feed.removeLast() }
|
|
writePasteboard(item.text) // mirror received clipboard onto this Mac
|
|
}
|
|
|
|
fileprivate func setConnected(_ value: Bool) {
|
|
connected = value
|
|
}
|
|
}
|
|
|
|
/// Adapts the engine's callback interface (fired on the Rust runtime thread)
|
|
/// onto the main actor for SwiftUI.
|
|
private final class EngineBridge: MessageHandler {
|
|
weak var store: MacTetherStore?
|
|
init(store: MacTetherStore) { self.store = store }
|
|
|
|
func onMessage(msg: Message) {
|
|
// RTC-delivered messages carry ts=0 (no server stamp) — use now.
|
|
let date = msg.ts > 0 ? Date(timeIntervalSince1970: Double(msg.ts) / 1000.0) : Date()
|
|
let item = FeedItem(text: msg.text, source: msg.source.isEmpty ? "unknown" : msg.source, date: date)
|
|
Task { @MainActor in self.store?.ingest(item) }
|
|
}
|
|
|
|
func onStatus(connected: Bool) {
|
|
Task { @MainActor in self.store?.setConnected(connected) }
|
|
}
|
|
}
|