Files
tether/ios/MacTetherApp/Sources/MacTetherStore.swift
Patrick Ecord db64b3b9a6 macos: migrate app to the shared tethercore engine (SSE + RTC)
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>
2026-06-20 23:31:54 -05:00

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) }
}
}