Add a horizontal strip of discovered tether devices to the window: the store polls engine.nearby() every 2s while connected and renders each as a chip with its friendly name + platform icon. Tapping a device calls pair() — sets room to that device's room and reconnects, so you join it without typing a room id. Devices already in your room show "in your room" (disabled). Verified live on macOS: two named LAN devices appear as "tap to join" chips once the app has Local Network permission. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
144 lines
5.0 KiB
Swift
144 lines
5.0 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") ?? "https://tether.pecord.io"
|
|
var room: String = RoomIdentity.derive()
|
|
var feed: [FeedItem] = []
|
|
var nearby: [Peer] = [] // tether devices on the LAN, for the picker
|
|
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 nearbyTask: 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", name: deviceID)
|
|
e.start(handler: b)
|
|
engine = e
|
|
bridge = b
|
|
connected = true
|
|
lastError = nil
|
|
|
|
// Poll the engine's mDNS-discovered device list for the picker.
|
|
nearbyTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(2))
|
|
guard let self, let engine = self.engine else { continue }
|
|
self.nearby = engine.nearby().sorted { $0.name < $1.name }
|
|
}
|
|
}
|
|
|
|
// 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
|
|
nearbyTask?.cancel()
|
|
nearbyTask = nil
|
|
nearby = []
|
|
connected = false
|
|
}
|
|
|
|
/// Join a discovered device's room — the "pair" action from the picker.
|
|
func pair(with peer: Peer) {
|
|
room = peer.room
|
|
connect()
|
|
}
|
|
|
|
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) }
|
|
}
|
|
}
|